Files
fonrey/apps/client/migrations/0001_initial.py
Sisyphus ed40de4050 feat(client,setting): complete Phase 2 with partitioned client_follow_logs
- apps/client (11 models): Client, ClientContact, ClientRequirement,
  ClientSchoolPreference, ClientFollowLog (partitioned),
  ClientFollowLogAttachment, ClientViewing, ClientPropertyMatch,
  ClientStatusLog, ClientFavoriteFolder, ClientFolderItem
- apps/client/0002 RunSQL: PARTITION BY RANGE(created_at) for
  client_follow_logs + monthly partitions + default; triggers
  update_client_last_follow + update_client_viewing_progress;
  partial unique index on client_no WHERE deleted_at IS NULL
- apps/setting (4 models): LookupGroup, LookupItem, TenantSetting,
  FieldRequirementRule (tenant schema only per spec)

manage.py check green; all 9 Phase 2 apps complete.
2026-04-29 17:33:58 +08:00

352 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Generated by Django 4.2.16 on 2026-04-29 09:31
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
('org', '0001_initial'),
('fonrey_property', '0002_partitions_and_triggers'),
]
operations = [
migrations.CreateModel(
name='ClientFollowLog',
fields=[
('id', models.UUIDField(primary_key=True, serialize=False)),
('created_at', models.DateTimeField()),
('log_type', models.CharField(choices=[('written', '写入跟进'), ('modified', '修改跟进'), ('sensitive_view', '敏感查看'), ('other', '其他'), ('system', '系统')], max_length=30)),
('purpose', models.CharField(blank=True, default='', max_length=50)),
('content', models.TextField(blank=True, default='')),
('log_tag', models.CharField(blank=True, default='', max_length=50)),
('change_detail', models.JSONField(blank=True, null=True)),
('is_public', models.BooleanField(default=True)),
('is_deletable', models.BooleanField(default=True)),
('operator_snapshot', models.JSONField(blank=True, null=True)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
],
options={
'db_table': 'client_follow_logs',
'managed': False,
},
),
migrations.CreateModel(
name='Client',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('deleted_at', models.DateTimeField(blank=True, db_index=True, null=True)),
('client_no', models.CharField(max_length=30, unique=True)),
('client_type', models.CharField(choices=[('private', '私客'), ('public', '公客'), ('transacted', '成交客')], default='private', max_length=20)),
('status', models.CharField(choices=[('buying', '求购'), ('renting', '求租'), ('buy_or_rent', '租购'), ('suspended', '暂缓'), ('bought', '已购'), ('rented_done', '已租'), ('public', '公客'), ('invalid', '无效')], default='buying', max_length=20)),
('grade', models.CharField(choices=[('A', 'A急迫'), ('B', 'B较强'), ('C', 'C一般'), ('D', 'D较弱'), ('E', 'E暂不关注')], default='C', max_length=5)),
('property_usage', models.CharField(choices=[('residential', '住宅'), ('villa', '别墅'), ('commercial_residential', '商住'), ('shop', '商铺'), ('office', '写字楼'), ('other', '其他')], default='residential', max_length=30)),
('buying_purpose', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rigid', '刚需'), ('investment', '投资'), ('school_district', '学区'), ('upgrade', '改善'), ('commercial', '商用'), ('other', '其他')], max_length=20), blank=True, default=list, size=None)),
('payment_method', models.CharField(blank=True, choices=[('full', '全额'), ('mortgage', '商业贷款'), ('mortgage_fund', '商贷+公积金'), ('fund', '公积金')], default='', max_length=30)),
('properties_owned', models.CharField(blank=True, choices=[('none', ''), ('local_none', '本地无/外地有'), ('local_has', '本地有')], default='', max_length=20)),
('has_loan_record', models.BooleanField(blank=True, null=True)),
('id_type', models.CharField(blank=True, choices=[('id_card', '身份证'), ('passport', '护照'), ('hk_macao', '港澳通行证'), ('other', '其他')], default='', max_length=20)),
('id_number_enc', models.BinaryField(blank=True, null=True)),
('source', models.CharField(blank=True, default='', max_length=50)),
('remarks', models.TextField(blank=True, default='')),
('is_starred', models.BooleanField(default=False)),
('is_pinned', models.BooleanField(default=False)),
('is_big_value', models.BooleanField(default=False)),
('is_protected', models.BooleanField(default=False)),
('prefers_new_house', models.BooleanField(blank=True, null=True)),
('transfer_to_public_type', models.CharField(blank=True, choices=[('manual', '手动转公'), ('auto', '自动转公'), ('marketing_jump', '营销客跳公'), ('resource_public', '资料客素公')], default='', max_length=20)),
('transferred_public_at', models.DateTimeField(blank=True, null=True)),
('invalid_reason', models.CharField(blank=True, choices=[('invalid_phone', '号码无效'), ('peer_agent', '同行'), ('ad', '广告推销'), ('no_intent', '无意向'), ('other', '其他')], default='', max_length=30)),
('invalidated_at', models.DateTimeField(blank=True, null=True)),
('transacted_at', models.DateField(blank=True, null=True)),
('transacted_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('transacted_type', models.CharField(blank=True, choices=[('bought', '我购'), ('rented', '我租')], default='', max_length=20)),
('transacted_property_type', models.CharField(blank=True, choices=[('second_hand', '二手'), ('new_house', '新房')], default='', max_length=20)),
('activity_level', models.CharField(blank=True, choices=[('new_matched', '新配对'), ('active_7d', '7日活跃'), ('active_30d', '30日活跃'), ('active_90d', '90日活跃'), ('expiring', '即将过期'), ('frozen', '暂缓中'), ('invalid', '无效')], default='', max_length=20)),
('last_active_at', models.DateTimeField(blank=True, null=True)),
('last_follow_at', models.DateTimeField(blank=True, null=True)),
('commission_date', models.DateField(blank=True, null=True)),
('entrust_count', models.SmallIntegerField(default=1)),
('version', models.IntegerField(default=1)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created', to='org.staff')),
('first_recorder', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='first_recorded_clients', to='org.staff')),
('org_unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='clients', to='org.orgunit')),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_clients', to='org.staff')),
('transacted_property', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transacted_clients', to='fonrey_property.property')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated', to='org.staff')),
],
options={
'db_table': 'clients',
},
),
migrations.CreateModel(
name='ClientFavoriteFolder',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=10)),
('is_default', models.BooleanField(default=False)),
('sort_order', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_folders', to='org.staff')),
],
options={
'db_table': 'client_favorite_folders',
},
),
migrations.CreateModel(
name='ClientRequirement',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('requirement_type', models.CharField(choices=[('second_hand', '二手'), ('new_house', '新房'), ('rental', '租房')], max_length=20)),
('is_primary', models.BooleanField(default=True)),
('budget_min', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('budget_max', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('area_min', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True)),
('area_max', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True)),
('bedroom_counts', django.contrib.postgres.fields.ArrayField(base_field=models.SmallIntegerField(), blank=True, default=list, size=None)),
('floor_preferences', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('no_first', '不要一楼'), ('low', '低楼层'), ('mid', '中楼层'), ('high', '高楼层'), ('no_top', '不要顶楼')], max_length=20), blank=True, default=list, size=None)),
('orientations', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('east', ''), ('south', ''), ('west', '西'), ('north', '')], max_length=10), blank=True, default=list, size=None)),
('decorations', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rough', '毛坯'), ('plain', '清水'), ('simple', '简装'), ('medium', '中装'), ('fine', '精装'), ('luxury', '豪装')], max_length=10), blank=True, default=list, size=None)),
('building_age_ranges', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('within_5y', '5年内'), ('5_10y', '5-10年'), ('10_15y', '10-15年'), ('15_20y', '15-20年'), ('over_20y', '20年以上')], max_length=20), blank=True, default=list, size=None)),
('intent_district_ids', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(), blank=True, default=list, size=None)),
('intent_business_area_ids', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(), blank=True, default=list, size=None)),
('intent_complex_names', models.TextField(blank=True, default='')),
('transportation', models.CharField(blank=True, default='', max_length=50)),
('intent_school_names', models.TextField(blank=True, default='')),
('school_enrollment_date', models.DateField(blank=True, null=True)),
('traffic_preference', models.TextField(blank=True, default='')),
('requirement_notes', models.CharField(blank=True, default='', max_length=200)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requirements', to='fonrey_client.client')),
],
options={
'db_table': 'client_requirements',
},
),
migrations.CreateModel(
name='ClientPropertyMatch',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('match_source', models.CharField(choices=[('recorded', '录客配房'), ('system', '系统配房')], default='recorded', max_length=20)),
('match_group', models.CharField(blank=True, choices=[('quality_layout', '优质户型'), ('price_reduced', '降价'), ('hot', '热门'), ('newly_listed', '新上')], default='', max_length=30)),
('match_score', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
('match_reasons', models.JSONField(blank=True, null=True)),
('status', models.CharField(choices=[('suggested', '待推送'), ('shared', '已分享'), ('rejected', '已反馈不合适'), ('viewed', '客户已查看')], default='suggested', max_length=20)),
('shared_at', models.DateTimeField(blank=True, null=True)),
('feedback', models.CharField(blank=True, default='', max_length=50)),
('calculated_at', models.DateTimeField(auto_now_add=True)),
('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='property_matches', to='fonrey_client.client')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_matches', to='org.staff')),
('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='client_matches', to='fonrey_property.property')),
],
options={
'db_table': 'client_property_matches',
},
),
migrations.CreateModel(
name='ClientFollowLogAttachment',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('follow_log_id', models.UUIDField()),
('file_key', models.TextField()),
('file_name', models.CharField(max_length=255)),
('file_size', models.IntegerField()),
('file_type', models.CharField(blank=True, default='', max_length=10)),
('has_location', models.BooleanField(default=False)),
('sort_order', models.SmallIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'client_follow_log_attachments',
'indexes': [models.Index(fields=['follow_log_id'], name='idx_cfla_log')],
},
),
migrations.CreateModel(
name='ClientFolderItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('added_at', models.DateTimeField(auto_now_add=True)),
('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='folder_items', to='fonrey_client.client')),
('folder', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='fonrey_client.clientfavoritefolder')),
],
options={
'db_table': 'client_folder_items',
},
),
migrations.CreateModel(
name='ClientContact',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('sort_order', models.SmallIntegerField(default=0)),
('name', models.CharField(max_length=50)),
('gender', models.CharField(choices=[('male', '先生'), ('female', '女士')], default='male', max_length=10)),
('phone_enc', models.BinaryField()),
('phone_hash', models.CharField(max_length=64)),
('phone_country_code', models.CharField(default='+86', max_length=10)),
('phone_is_invalid', models.BooleanField(default=False)),
('phone2_enc', models.BinaryField(blank=True, null=True)),
('phone2_hash', models.CharField(blank=True, default='', max_length=64)),
('wechat', models.CharField(blank=True, default='', max_length=100)),
('qq', models.CharField(blank=True, default='', max_length=20)),
('remarks', models.CharField(blank=True, default='', max_length=200)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contacts', to='fonrey_client.client')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_client_contacts', to='org.staff')),
],
options={
'db_table': 'client_contacts',
},
),
migrations.CreateModel(
name='ClientViewing',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('viewing_type', models.CharField(choices=[('appointment', '预约'), ('viewing', '带看'), ('revisit', '复看'), ('empty', '空看')], default='viewing', max_length=20)),
('companion_ids', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(), blank=True, default=list, size=None)),
('cooperator_ids', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(), blank=True, default=list, size=None)),
('scheduled_at', models.DateTimeField(blank=True, null=True)),
('viewing_start_at', models.DateTimeField(blank=True, null=True)),
('viewing_end_at', models.DateTimeField(blank=True, null=True)),
('situation', models.TextField(blank=True, default='')),
('client_intent', models.CharField(blank=True, choices=[('interested', '感兴趣'), ('not_interested', '不感兴趣'), ('negotiating', '谈判中'), ('cancelled', '取消')], default='', max_length=20)),
('viewing_progress', models.SmallIntegerField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('agent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='led_viewings', to='org.staff')),
('client', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='viewings', to='fonrey_client.client')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_client_viewings', to='org.staff')),
('property', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='client_viewings', to='fonrey_property.property')),
],
options={
'db_table': 'client_viewings',
'indexes': [models.Index(fields=['client', '-viewing_start_at'], name='idx_cv_client_time'), models.Index(fields=['property'], name='idx_cv_property'), models.Index(fields=['agent'], name='idx_cv_agent')],
},
),
migrations.CreateModel(
name='ClientStatusLog',
fields=[
('id', models.UUIDField(primary_key=True, serialize=False)),
('change_type', models.CharField(choices=[('status_change', '改状态'), ('grade_change', '改等级'), ('to_public', '转公客'), ('to_transacted', '转成交'), ('to_invalid', '转无效'), ('owner_change', '改归属人'), ('source_change', '改来源'), ('merge', '合并客源')], max_length=30)),
('old_value', models.JSONField(blank=True, null=True)),
('new_value', models.JSONField(blank=True, null=True)),
('reason', models.TextField(blank=True, default='')),
('operated_at', models.DateTimeField(auto_now_add=True)),
('client', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='status_logs', to='fonrey_client.client')),
('operator', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='client_status_changes', to='org.staff')),
],
options={
'db_table': 'client_status_logs',
'indexes': [models.Index(fields=['client', '-operated_at'], name='idx_csl_client'), models.Index(fields=['change_type', '-operated_at'], name='idx_csl_type')],
},
),
migrations.CreateModel(
name='ClientSchoolPreference',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('school_id', models.UUIDField(blank=True, null=True)),
('school_name', models.CharField(max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('requirement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='school_preferences', to='fonrey_client.clientrequirement')),
],
options={
'db_table': 'client_school_preferences',
'indexes': [models.Index(fields=['requirement'], name='idx_csp_requirement')],
},
),
migrations.AddIndex(
model_name='clientrequirement',
index=models.Index(fields=['client'], name='idx_creq_client'),
),
migrations.AddIndex(
model_name='clientrequirement',
index=models.Index(fields=['requirement_type', 'client'], name='idx_creq_type'),
),
migrations.AddIndex(
model_name='clientrequirement',
index=models.Index(fields=['budget_min', 'budget_max'], name='idx_creq_budget'),
),
migrations.AddIndex(
model_name='clientrequirement',
index=models.Index(fields=['area_min', 'area_max'], name='idx_creq_area'),
),
migrations.AddIndex(
model_name='clientpropertymatch',
index=models.Index(fields=['client', 'match_source', 'match_group'], name='idx_cpm_client_grp'),
),
migrations.AddIndex(
model_name='clientpropertymatch',
index=models.Index(fields=['client', 'status'], name='idx_cpm_status'),
),
migrations.AddConstraint(
model_name='clientpropertymatch',
constraint=models.UniqueConstraint(fields=('client', 'property'), name='uq_client_match_pair'),
),
migrations.AddIndex(
model_name='clientfolderitem',
index=models.Index(fields=['client'], name='idx_cfi_client'),
),
migrations.AddConstraint(
model_name='clientfolderitem',
constraint=models.UniqueConstraint(fields=('folder', 'client'), name='uq_cfi_folder_client'),
),
migrations.AddIndex(
model_name='clientfavoritefolder',
index=models.Index(fields=['staff'], name='idx_cff_staff'),
),
migrations.AddConstraint(
model_name='clientfavoritefolder',
constraint=models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True), ('is_default', True)), fields=('staff',), name='uq_cff_default_per_staff'),
),
migrations.AddIndex(
model_name='clientcontact',
index=models.Index(fields=['phone_hash'], name='idx_cc_phone_hash'),
),
migrations.AddIndex(
model_name='clientcontact',
index=models.Index(fields=['phone2_hash'], name='idx_cc_phone2_hash'),
),
migrations.AddIndex(
model_name='clientcontact',
index=models.Index(fields=['client'], name='idx_cc_client'),
),
migrations.AddIndex(
model_name='client',
index=models.Index(fields=['client_type', 'status'], name='idx_clients_type_stat'),
),
migrations.AddIndex(
model_name='client',
index=models.Index(fields=['owner'], name='idx_clients_owner'),
),
migrations.AddIndex(
model_name='client',
index=models.Index(fields=['org_unit'], name='idx_clients_org_unit'),
),
migrations.AddIndex(
model_name='client',
index=models.Index(fields=['activity_level', '-last_active_at'], name='idx_clients_activity'),
),
migrations.AddIndex(
model_name='client',
index=models.Index(fields=['grade'], name='idx_clients_grade'),
),
migrations.AddIndex(
model_name='client',
index=models.Index(fields=['-transferred_public_at'], name='idx_clients_transferred'),
),
migrations.AddIndex(
model_name='client',
index=models.Index(fields=['-last_follow_at'], name='idx_clients_last_follow'),
),
]