From e67b07a7c8a708571b92b2ba1717005966d2b0f8 Mon Sep 17 00:00:00 2001 From: ishenwei Date: Thu, 30 Apr 2026 09:19:58 +0800 Subject: [PATCH] feat(client): add Chinese verbose_name and help_text to all client fields (Phase 4.1 part 2/9) Sync DATA_MODEL_CLIENT.md field-level Chinese annotations to Django models across 11 client tables (Client, ClientContact, ClientRequirement, ClientSchoolPreference, ClientFavoriteFolder, ClientFolderItem, ClientFollowLog, ClientFollowLogAttachment, ClientViewing, ClientPropertyMatch, ClientStatusLog). Pre-existing docstrings retained on ClientFollowLog (partitioned parent treated as unmanaged) and ClientStatusLog (immutable audit log). --- apps/client/models/contacts.py | 243 +++++++++++++++++++++++----- apps/client/models/core.py | 198 +++++++++++++++++++---- apps/client/models/folders.py | 50 ++++-- apps/client/models/follow.py | 126 ++++++++++++--- apps/client/models/viewing_match.py | 182 +++++++++++++++++---- 5 files changed, 678 insertions(+), 121 deletions(-) diff --git a/apps/client/models/contacts.py b/apps/client/models/contacts.py index f84630d..fd449f0 100644 --- a/apps/client/models/contacts.py +++ b/apps/client/models/contacts.py @@ -14,35 +14,103 @@ from core.models.base import UUIDPrimaryKeyModel class ClientContact(UUIDPrimaryKeyModel): client = models.ForeignKey( - "fonrey_client.Client", on_delete=models.CASCADE, related_name="contacts" + "fonrey_client.Client", + on_delete=models.CASCADE, + related_name="contacts", + verbose_name="所属客源", + help_text="联系人随客源级联删除", + ) + sort_order = models.SmallIntegerField( + default=0, + verbose_name="排序顺序", + help_text="sort_order=0 为主联系人,姓名用于客源姓名显示", + ) + name = models.CharField( + max_length=50, + verbose_name="联系人姓名", ) - sort_order = models.SmallIntegerField(default=0) - name = models.CharField(max_length=50) gender = models.CharField( - max_length=10, choices=ClientContactGender.choices, default=ClientContactGender.MALE + max_length=10, + choices=ClientContactGender.choices, + default=ClientContactGender.MALE, + verbose_name="性别", + help_text="male=先生 / female=女士", ) - 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) + phone_enc = models.BinaryField( + verbose_name="手机号(加密)", + help_text="AES-256-GCM 加密手机号(电话1)", + ) + phone_hash = models.CharField( + max_length=64, + verbose_name="手机号哈希", + help_text="SHA-256 哈希(重复检测)", + ) + phone_country_code = models.CharField( + max_length=10, + default="+86", + verbose_name="国际区号", + ) + phone_is_invalid = models.BooleanField( + default=False, + verbose_name="号码是否无效", + help_text="标记无效后该号码不再参与重复检测", + ) - phone2_enc = models.BinaryField(null=True, blank=True) - phone2_hash = models.CharField(max_length=64, blank=True, default="") + phone2_enc = models.BinaryField( + null=True, + blank=True, + verbose_name="备用电话2(加密)", + ) + phone2_hash = models.CharField( + max_length=64, + blank=True, + default="", + verbose_name="备用电话2哈希", + help_text="SHA-256,用于重复检测", + ) - 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="") + wechat = models.CharField( + max_length=100, + blank=True, + default="", + verbose_name="微信号", + ) + qq = models.CharField( + max_length=20, + blank=True, + default="", + verbose_name="QQ号", + ) + remarks = models.CharField( + max_length=200, + blank=True, + default="", + verbose_name="联系人备注", + help_text="最多200字", + ) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - deleted_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="创建时间", + ) + updated_at = models.DateTimeField( + auto_now=True, + verbose_name="最后更新时间", + ) + deleted_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="删除时间", + help_text="软删除时间戳;NULL=未删除(不影响客源本身)", + ) created_by = models.ForeignKey( "org.Staff", null=True, blank=True, on_delete=models.SET_NULL, related_name="created_client_contacts", + verbose_name="创建人", ) class Meta: @@ -58,65 +126,152 @@ class ClientContact(UUIDPrimaryKeyModel): class ClientRequirement(UUIDPrimaryKeyModel): client = models.ForeignKey( - "fonrey_client.Client", on_delete=models.CASCADE, related_name="requirements" + "fonrey_client.Client", + on_delete=models.CASCADE, + related_name="requirements", + verbose_name="所属客源", + help_text="需求随客源级联删除", ) requirement_type = models.CharField( - max_length=20, choices=ClientRequirementType.choices + max_length=20, + choices=ClientRequirementType.choices, + verbose_name="需求类型", + help_text="second_hand=二手 / new_house=新房 / rental=租房", + ) + is_primary = models.BooleanField( + default=True, + verbose_name="是否主需求", + help_text="用于列表展示", ) - is_primary = models.BooleanField(default=True) budget_min = models.DecimalField( - max_digits=12, decimal_places=2, null=True, blank=True + max_digits=12, + decimal_places=2, + null=True, + blank=True, + verbose_name="最低预算", + help_text="单位:万元/元,依据需求类型", ) budget_max = models.DecimalField( - max_digits=12, decimal_places=2, null=True, blank=True + max_digits=12, + decimal_places=2, + null=True, + blank=True, + verbose_name="最高预算", ) area_min = models.DecimalField( - max_digits=8, decimal_places=2, null=True, blank=True + max_digits=8, + decimal_places=2, + null=True, + blank=True, + verbose_name="最小面积", + help_text="单位:㎡", ) area_max = models.DecimalField( - max_digits=8, decimal_places=2, null=True, blank=True + max_digits=8, + decimal_places=2, + null=True, + blank=True, + verbose_name="最大面积", + help_text="单位:㎡", ) bedroom_counts = ArrayField( - models.SmallIntegerField(), blank=True, default=list + models.SmallIntegerField(), + blank=True, + default=list, + verbose_name="可接受卧室数", + help_text="多选,如 [2,3]", ) floor_preferences = ArrayField( models.CharField(max_length=20, choices=ClientFloorPreference.choices), blank=True, default=list, + verbose_name="楼层偏好", + help_text="多选:no_first=不要一层 / low=低楼层 / mid=中楼层 / high=高楼层 / no_top=不要顶层", ) orientations = ArrayField( models.CharField(max_length=10, choices=ClientOrientation.choices), blank=True, default=list, + verbose_name="朝向偏好", + help_text="多选:east=东 / south=南 / west=西 / north=北", ) decorations = ArrayField( models.CharField(max_length=10, choices=ClientDecoration.choices), blank=True, default=list, + verbose_name="装修偏好", + help_text="多选(枚举同 properties.decoration)", ) building_age_ranges = ArrayField( models.CharField(max_length=20, choices=ClientBuildingAgeRange.choices), blank=True, default=list, + verbose_name="楼龄偏好", + help_text="多选:within_5y / 5_10y / 10_15y / 15_20y / over_20y", ) intent_district_ids = ArrayField( - models.UUIDField(), blank=True, default=list + models.UUIDField(), + blank=True, + default=list, + verbose_name="意向行政区", + help_text="行政区 ID 数组", ) intent_business_area_ids = ArrayField( - models.UUIDField(), blank=True, default=list + models.UUIDField(), + blank=True, + default=list, + verbose_name="意向商圈", + help_text="商圈 ID 数组", + ) + intent_complex_names = models.TextField( + blank=True, + default="", + verbose_name="意向小区", + help_text="文本,逗号分隔,最多500字", + ) + transportation = models.CharField( + max_length=50, + blank=True, + default="", + verbose_name="交通要求", + help_text="最多50字", + ) + intent_school_names = models.TextField( + blank=True, + default="", + verbose_name="意向学校", + help_text="文本,逗号分隔", + ) + school_enrollment_date = models.DateField( + null=True, + blank=True, + verbose_name="入学时间", + help_text="月份精度,取该月1日存储", + ) + traffic_preference = models.TextField( + blank=True, + default="", + verbose_name="交通备注", + ) + requirement_notes = models.CharField( + max_length=200, + blank=True, + default="", + verbose_name="需求备注", + help_text="最多200字", ) - 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) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="创建时间", + ) + updated_at = models.DateTimeField( + auto_now=True, + verbose_name="最后更新时间", + ) class Meta: db_table = "client_requirements" @@ -135,10 +290,24 @@ class ClientSchoolPreference(UUIDPrimaryKeyModel): ClientRequirement, on_delete=models.CASCADE, related_name="school_preferences", + verbose_name="所属需求", + help_text="意向学校随需求级联删除", + ) + school_id = models.UUIDField( + null=True, + blank=True, + verbose_name="学校ID", + help_text="从学校表选择,允许为 NULL(自由输入)", + ) + school_name = models.CharField( + max_length=100, + verbose_name="学校名称", + help_text="当 school_id 为 NULL 时为手动输入", + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="创建时间", ) - 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" diff --git a/apps/client/models/core.py b/apps/client/models/core.py index 1278ac7..f3fe79f 100644 --- a/apps/client/models/core.py +++ b/apps/client/models/core.py @@ -20,80 +20,190 @@ from core.models.base import AuditedModel class Client(AuditedModel): - client_no = models.CharField(max_length=30, unique=True) + client_no = models.CharField( + max_length=30, + unique=True, + verbose_name="客源编号", + help_text="系统生成的客源编号,格式由运营配置(如 KY20260424001)", + ) client_type = models.CharField( - max_length=20, choices=ClientType.choices, default=ClientType.PRIVATE + max_length=20, + choices=ClientType.choices, + default=ClientType.PRIVATE, + verbose_name="客源分类", + help_text="private=私客 / public=公客 / transacted=成交客", ) status = models.CharField( - max_length=20, choices=ClientStatus.choices, default=ClientStatus.BUYING + max_length=20, + choices=ClientStatus.choices, + default=ClientStatus.BUYING, + verbose_name="客源状态", + help_text="buying=求购 / renting=求租 / buy_or_rent=租购 / suspended=暂缓 / bought=已购 / rented_done=已租 / public=公客 / invalid=无效(详见 ENUMS)", ) grade = models.CharField( - max_length=5, choices=ClientGrade.choices, default=ClientGrade.C + max_length=5, + choices=ClientGrade.choices, + default=ClientGrade.C, + verbose_name="客源等级", + help_text="A=A急迫 / B=较强 / C=一般 / D=较弱 / E=暂不关注", ) property_usage = models.CharField( max_length=30, choices=ClientPropertyUsage.choices, default=ClientPropertyUsage.RESIDENTIAL, + verbose_name="房屋用途", + help_text="residential=住宅 / villa=别墅 / commercial_residential=商住 / shop=商铺 / office=写字楼 / other=其他", ) buying_purpose = ArrayField( models.CharField(max_length=20, choices=ClientBuyingPurpose.choices), blank=True, default=list, + verbose_name="购房目的", + help_text="多选:rigid=刚需 / investment=投资 / school_district=学区 / upgrade=改善 / commercial=商用 / other=其他", ) payment_method = models.CharField( - max_length=30, choices=ClientPaymentMethod.choices, blank=True, default="" + max_length=30, + choices=ClientPaymentMethod.choices, + blank=True, + default="", + verbose_name="付款方式", + help_text="full=全额 / mortgage=商业贷款 / mortgage_fund=商贷+公积金 / fund=公积金", ) properties_owned = models.CharField( - max_length=20, choices=ClientPropertiesOwned.choices, blank=True, default="" + max_length=20, + choices=ClientPropertiesOwned.choices, + blank=True, + default="", + verbose_name="名下房产", + help_text="none=无 / local_none=本地无外地有 / local_has=本地有", + ) + has_loan_record = models.BooleanField( + null=True, + blank=True, + verbose_name="有无贷款记录", ) - has_loan_record = models.BooleanField(null=True, blank=True) id_type = models.CharField( - max_length=20, choices=ClientIdType.choices, blank=True, default="" + max_length=20, + choices=ClientIdType.choices, + blank=True, + default="", + verbose_name="证件类型", + help_text="id_card=身份证 / passport=护照 / hk_macao=港澳台 / other=其他", + ) + id_number_enc = models.BinaryField( + null=True, + blank=True, + verbose_name="证件号码(加密)", + help_text="AES 加密存储", ) - id_number_enc = models.BinaryField(null=True, blank=True) - source = models.CharField(max_length=50, blank=True, default="") - remarks = models.TextField(blank=True, default="") + source = models.CharField( + max_length=50, + blank=True, + default="", + verbose_name="客户来源", + help_text="lookup_items 维护", + ) + remarks = models.TextField( + blank=True, + default="", + verbose_name="备注", + help_text="最多200字", + ) - 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) + is_starred = models.BooleanField( + default=False, + verbose_name="是否收藏", + help_text="快速标记,详细收藏夹用 client_folder_items", + ) + is_pinned = models.BooleanField( + default=False, + verbose_name="是否置顶", + help_text="列表顶部置顶", + ) + is_big_value = models.BooleanField( + default=False, + verbose_name="是否大价值客户", + help_text="影响筛选展示", + ) + is_protected = models.BooleanField( + default=False, + verbose_name="是否保护客", + help_text="影响转公逻辑", + ) + prefers_new_house = models.BooleanField( + null=True, + blank=True, + verbose_name="偏好新房", + help_text="用于筛选", + ) transfer_to_public_type = models.CharField( max_length=20, choices=ClientTransferToPublicType.choices, blank=True, default="", + verbose_name="转公客方式", + help_text="manual=手动转公 / auto=自动转公(超时) / marketing_jump=营销客跳公 / resource_public=资料客素公", + ) + transferred_public_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="进入公客池时间", ) - transferred_public_at = models.DateTimeField(null=True, blank=True) invalid_reason = models.CharField( - max_length=30, choices=ClientInvalidReason.choices, blank=True, default="" + max_length=30, + choices=ClientInvalidReason.choices, + blank=True, + default="", + verbose_name="无效原因", + help_text="invalid_phone=号码无效 / peer_agent=同行 / ad=广告推销 / no_intent=无意向 / other=其他", + ) + invalidated_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="标记无效时间", ) - invalidated_at = models.DateTimeField(null=True, blank=True) - transacted_at = models.DateField(null=True, blank=True) + transacted_at = models.DateField( + null=True, + blank=True, + verbose_name="成交日期", + ) transacted_property = models.ForeignKey( "fonrey_property.Property", null=True, blank=True, on_delete=models.SET_NULL, related_name="transacted_clients", + verbose_name="成交房源", + help_text="成交关联的房源", ) transacted_price = models.DecimalField( - max_digits=12, decimal_places=2, null=True, blank=True + max_digits=12, + decimal_places=2, + null=True, + blank=True, + verbose_name="成交价格", + help_text="单位:万元", ) transacted_type = models.CharField( - max_length=20, choices=ClientTransactedType.choices, blank=True, default="" + max_length=20, + choices=ClientTransactedType.choices, + blank=True, + default="", + verbose_name="成交类型", + help_text="bought=我购 / rented=我租", ) transacted_property_type = models.CharField( max_length=20, choices=ClientTransactedPropertyType.choices, blank=True, default="", + verbose_name="成交房源类型", + help_text="second_hand=二手 / new_house=新房", ) first_recorder = models.ForeignKey( @@ -102,6 +212,7 @@ class Client(AuditedModel): blank=True, on_delete=models.SET_NULL, related_name="first_recorded_clients", + verbose_name="首录人", ) owner = models.ForeignKey( "org.Staff", @@ -109,6 +220,8 @@ class Client(AuditedModel): blank=True, on_delete=models.SET_NULL, related_name="owned_clients", + verbose_name="归属人", + help_text="私客独占跟进人", ) org_unit = models.ForeignKey( "org.OrgUnit", @@ -116,18 +229,47 @@ class Client(AuditedModel): blank=True, on_delete=models.SET_NULL, related_name="clients", + verbose_name="归属部门", + help_text="冗余字段,加速筛选", ) activity_level = models.CharField( - max_length=20, choices=ClientActivityLevel.choices, blank=True, default="" + max_length=20, + choices=ClientActivityLevel.choices, + blank=True, + default="", + verbose_name="活跃度", + help_text="new_matched=新配偶 / active_7d / active_30d / active_90d / expiring / frozen / invalid(异步计算)", + ) + last_active_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="最后有效跟进时间", + help_text="触发器维护", + ) + last_follow_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="最后跟进时间", + help_text="冗余字段,列表排序用", ) - 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) + commission_date = models.DateField( + null=True, + blank=True, + verbose_name="委托日期", + ) + entrust_count = models.SmallIntegerField( + default=1, + verbose_name="委托次数", + help_text="成交后再委托则累加", + ) - version = models.IntegerField(default=1) + version = models.IntegerField( + default=1, + verbose_name="版本号", + help_text="乐观锁;每次 UPDATE +1;应用层检测 0 行受影响时抛 ConflictError", + ) class Meta: db_table = "clients" diff --git a/apps/client/models/folders.py b/apps/client/models/folders.py index e8e5306..9efa815 100644 --- a/apps/client/models/folders.py +++ b/apps/client/models/folders.py @@ -5,13 +5,36 @@ from core.models.base import UUIDPrimaryKeyModel class ClientFavoriteFolder(UUIDPrimaryKeyModel): staff = models.ForeignKey( - "org.Staff", on_delete=models.CASCADE, related_name="favorite_folders" + "org.Staff", + on_delete=models.CASCADE, + related_name="favorite_folders", + verbose_name="所属经纪人", + ) + name = models.CharField( + max_length=10, + verbose_name="收藏夹名称", + help_text="最多10字", + ) + is_default = models.BooleanField( + default=False, + verbose_name="是否默认", + help_text="系统默认收藏夹,每个经纪人只能有一个", + ) + sort_order = models.IntegerField( + default=0, + verbose_name="显示顺序", + help_text="升序排列", + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="创建时间", + ) + deleted_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="删除时间", + help_text="软删除时间戳;NULL=未删除", ) - 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" @@ -31,12 +54,21 @@ class ClientFavoriteFolder(UUIDPrimaryKeyModel): class ClientFolderItem(models.Model): folder = models.ForeignKey( - ClientFavoriteFolder, on_delete=models.CASCADE, related_name="items" + ClientFavoriteFolder, + on_delete=models.CASCADE, + related_name="items", + verbose_name="所属收藏夹", ) client = models.ForeignKey( - "fonrey_client.Client", on_delete=models.CASCADE, related_name="folder_items" + "fonrey_client.Client", + on_delete=models.CASCADE, + related_name="folder_items", + verbose_name="被收藏的客源", + ) + added_at = models.DateTimeField( + auto_now_add=True, + verbose_name="加入收藏夹时间", ) - added_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = "client_folder_items" diff --git a/apps/client/models/follow.py b/apps/client/models/follow.py index f214e94..87e43ec 100644 --- a/apps/client/models/follow.py +++ b/apps/client/models/follow.py @@ -10,29 +10,86 @@ class ClientFollowLog(models.Model): Managed via RunSQL; Django ORM treats parent as unmanaged. """ - id = models.UUIDField(primary_key=True) - created_at = models.DateTimeField() + id = models.UUIDField( + primary_key=True, + verbose_name="主键", + ) + created_at = models.DateTimeField( + verbose_name="创建时间", + help_text="分区键", + ) client = models.ForeignKey( "fonrey_client.Client", on_delete=models.CASCADE, related_name="follow_logs", + verbose_name="所属客源", + help_text="跟进日志随客源级联删除", ) - 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) + log_type = models.CharField( + max_length=30, + choices=ClientFollowLogType.choices, + verbose_name="跟进类型", + help_text="written=写入跟进 / modified=修改跟进 / sensitive_view=敏感信息查看(不可删) / other=其他跟进 / system=系统日志", + ) + purpose = models.CharField( + max_length=50, + blank=True, + default="", + verbose_name="跟进目的", + help_text="lookup_items 维护,23项", + ) + content = models.TextField( + blank=True, + default="", + verbose_name="跟进内容", + help_text="最少6字,最多500字", + ) + log_tag = models.CharField( + max_length=50, + blank=True, + default="", + verbose_name="跟进标签", + help_text="has_recording=有录音 / has_photo=有图片 / not_satisfied=对房源不满意 / still_considering=还在考虑 / ready_to_deposit=可交定金", + ) + change_detail = models.JSONField( + null=True, + blank=True, + verbose_name="字段变更明细", + help_text='修改跟进专用,格式:{"field": "grade", "old": "C", "new": "B", "label": "等级"}', + ) - is_public = models.BooleanField(default=True) - is_deletable = models.BooleanField(default=True) + is_public = models.BooleanField( + default=True, + verbose_name="是否公开", + help_text="FALSE=仅本人及上级可见", + ) + is_deletable = models.BooleanField( + default=True, + verbose_name="是否可删除", + help_text="敏感信息查看类型为 FALSE,不可删除", + ) operator = models.ForeignKey( - "org.Staff", null=True, blank=True, on_delete=models.SET_NULL + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + verbose_name="操作人", + ) + operator_snapshot = models.JSONField( + null=True, + blank=True, + verbose_name="操作人快照", + help_text="{name, store_group, role}(防止人员调动后显示异常)", ) - operator_snapshot = models.JSONField(null=True, blank=True) - deleted_at = models.DateTimeField(null=True, blank=True) + deleted_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="删除时间", + help_text="仅 is_deletable=TRUE 时可软删", + ) class Meta: db_table = "client_follow_logs" @@ -43,14 +100,43 @@ class ClientFollowLog(models.Model): 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) + follow_log_id = models.UUIDField( + verbose_name="所属跟进日志ID", + help_text="跨分区 FK;不通过 Django FK 强制约束", + ) + file_key = models.TextField( + verbose_name="文件存储路径", + help_text="R2/S3 存储路径", + ) + file_name = models.CharField( + max_length=255, + verbose_name="文件名", + help_text="原始文件名(用于展示和下载)", + ) + file_size = models.IntegerField( + verbose_name="文件大小", + help_text="单位:bytes,最大 20MB", + ) + file_type = models.CharField( + max_length=10, + blank=True, + default="", + verbose_name="文件类型", + help_text="bmp / jpg / png / gif", + ) + has_location = models.BooleanField( + default=False, + verbose_name="是否含位置信息", + help_text="是否含 GPS 位置信息", + ) + sort_order = models.SmallIntegerField( + default=0, + verbose_name="排序顺序", + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="创建时间", + ) class Meta: db_table = "client_follow_log_attachments" diff --git a/apps/client/models/viewing_match.py b/apps/client/models/viewing_match.py index 85e930a..f9df19e 100644 --- a/apps/client/models/viewing_match.py +++ b/apps/client/models/viewing_match.py @@ -14,15 +14,25 @@ from core.models.base import UUIDPrimaryKeyModel class ClientViewing(UUIDPrimaryKeyModel): client = models.ForeignKey( - "fonrey_client.Client", on_delete=models.RESTRICT, related_name="viewings" + "fonrey_client.Client", + on_delete=models.RESTRICT, + related_name="viewings", + verbose_name="所属客源", + help_text="带看记录仅软删除,不随客源删除", ) property = models.ForeignKey( "fonrey_property.Property", on_delete=models.RESTRICT, related_name="client_viewings", + verbose_name="带看房源", + help_text="房源删除时保留带看记录", ) viewing_type = models.CharField( - max_length=20, choices=ClientViewingType.choices, default=ClientViewingType.VIEWING + max_length=20, + choices=ClientViewingType.choices, + default=ClientViewingType.VIEWING, + verbose_name="带看类型", + help_text="appointment=预约 / viewing=带看 / revisit=复看 / empty=空看", ) agent = models.ForeignKey( @@ -31,28 +41,77 @@ class ClientViewing(UUIDPrimaryKeyModel): blank=True, on_delete=models.SET_NULL, related_name="led_viewings", + verbose_name="主带看经纪人", + ) + companion_ids = ArrayField( + models.UUIDField(), + blank=True, + default=list, + verbose_name="陪看人员", + help_text="员工 ID 数组(最多5人)", + ) + cooperator_ids = ArrayField( + models.UUIDField(), + blank=True, + default=list, + verbose_name="合作带看人", + help_text="员工 ID 数组(最多5人)", ) - 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) + scheduled_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="预约时间", + ) + viewing_start_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="实际带看开始时间", + ) + viewing_end_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="带看结束时间", + ) - situation = models.TextField(blank=True, default="") + situation = models.TextField( + blank=True, + default="", + verbose_name="带看情况", + help_text="必填,≥6字", + ) client_intent = models.CharField( - max_length=20, choices=ClientViewingIntent.choices, blank=True, default="" + max_length=20, + choices=ClientViewingIntent.choices, + blank=True, + default="", + verbose_name="客户意向", + help_text="interested=感兴趣 / not_interested=不感兴趣 / negotiating=谈判中 / cancelled=取消", + ) + viewing_progress = models.SmallIntegerField( + null=True, + blank=True, + verbose_name="带看进度", + help_text="1=一看,2=二看…,冗余字段,触发器维护", ) - 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_at = models.DateTimeField( + auto_now_add=True, + verbose_name="创建时间", + ) + deleted_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="删除时间", + help_text="软删除时间戳;带看记录可软删除", + ) created_by = models.ForeignKey( "org.Staff", null=True, blank=True, on_delete=models.SET_NULL, related_name="created_client_viewings", + verbose_name="创建人", ) class Meta: @@ -70,41 +129,79 @@ class ClientViewing(UUIDPrimaryKeyModel): class ClientPropertyMatch(UUIDPrimaryKeyModel): client = models.ForeignKey( - "fonrey_client.Client", on_delete=models.CASCADE, related_name="property_matches" + "fonrey_client.Client", + on_delete=models.CASCADE, + related_name="property_matches", + verbose_name="所属客源", ) property = models.ForeignKey( "fonrey_property.Property", on_delete=models.CASCADE, related_name="client_matches", + verbose_name="匹配房源", ) match_source = models.CharField( max_length=20, choices=ClientPropertyMatchSource.choices, default=ClientPropertyMatchSource.RECORDED, + verbose_name="匹配来源", + help_text="recorded=录客配房(基于录入需求) / system=系统配房(算法推荐)", ) match_group = models.CharField( - max_length=30, choices=ClientPropertyMatchGroup.choices, blank=True, default="" + max_length=30, + choices=ClientPropertyMatchGroup.choices, + blank=True, + default="", + verbose_name="匹配分组", + help_text="quality_layout=优质户型 / price_reduced=降价 / hot=热门 / newly_listed=新上", ) match_score = models.DecimalField( - max_digits=5, decimal_places=2, null=True, blank=True + max_digits=5, + decimal_places=2, + null=True, + blank=True, + verbose_name="匹配度评分", + help_text="0-100", + ) + match_reasons = models.JSONField( + null=True, + blank=True, + verbose_name="匹配原因详情", + help_text='格式:[{"key": "budget", "match": true}, ...]', ) - match_reasons = models.JSONField(null=True, blank=True) status = models.CharField( max_length=20, choices=ClientPropertyMatchStatus.choices, default=ClientPropertyMatchStatus.SUGGESTED, + verbose_name="状态", + help_text="suggested=待推送 / shared=已分享 / rejected=已反馈不合适 / viewed=客户已查看", + ) + shared_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="分享时间", + ) + feedback = models.CharField( + max_length=50, + blank=True, + default="", + verbose_name="反馈原因", + help_text="lookup_items 维护", + ) + calculated_at = models.DateTimeField( + auto_now_add=True, + verbose_name="配房计算时间", ) - 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", + verbose_name="创建人", + help_text="触发配房操作的员工(录客配房时记录,系统配房可为NULL)", ) class Meta: @@ -128,20 +225,51 @@ class ClientPropertyMatch(UUIDPrimaryKeyModel): class ClientStatusLog(models.Model): """Audit log; record-level immutable (no deleted_at).""" - id = models.UUIDField(primary_key=True) + id = models.UUIDField( + primary_key=True, + verbose_name="主键", + ) client = models.ForeignKey( - "fonrey_client.Client", on_delete=models.RESTRICT, related_name="status_logs" + "fonrey_client.Client", + on_delete=models.RESTRICT, + related_name="status_logs", + verbose_name="所属客源", + help_text="状态日志永久保留,RESTRICT 防止删除客源", ) change_type = models.CharField( - max_length=30, choices=ClientStatusLogChangeType.choices + max_length=30, + choices=ClientStatusLogChangeType.choices, + verbose_name="变更类型", + help_text="status_change=改状态 / grade_change=改等级 / to_public=转公客 / to_transacted=转成交 / to_invalid=转无效 / owner_change=改归属人 / source_change=改来源", + ) + old_value = models.JSONField( + null=True, + blank=True, + verbose_name="变更前快照", + help_text='格式:{"status": "buying", "label": "求购"}', + ) + new_value = models.JSONField( + null=True, + blank=True, + verbose_name="变更后快照", + ) + reason = models.TextField( + blank=True, + default="", + verbose_name="变更理由", + help_text="改状态必填,最多200字", ) - 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" + "org.Staff", + on_delete=models.RESTRICT, + related_name="client_status_changes", + verbose_name="操作人", + help_text="必填,状态变更审计用", + ) + operated_at = models.DateTimeField( + auto_now_add=True, + verbose_name="操作时间", ) - operated_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = "client_status_logs"