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.
This commit is contained in:
351
apps/client/migrations/0001_initial.py
Normal file
351
apps/client/migrations/0001_initial.py
Normal file
@@ -0,0 +1,351 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
99
apps/client/migrations/0002_partitions_and_triggers.py
Normal file
99
apps/client/migrations/0002_partitions_and_triggers.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from django.db import migrations
|
||||
|
||||
CREATE_CLIENT_FOLLOW_LOGS = """
|
||||
CREATE TABLE client_follow_logs (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
log_type VARCHAR(30) NOT NULL
|
||||
CHECK (log_type IN ('written','modified','sensitive_view',
|
||||
'other','system')),
|
||||
purpose VARCHAR(50),
|
||||
content TEXT,
|
||||
log_tag VARCHAR(50),
|
||||
change_detail JSONB,
|
||||
is_public BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_deletable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
operator_id UUID REFERENCES staff(id) ON DELETE SET NULL,
|
||||
operator_snapshot JSONB,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (id, created_at)
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE client_follow_logs_2026_04 PARTITION OF client_follow_logs
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
CREATE TABLE client_follow_logs_2026_05 PARTITION OF client_follow_logs
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE client_follow_logs_default PARTITION OF client_follow_logs DEFAULT;
|
||||
|
||||
CREATE INDEX idx_cfl_client_time ON client_follow_logs(client_id, created_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_cfl_type ON client_follow_logs(client_id, log_type, created_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_cfl_operator ON client_follow_logs(operator_id, created_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_cfl_sensitive ON client_follow_logs(client_id, created_at DESC)
|
||||
WHERE log_type = 'sensitive_view';
|
||||
"""
|
||||
|
||||
DROP_CLIENT_FOLLOW_LOGS = "DROP TABLE IF EXISTS client_follow_logs CASCADE;"
|
||||
|
||||
CREATE_TRIGGERS = """
|
||||
CREATE OR REPLACE FUNCTION update_client_last_follow()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.log_type = 'written' THEN
|
||||
UPDATE clients
|
||||
SET last_follow_at = NEW.created_at,
|
||||
last_active_at = NEW.created_at,
|
||||
updated_at = NOW()
|
||||
WHERE id = NEW.client_id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_client_last_follow
|
||||
AFTER INSERT ON client_follow_logs
|
||||
FOR EACH ROW EXECUTE FUNCTION update_client_last_follow();
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_client_viewing_progress()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE clients
|
||||
SET updated_at = NOW()
|
||||
WHERE id = NEW.client_id;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_client_viewing_progress
|
||||
AFTER INSERT ON client_viewings
|
||||
FOR EACH ROW EXECUTE FUNCTION update_client_viewing_progress();
|
||||
"""
|
||||
|
||||
DROP_TRIGGERS = """
|
||||
DROP TRIGGER IF EXISTS trg_client_viewing_progress ON client_viewings;
|
||||
DROP FUNCTION IF EXISTS update_client_viewing_progress();
|
||||
DROP TRIGGER IF EXISTS trg_client_last_follow ON client_follow_logs;
|
||||
DROP FUNCTION IF EXISTS update_client_last_follow();
|
||||
"""
|
||||
|
||||
CREATE_UNIQUE_CLIENT_NO = """
|
||||
CREATE UNIQUE INDEX idx_clients_client_no_active ON clients(client_no)
|
||||
WHERE deleted_at IS NULL;
|
||||
"""
|
||||
|
||||
DROP_UNIQUE_CLIENT_NO = "DROP INDEX IF EXISTS idx_clients_client_no_active;"
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("fonrey_client", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(CREATE_CLIENT_FOLLOW_LOGS, reverse_sql=DROP_CLIENT_FOLLOW_LOGS),
|
||||
migrations.RunSQL(CREATE_TRIGGERS, reverse_sql=DROP_TRIGGERS),
|
||||
migrations.RunSQL(CREATE_UNIQUE_CLIENT_NO, reverse_sql=DROP_UNIQUE_CLIENT_NO),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
from .contacts import ClientContact, ClientRequirement, ClientSchoolPreference
|
||||
from .core import Client
|
||||
from .folders import ClientFavoriteFolder, ClientFolderItem
|
||||
from .follow import ClientFollowLog, ClientFollowLogAttachment
|
||||
from .viewing_match import (
|
||||
ClientPropertyMatch,
|
||||
ClientStatusLog,
|
||||
ClientViewing,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Client",
|
||||
"ClientContact",
|
||||
"ClientRequirement",
|
||||
"ClientSchoolPreference",
|
||||
"ClientFollowLog",
|
||||
"ClientFollowLogAttachment",
|
||||
"ClientViewing",
|
||||
"ClientPropertyMatch",
|
||||
"ClientStatusLog",
|
||||
"ClientFavoriteFolder",
|
||||
"ClientFolderItem",
|
||||
]
|
||||
|
||||
143
apps/client/models/contacts.py
Normal file
143
apps/client/models/contacts.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
|
||||
from core.enums import (
|
||||
ClientBuildingAgeRange,
|
||||
ClientContactGender,
|
||||
ClientDecoration,
|
||||
ClientFloorPreference,
|
||||
ClientOrientation,
|
||||
ClientRequirementType,
|
||||
)
|
||||
from core.models.base import UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class ClientContact(UUIDPrimaryKeyModel):
|
||||
client = models.ForeignKey(
|
||||
"fonrey_client.Client", on_delete=models.CASCADE, related_name="contacts"
|
||||
)
|
||||
sort_order = models.SmallIntegerField(default=0)
|
||||
name = models.CharField(max_length=50)
|
||||
gender = models.CharField(
|
||||
max_length=10, choices=ClientContactGender.choices, default=ClientContactGender.MALE
|
||||
)
|
||||
|
||||
phone_enc = models.BinaryField()
|
||||
phone_hash = models.CharField(max_length=64)
|
||||
phone_country_code = models.CharField(max_length=10, default="+86")
|
||||
phone_is_invalid = models.BooleanField(default=False)
|
||||
|
||||
phone2_enc = models.BinaryField(null=True, blank=True)
|
||||
phone2_hash = models.CharField(max_length=64, blank=True, default="")
|
||||
|
||||
wechat = models.CharField(max_length=100, blank=True, default="")
|
||||
qq = models.CharField(max_length=20, blank=True, default="")
|
||||
remarks = models.CharField(max_length=200, blank=True, default="")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||
created_by = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="created_client_contacts",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "client_contacts"
|
||||
indexes = [
|
||||
models.Index(fields=["phone_hash"], name="idx_cc_phone_hash"),
|
||||
models.Index(fields=["phone2_hash"], name="idx_cc_phone2_hash"),
|
||||
models.Index(fields=["client"], name="idx_cc_client"),
|
||||
]
|
||||
|
||||
|
||||
class ClientRequirement(UUIDPrimaryKeyModel):
|
||||
client = models.ForeignKey(
|
||||
"fonrey_client.Client", on_delete=models.CASCADE, related_name="requirements"
|
||||
)
|
||||
requirement_type = models.CharField(
|
||||
max_length=20, choices=ClientRequirementType.choices
|
||||
)
|
||||
is_primary = models.BooleanField(default=True)
|
||||
|
||||
budget_min = models.DecimalField(
|
||||
max_digits=12, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
budget_max = models.DecimalField(
|
||||
max_digits=12, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
area_min = models.DecimalField(
|
||||
max_digits=8, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
area_max = models.DecimalField(
|
||||
max_digits=8, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
|
||||
bedroom_counts = ArrayField(
|
||||
models.SmallIntegerField(), blank=True, default=list
|
||||
)
|
||||
floor_preferences = ArrayField(
|
||||
models.CharField(max_length=20, choices=ClientFloorPreference.choices),
|
||||
blank=True,
|
||||
default=list,
|
||||
)
|
||||
orientations = ArrayField(
|
||||
models.CharField(max_length=10, choices=ClientOrientation.choices),
|
||||
blank=True,
|
||||
default=list,
|
||||
)
|
||||
decorations = ArrayField(
|
||||
models.CharField(max_length=10, choices=ClientDecoration.choices),
|
||||
blank=True,
|
||||
default=list,
|
||||
)
|
||||
building_age_ranges = ArrayField(
|
||||
models.CharField(max_length=20, choices=ClientBuildingAgeRange.choices),
|
||||
blank=True,
|
||||
default=list,
|
||||
)
|
||||
|
||||
intent_district_ids = ArrayField(
|
||||
models.UUIDField(), blank=True, default=list
|
||||
)
|
||||
intent_business_area_ids = ArrayField(
|
||||
models.UUIDField(), blank=True, default=list
|
||||
)
|
||||
intent_complex_names = models.TextField(blank=True, default="")
|
||||
transportation = models.CharField(max_length=50, blank=True, default="")
|
||||
intent_school_names = models.TextField(blank=True, default="")
|
||||
school_enrollment_date = models.DateField(null=True, blank=True)
|
||||
traffic_preference = models.TextField(blank=True, default="")
|
||||
requirement_notes = models.CharField(max_length=200, blank=True, default="")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "client_requirements"
|
||||
indexes = [
|
||||
models.Index(fields=["client"], name="idx_creq_client"),
|
||||
models.Index(fields=["requirement_type", "client"], name="idx_creq_type"),
|
||||
models.Index(fields=["budget_min", "budget_max"], name="idx_creq_budget"),
|
||||
models.Index(fields=["area_min", "area_max"], name="idx_creq_area"),
|
||||
]
|
||||
|
||||
|
||||
class ClientSchoolPreference(UUIDPrimaryKeyModel):
|
||||
requirement = models.ForeignKey(
|
||||
ClientRequirement,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="school_preferences",
|
||||
)
|
||||
school_id = models.UUIDField(null=True, blank=True)
|
||||
school_name = models.CharField(max_length=100)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "client_school_preferences"
|
||||
indexes = [
|
||||
models.Index(fields=["requirement"], name="idx_csp_requirement"),
|
||||
]
|
||||
147
apps/client/models/core.py
Normal file
147
apps/client/models/core.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
|
||||
from core.enums import (
|
||||
ClientActivityLevel,
|
||||
ClientBuyingPurpose,
|
||||
ClientGrade,
|
||||
ClientIdType,
|
||||
ClientInvalidReason,
|
||||
ClientPaymentMethod,
|
||||
ClientPropertiesOwned,
|
||||
ClientPropertyUsage,
|
||||
ClientStatus,
|
||||
ClientTransactedPropertyType,
|
||||
ClientTransactedType,
|
||||
ClientTransferToPublicType,
|
||||
ClientType,
|
||||
)
|
||||
from core.models.base import AuditedModel
|
||||
|
||||
|
||||
class Client(AuditedModel):
|
||||
client_no = models.CharField(max_length=30, unique=True)
|
||||
client_type = models.CharField(
|
||||
max_length=20, choices=ClientType.choices, default=ClientType.PRIVATE
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20, choices=ClientStatus.choices, default=ClientStatus.BUYING
|
||||
)
|
||||
grade = models.CharField(
|
||||
max_length=5, choices=ClientGrade.choices, default=ClientGrade.C
|
||||
)
|
||||
property_usage = models.CharField(
|
||||
max_length=30,
|
||||
choices=ClientPropertyUsage.choices,
|
||||
default=ClientPropertyUsage.RESIDENTIAL,
|
||||
)
|
||||
buying_purpose = ArrayField(
|
||||
models.CharField(max_length=20, choices=ClientBuyingPurpose.choices),
|
||||
blank=True,
|
||||
default=list,
|
||||
)
|
||||
payment_method = models.CharField(
|
||||
max_length=30, choices=ClientPaymentMethod.choices, blank=True, default=""
|
||||
)
|
||||
properties_owned = models.CharField(
|
||||
max_length=20, choices=ClientPropertiesOwned.choices, blank=True, default=""
|
||||
)
|
||||
has_loan_record = models.BooleanField(null=True, blank=True)
|
||||
|
||||
id_type = models.CharField(
|
||||
max_length=20, choices=ClientIdType.choices, blank=True, default=""
|
||||
)
|
||||
id_number_enc = models.BinaryField(null=True, blank=True)
|
||||
|
||||
source = models.CharField(max_length=50, blank=True, default="")
|
||||
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(null=True, blank=True)
|
||||
|
||||
transfer_to_public_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=ClientTransferToPublicType.choices,
|
||||
blank=True,
|
||||
default="",
|
||||
)
|
||||
transferred_public_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
invalid_reason = models.CharField(
|
||||
max_length=30, choices=ClientInvalidReason.choices, blank=True, default=""
|
||||
)
|
||||
invalidated_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
transacted_at = models.DateField(null=True, blank=True)
|
||||
transacted_property = models.ForeignKey(
|
||||
"fonrey_property.Property",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="transacted_clients",
|
||||
)
|
||||
transacted_price = models.DecimalField(
|
||||
max_digits=12, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
transacted_type = models.CharField(
|
||||
max_length=20, choices=ClientTransactedType.choices, blank=True, default=""
|
||||
)
|
||||
transacted_property_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=ClientTransactedPropertyType.choices,
|
||||
blank=True,
|
||||
default="",
|
||||
)
|
||||
|
||||
first_recorder = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="first_recorded_clients",
|
||||
)
|
||||
owner = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="owned_clients",
|
||||
)
|
||||
org_unit = models.ForeignKey(
|
||||
"org.OrgUnit",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="clients",
|
||||
)
|
||||
|
||||
activity_level = models.CharField(
|
||||
max_length=20, choices=ClientActivityLevel.choices, blank=True, default=""
|
||||
)
|
||||
last_active_at = models.DateTimeField(null=True, blank=True)
|
||||
last_follow_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
commission_date = models.DateField(null=True, blank=True)
|
||||
entrust_count = models.SmallIntegerField(default=1)
|
||||
|
||||
version = models.IntegerField(default=1)
|
||||
|
||||
class Meta:
|
||||
db_table = "clients"
|
||||
indexes = [
|
||||
models.Index(fields=["client_type", "status"], name="idx_clients_type_stat"),
|
||||
models.Index(fields=["owner"], name="idx_clients_owner"),
|
||||
models.Index(fields=["org_unit"], name="idx_clients_org_unit"),
|
||||
models.Index(
|
||||
fields=["activity_level", "-last_active_at"],
|
||||
name="idx_clients_activity",
|
||||
),
|
||||
models.Index(fields=["grade"], name="idx_clients_grade"),
|
||||
models.Index(
|
||||
fields=["-transferred_public_at"], name="idx_clients_transferred"
|
||||
),
|
||||
models.Index(fields=["-last_follow_at"], name="idx_clients_last_follow"),
|
||||
]
|
||||
48
apps/client/models/folders.py
Normal file
48
apps/client/models/folders.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from django.db import models
|
||||
|
||||
from core.models.base import UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class ClientFavoriteFolder(UUIDPrimaryKeyModel):
|
||||
staff = models.ForeignKey(
|
||||
"org.Staff", on_delete=models.CASCADE, related_name="favorite_folders"
|
||||
)
|
||||
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(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "client_favorite_folders"
|
||||
indexes = [
|
||||
models.Index(fields=["staff"], name="idx_cff_staff"),
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["staff"],
|
||||
condition=models.Q(is_default=True, deleted_at__isnull=True),
|
||||
name="uq_cff_default_per_staff",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class ClientFolderItem(models.Model):
|
||||
folder = models.ForeignKey(
|
||||
ClientFavoriteFolder, on_delete=models.CASCADE, related_name="items"
|
||||
)
|
||||
client = models.ForeignKey(
|
||||
"fonrey_client.Client", on_delete=models.CASCADE, related_name="folder_items"
|
||||
)
|
||||
added_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "client_folder_items"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["folder", "client"], name="uq_cfi_folder_client"
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["client"], name="idx_cfi_client"),
|
||||
]
|
||||
55
apps/client/models/follow.py
Normal file
55
apps/client/models/follow.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from django.db import models
|
||||
|
||||
from core.enums import ClientFollowLogType
|
||||
from core.models.base import UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class ClientFollowLog(models.Model):
|
||||
"""Partitioned table (PARTITION BY RANGE created_at).
|
||||
|
||||
Managed via RunSQL; Django ORM treats parent as unmanaged.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True)
|
||||
created_at = models.DateTimeField()
|
||||
client = models.ForeignKey(
|
||||
"fonrey_client.Client",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="follow_logs",
|
||||
)
|
||||
|
||||
log_type = models.CharField(max_length=30, choices=ClientFollowLogType.choices)
|
||||
purpose = models.CharField(max_length=50, blank=True, default="")
|
||||
content = models.TextField(blank=True, default="")
|
||||
log_tag = models.CharField(max_length=50, blank=True, default="")
|
||||
change_detail = models.JSONField(null=True, blank=True)
|
||||
|
||||
is_public = models.BooleanField(default=True)
|
||||
is_deletable = models.BooleanField(default=True)
|
||||
|
||||
operator = models.ForeignKey(
|
||||
"org.Staff", null=True, blank=True, on_delete=models.SET_NULL
|
||||
)
|
||||
operator_snapshot = models.JSONField(null=True, blank=True)
|
||||
|
||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "client_follow_logs"
|
||||
managed = False
|
||||
unique_together = (("id", "created_at"),)
|
||||
|
||||
|
||||
class ClientFollowLogAttachment(UUIDPrimaryKeyModel):
|
||||
follow_log_id = models.UUIDField() # cross-partitioned FK; not enforced via Django FK
|
||||
file_key = models.TextField()
|
||||
file_name = models.CharField(max_length=255)
|
||||
file_size = models.IntegerField()
|
||||
file_type = models.CharField(max_length=10, blank=True, default="")
|
||||
has_location = models.BooleanField(default=False)
|
||||
sort_order = models.SmallIntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "client_follow_log_attachments"
|
||||
indexes = [models.Index(fields=["follow_log_id"], name="idx_cfla_log")]
|
||||
147
apps/client/models/viewing_match.py
Normal file
147
apps/client/models/viewing_match.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
|
||||
from core.enums import (
|
||||
ClientPropertyMatchGroup,
|
||||
ClientPropertyMatchSource,
|
||||
ClientPropertyMatchStatus,
|
||||
ClientStatusLogChangeType,
|
||||
ClientViewingIntent,
|
||||
ClientViewingType,
|
||||
)
|
||||
from core.models.base import UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class ClientViewing(UUIDPrimaryKeyModel):
|
||||
client = models.ForeignKey(
|
||||
"fonrey_client.Client", on_delete=models.RESTRICT, related_name="viewings"
|
||||
)
|
||||
property = models.ForeignKey(
|
||||
"fonrey_property.Property",
|
||||
on_delete=models.RESTRICT,
|
||||
related_name="client_viewings",
|
||||
)
|
||||
viewing_type = models.CharField(
|
||||
max_length=20, choices=ClientViewingType.choices, default=ClientViewingType.VIEWING
|
||||
)
|
||||
|
||||
agent = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="led_viewings",
|
||||
)
|
||||
companion_ids = ArrayField(models.UUIDField(), blank=True, default=list)
|
||||
cooperator_ids = ArrayField(models.UUIDField(), blank=True, default=list)
|
||||
|
||||
scheduled_at = models.DateTimeField(null=True, blank=True)
|
||||
viewing_start_at = models.DateTimeField(null=True, blank=True)
|
||||
viewing_end_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
situation = models.TextField(blank=True, default="")
|
||||
client_intent = models.CharField(
|
||||
max_length=20, choices=ClientViewingIntent.choices, blank=True, default=""
|
||||
)
|
||||
viewing_progress = models.SmallIntegerField(null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||
created_by = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="created_client_viewings",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
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"),
|
||||
]
|
||||
|
||||
|
||||
class ClientPropertyMatch(UUIDPrimaryKeyModel):
|
||||
client = models.ForeignKey(
|
||||
"fonrey_client.Client", on_delete=models.CASCADE, related_name="property_matches"
|
||||
)
|
||||
property = models.ForeignKey(
|
||||
"fonrey_property.Property",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="client_matches",
|
||||
)
|
||||
|
||||
match_source = models.CharField(
|
||||
max_length=20,
|
||||
choices=ClientPropertyMatchSource.choices,
|
||||
default=ClientPropertyMatchSource.RECORDED,
|
||||
)
|
||||
match_group = models.CharField(
|
||||
max_length=30, choices=ClientPropertyMatchGroup.choices, blank=True, default=""
|
||||
)
|
||||
match_score = models.DecimalField(
|
||||
max_digits=5, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
match_reasons = models.JSONField(null=True, blank=True)
|
||||
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=ClientPropertyMatchStatus.choices,
|
||||
default=ClientPropertyMatchStatus.SUGGESTED,
|
||||
)
|
||||
shared_at = models.DateTimeField(null=True, blank=True)
|
||||
feedback = models.CharField(max_length=50, blank=True, default="")
|
||||
calculated_at = models.DateTimeField(auto_now_add=True)
|
||||
created_by = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="created_matches",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "client_property_matches"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["client", "property"], name="uq_client_match_pair"
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["client", "match_source", "match_group"],
|
||||
name="idx_cpm_client_grp",
|
||||
),
|
||||
models.Index(fields=["client", "status"], name="idx_cpm_status"),
|
||||
]
|
||||
|
||||
|
||||
class ClientStatusLog(models.Model):
|
||||
"""Audit log; record-level immutable (no deleted_at)."""
|
||||
|
||||
id = models.UUIDField(primary_key=True)
|
||||
client = models.ForeignKey(
|
||||
"fonrey_client.Client", on_delete=models.RESTRICT, related_name="status_logs"
|
||||
)
|
||||
change_type = models.CharField(
|
||||
max_length=30, choices=ClientStatusLogChangeType.choices
|
||||
)
|
||||
old_value = models.JSONField(null=True, blank=True)
|
||||
new_value = models.JSONField(null=True, blank=True)
|
||||
reason = models.TextField(blank=True, default="")
|
||||
operator = models.ForeignKey(
|
||||
"org.Staff", on_delete=models.RESTRICT, related_name="client_status_changes"
|
||||
)
|
||||
operated_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
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"),
|
||||
]
|
||||
114
apps/setting/migrations/0001_initial.py
Normal file
114
apps/setting/migrations/0001_initial.py
Normal file
@@ -0,0 +1,114 @@
|
||||
# Generated by Django 4.2.16 on 2026-04-29 09:33
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('org', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='FieldRequirementRule',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('module', models.CharField(choices=[('property', '房源'), ('client', '客源')], max_length=20)),
|
||||
('entity_type', models.CharField(choices=[('residential', '住宅'), ('villa', '别墅'), ('commercial_residential', '商住'), ('shop', '商铺'), ('office', '写字楼'), ('other', '其他')], max_length=50)),
|
||||
('trade_status', models.CharField(choices=[('sale', '出售'), ('rent', '出租'), ('sale_rent', '租售'), ('*', '全部')], max_length=20)),
|
||||
('field_key', models.CharField(max_length=50)),
|
||||
('requirement', models.CharField(choices=[('required', '必填'), ('optional', '选填'), ('hidden', '隐藏')], max_length=10)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'field_requirement_rules',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LookupGroup',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('module', models.CharField(max_length=50)),
|
||||
('key', models.CharField(max_length=100)),
|
||||
('label_zh', models.CharField(max_length=50)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('sort_order', models.SmallIntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'lookup_groups',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TenantSetting',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('category', models.CharField(max_length=50)),
|
||||
('key', models.CharField(max_length=100)),
|
||||
('value', models.JSONField()),
|
||||
('value_type', models.CharField(choices=[('bool', '布尔'), ('int', '整数'), ('string', '字符串'), ('enum', '枚举')], max_length=20)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_tenant_settings', to='org.staff')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'tenant_settings',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LookupItem',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('value', models.CharField(max_length=100)),
|
||||
('label_zh', models.CharField(max_length=50)),
|
||||
('is_system', models.BooleanField(default=False)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('sort_order', models.SmallIntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_lookup_items', to='org.staff')),
|
||||
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='setting.lookupgroup')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'lookup_items',
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='lookupgroup',
|
||||
constraint=models.UniqueConstraint(fields=('module', 'key'), name='uq_lookup_groups_module_key'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='fieldrequirementrule',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_field_rules', to='org.staff'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='tenantsetting',
|
||||
index=models.Index(fields=['category'], name='idx_tenant_settings_cat'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='tenantsetting',
|
||||
constraint=models.UniqueConstraint(fields=('category', 'key'), name='uq_tenant_settings_cat_key'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='lookupitem',
|
||||
index=models.Index(fields=['group', 'is_active', 'sort_order'], name='idx_lookup_items_active'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='lookupitem',
|
||||
constraint=models.UniqueConstraint(fields=('group', 'value'), name='uq_lookup_items_group_value'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='fieldrequirementrule',
|
||||
index=models.Index(fields=['module', 'entity_type', 'trade_status'], name='idx_field_req_lookup'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='fieldrequirementrule',
|
||||
constraint=models.UniqueConstraint(fields=('module', 'entity_type', 'trade_status', 'field_key'), name='uq_field_req_quad'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
from .lookup import LookupGroup, LookupItem
|
||||
from .setting import FieldRequirementRule, TenantSetting
|
||||
|
||||
__all__ = [
|
||||
"LookupGroup",
|
||||
"LookupItem",
|
||||
"TenantSetting",
|
||||
"FieldRequirementRule",
|
||||
]
|
||||
|
||||
55
apps/setting/models/lookup.py
Normal file
55
apps/setting/models/lookup.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from django.db import models
|
||||
|
||||
from core.models.base import UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class LookupGroup(UUIDPrimaryKeyModel):
|
||||
module = models.CharField(max_length=50)
|
||||
key = models.CharField(max_length=100)
|
||||
label_zh = models.CharField(max_length=50)
|
||||
description = models.TextField(blank=True, default="")
|
||||
sort_order = models.SmallIntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "lookup_groups"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["module", "key"], name="uq_lookup_groups_module_key"
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class LookupItem(UUIDPrimaryKeyModel):
|
||||
group = models.ForeignKey(
|
||||
LookupGroup, on_delete=models.CASCADE, related_name="items"
|
||||
)
|
||||
value = models.CharField(max_length=100)
|
||||
label_zh = models.CharField(max_length=50)
|
||||
is_system = models.BooleanField(default=False)
|
||||
is_active = models.BooleanField(default=True)
|
||||
sort_order = models.SmallIntegerField(default=0)
|
||||
created_by = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="created_lookup_items",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "lookup_items"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["group", "value"], name="uq_lookup_items_group_value"
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["group", "is_active", "sort_order"],
|
||||
name="idx_lookup_items_active",
|
||||
),
|
||||
]
|
||||
75
apps/setting/models/setting.py
Normal file
75
apps/setting/models/setting.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from django.db import models
|
||||
|
||||
from core.enums import (
|
||||
FieldRuleEntityType,
|
||||
FieldRuleModule,
|
||||
FieldRuleRequirement,
|
||||
SettingValueType,
|
||||
)
|
||||
from core.models.base import UUIDPrimaryKeyModel
|
||||
|
||||
TRADE_STATUS_CHOICES = (
|
||||
("sale", "出售"),
|
||||
("rent", "出租"),
|
||||
("sale_rent", "租售"),
|
||||
("*", "全部"),
|
||||
)
|
||||
|
||||
|
||||
class TenantSetting(UUIDPrimaryKeyModel):
|
||||
category = models.CharField(max_length=50)
|
||||
key = models.CharField(max_length=100)
|
||||
value = models.JSONField()
|
||||
value_type = models.CharField(max_length=20, choices=SettingValueType.choices)
|
||||
updated_by = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="updated_tenant_settings",
|
||||
)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "tenant_settings"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["category", "key"], name="uq_tenant_settings_cat_key"
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["category"], name="idx_tenant_settings_cat"),
|
||||
]
|
||||
|
||||
|
||||
class FieldRequirementRule(UUIDPrimaryKeyModel):
|
||||
module = models.CharField(max_length=20, choices=FieldRuleModule.choices)
|
||||
entity_type = models.CharField(max_length=50, choices=FieldRuleEntityType.choices)
|
||||
trade_status = models.CharField(max_length=20, choices=TRADE_STATUS_CHOICES)
|
||||
field_key = models.CharField(max_length=50)
|
||||
requirement = models.CharField(
|
||||
max_length=10, choices=FieldRuleRequirement.choices
|
||||
)
|
||||
updated_by = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="updated_field_rules",
|
||||
)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "field_requirement_rules"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["module", "entity_type", "trade_status", "field_key"],
|
||||
name="uq_field_req_quad",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["module", "entity_type", "trade_status"],
|
||||
name="idx_field_req_lookup",
|
||||
),
|
||||
]
|
||||
Reference in New Issue
Block a user