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:
Sisyphus
2026-04-29 17:33:58 +08:00
parent 5b55dda367
commit ed40de4050
12 changed files with 1266 additions and 0 deletions

View 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'),
),
]

View 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),
]

View File

@@ -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",
]

View 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
View 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"),
]

View 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"),
]

View 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")]

View 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"),
]

View 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'),
),
]

View File

@@ -0,0 +1,9 @@
from .lookup import LookupGroup, LookupItem
from .setting import FieldRequirementRule, TenantSetting
__all__ = [
"LookupGroup",
"LookupItem",
"TenantSetting",
"FieldRequirementRule",
]

View 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",
),
]

View 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",
),
]