diff --git a/apps/client/migrations/0001_initial.py b/apps/client/migrations/0001_initial.py new file mode 100644 index 0000000..ccb642e --- /dev/null +++ b/apps/client/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/apps/client/migrations/0002_partitions_and_triggers.py b/apps/client/migrations/0002_partitions_and_triggers.py new file mode 100644 index 0000000..678c8e5 --- /dev/null +++ b/apps/client/migrations/0002_partitions_and_triggers.py @@ -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), + ] diff --git a/apps/client/models/__init__.py b/apps/client/models/__init__.py index e69de29..83c3698 100644 --- a/apps/client/models/__init__.py +++ b/apps/client/models/__init__.py @@ -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", +] diff --git a/apps/client/models/contacts.py b/apps/client/models/contacts.py new file mode 100644 index 0000000..4d319ee --- /dev/null +++ b/apps/client/models/contacts.py @@ -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"), + ] diff --git a/apps/client/models/core.py b/apps/client/models/core.py new file mode 100644 index 0000000..cd40543 --- /dev/null +++ b/apps/client/models/core.py @@ -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"), + ] diff --git a/apps/client/models/folders.py b/apps/client/models/folders.py new file mode 100644 index 0000000..3c3fde3 --- /dev/null +++ b/apps/client/models/folders.py @@ -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"), + ] diff --git a/apps/client/models/follow.py b/apps/client/models/follow.py new file mode 100644 index 0000000..0fa3ea7 --- /dev/null +++ b/apps/client/models/follow.py @@ -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")] diff --git a/apps/client/models/viewing_match.py b/apps/client/models/viewing_match.py new file mode 100644 index 0000000..81a8be5 --- /dev/null +++ b/apps/client/models/viewing_match.py @@ -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"), + ] diff --git a/apps/setting/migrations/0001_initial.py b/apps/setting/migrations/0001_initial.py new file mode 100644 index 0000000..8071ee2 --- /dev/null +++ b/apps/setting/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/apps/setting/models/__init__.py b/apps/setting/models/__init__.py index e69de29..e177abb 100644 --- a/apps/setting/models/__init__.py +++ b/apps/setting/models/__init__.py @@ -0,0 +1,9 @@ +from .lookup import LookupGroup, LookupItem +from .setting import FieldRequirementRule, TenantSetting + +__all__ = [ + "LookupGroup", + "LookupItem", + "TenantSetting", + "FieldRequirementRule", +] diff --git a/apps/setting/models/lookup.py b/apps/setting/models/lookup.py new file mode 100644 index 0000000..b94be8c --- /dev/null +++ b/apps/setting/models/lookup.py @@ -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", + ), + ] diff --git a/apps/setting/models/setting.py b/apps/setting/models/setting.py new file mode 100644 index 0000000..8b48741 --- /dev/null +++ b/apps/setting/models/setting.py @@ -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", + ), + ]