> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked. # Fonrey — 客源管理数据模型(DATA_MODEL_CLIENT) > **所属系统**: Fonrey 房产经纪管理系统 > **版本**: v1.0 > **日期**: 2026-04-24 > **关联模块**: `apps/client/` — 私客、公客、成交客、跟进记录、带看、智能配房 --- ## 一、领域概览(Domain Overview) ### 核心概念 - **Client(客源)**:有购房/租房意向或历史成交记录的客户。核心实体,与房源(Property)是系统业务闭环的两端。 - **客源类型**: - **私客(private)**:经纪人独占跟进的意向客户,是本期核心。 - **公客(public)**:私客超时未跟进或手动转公后,进入全公司共享客源池。 - **成交客(transacted)**:已完成购房/租房成交的客户,用于复购/转介绍跟进。 - **ClientContact(联系人)**:一个客源可有多个联系人,每个联系人有独立手机号。手机号加密存储,用于重复检测(「私客与成交客重复」)。 - **ClientRequirement(需求信息)**:购房/租房的详细偏好。一个客源可同时有「二手」「新房」「租房」三种需求类型(分别对应独立的需求记录)。 - **ClientFollowLog(跟进日志)**:经纪人与客户每次沟通的书面记录,是客源活跃度计算的数据来源。 - **Viewing(带看记录)**:与 Property 模块共享此表,记录经纪人带客户看房的过程。见主 DATA_MODEL.md 3.17 节。 - **ClientPropertyMatch(智能配房)**:系统按需求自动匹配的房源列表,分「录客配房」和「系统配房」两种来源。 - **ClientFavoriteFolder(收藏夹)**:经纪人自定义的客源分组收藏夹。 ### 关键业务规则 1. **私客手机号唯一性**:录入联系人手机号时,系统通过 `phone_hash` 检测是否与现有私客/成交客/公客重复,并在列表顶部提示重复数量。 2. **活跃度计算**:系统根据「最后跟进日期」自动计算客源活跃度,分为:新配偶(新建)/ 7日活跃 / 30日活跃 / 90日活跃 / 即将过期 / 无效。具体阈值由运营配置。 3. **私客自动转公规则**:超过配置天数(如 30 天)无跟进记录,系统自动将私客标记为公客(`transfer_to_public_type = 'auto'`)。 4. **状态机**:客源状态有严格流转规则(见第四章),不可跳过转台。 5. **跟进目的枚举**:由 `lookup_items` 表维护,运营可配置,当前已知 23 项(见 Story 8)。 6. **号码查看审计**:查看联系人明文号码需记录 `client_follow_logs`(`log_type = 'sensitive_view'`),不可删除。 7. **需求类型独立存储**:同一客源可同时有「二手购房」「租房」两类需求,分别存储在独立需求记录中,由 `client_requirements.requirement_type` 区分。 --- ## 二、实体关系 ``` Client (客源主表) │ ├── 1:N ── ClientContact (联系人,多个号码) ├── 1:N ── ClientRequirement (需求信息,可多类型) ├── 1:N ── ClientFollowLog (跟进日志,高写入频率) ├── 1:N ── ClientViewing (带看预约) ├── 1:N ── ClientPropertyMatch (智能配房结果) ├── 1:1 ── ClientActivityCache (活跃度缓存,异步计算) ├── N:M ── ClientFavoriteFolder (通过 client_folder_items 关联) └── 1:N ── ClientStatusLog (状态变更日志,不可删) ClientFavoriteFolder └── 1:N ── ClientFolderItem (收藏夹中的客源) Staff (员工) ├── first_recorder_id → Client (首录人) └── owner_id → Client (归属人) ``` --- ## 三、Schema 定义 ### 3.1 clients — 客源主表 | 字段 | 类型 | 约束 | 业务说明 | |------|------|------|----------| | id | UUID | PK | | | client_no | VARCHAR(30) | UNIQUE, NOT NULL | 系统生成的客源编号,格式由运营配置(如 KY20260424001) | | client_type | VARCHAR(20) | NOT NULL DEFAULT 'private' | `private`=私客 / `public`=公客 / `transacted`=成交客 | | status | VARCHAR(20) | NOT NULL DEFAULT 'buying' | 见下方枚举 | | grade | VARCHAR(5) | NOT NULL DEFAULT 'C' | `A_urgent`=A急迫 / `A` / `B`=较强 / `C`=一般 / `D`=较弱 / `E`=暂不关注 | | property_usage | VARCHAR(30) | NOT NULL DEFAULT 'residential' | `residential`=住宅 / `villa`=别墅 / `commercial_residential`=商住 / `shop`=商铺 / `office`=写字楼 / `other`=其他 | | buying_purpose | VARCHAR(20)[] | | 购房目的多选:`rigid`=刚需 / `investment`=投资 / `school_district`=学区 / `upgrade`=改善 / `commercial`=商用 / `other`=其他 | | payment_method | VARCHAR(30) | | `full`=全额 / `mortgage`=商业贷款 / `mortgage_fund`=商贷+公积金 / `fund`=公积金 | | properties_owned | VARCHAR(20) | | `none`=无 / `local_none`=本地无外地有 / `local_has`=本地有 | | has_loan_record | BOOLEAN | | 有无贷款记录 | | id_type | VARCHAR(20) | | 证件类型:`id_card` / `passport` / `hk_macao` / `other` | | id_number_enc | BYTEA | | 证件号码(AES 加密) | | source | VARCHAR(50) | | 客户来源(lookup_items 维护) | | remarks | TEXT | | 备注,最多200字 | | is_starred | BOOLEAN | NOT NULL DEFAULT FALSE | 是否收藏(快速标记,详细收藏夹用 client_folder_items) | | is_pinned | BOOLEAN | NOT NULL DEFAULT FALSE | 是否置顶(列表顶部置顶) | | is_big_value | BOOLEAN | NOT NULL DEFAULT FALSE | 是否大价值客户(影响筛选展示) | | is_protected | BOOLEAN | NOT NULL DEFAULT FALSE | 是否保护客(影响转公逻辑) | | prefers_new_house | BOOLEAN | | 偏好新房(用于筛选) | | transfer_to_public_type | VARCHAR(20) | | 转公客方式:`manual`=手动转公 / `auto`=自动转公(超时) / `marketing_jump`=营销客跳公 / `resource_public`=资料客素公 | | transferred_public_at | TIMESTAMPTZ | | 进入公客池时间 | | invalid_reason | VARCHAR(30) | | 无效原因:`invalid_phone`=号码无效 / `peer_agent`=同行 / `ad`=广告推销 / `no_intent`=无意向 / `other` | | invalidated_at | TIMESTAMPTZ | | 标记无效时间 | | transacted_at | DATE | | 成交日期 | | transacted_property_id | UUID | FK→properties, SET NULL | 成交关联的房源 | | transacted_price | NUMERIC(12,2) | | 成交价格(万元) | | transacted_type | VARCHAR(20) | | 成交类型:`bought`=我购 / `rented`=我租 | | transacted_property_type | VARCHAR(20) | | 成交房源类型:`second_hand`=二手 / `new_house`=新房 | | first_recorder_id | UUID | FK→staff, SET NULL | 首录人 | | owner_id | UUID | FK→staff, SET NULL | 归属人(私客独占跟进人) | | org_unit_id | UUID | FK→org_units, SET NULL | 归属部门(冗余,加速筛选) | | activity_level | VARCHAR(20) | | `new_matched`=新配偶 / `active_7d` / `active_30d` / `active_90d` / `expiring` / `frozen` / `invalid`(异步计算)| | last_active_at | TIMESTAMPTZ | | 最后有效跟进时间(触发器维护) | | last_follow_at | TIMESTAMPTZ | | 最后跟进时间(冗余,列表排序用) | | commission_date | DATE | | 委托日期 | | entrust_count | SMALLINT | NOT NULL DEFAULT 1 | 委托次数(成交后再委托则累加) | | created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | | updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | | deleted_at | TIMESTAMPTZ | | 软删除 | | created_by | UUID | FK→staff, SET NULL | | | updated_by | UUID | FK→staff, SET NULL | | **关键索引**: ```sql CREATE UNIQUE INDEX idx_clients_client_no ON clients(client_no) WHERE deleted_at IS NULL; CREATE INDEX idx_clients_type_status ON clients(client_type, status) WHERE deleted_at IS NULL; CREATE INDEX idx_clients_owner ON clients(owner_id) WHERE deleted_at IS NULL; CREATE INDEX idx_clients_org_unit ON clients(org_unit_id) WHERE deleted_at IS NULL; CREATE INDEX idx_clients_activity ON clients(activity_level, last_active_at DESC) WHERE deleted_at IS NULL; CREATE INDEX idx_clients_grade ON clients(grade) WHERE deleted_at IS NULL; CREATE INDEX idx_clients_transferred_at ON clients(transferred_public_at DESC) WHERE client_type = 'public'; CREATE INDEX idx_clients_last_follow ON clients(last_follow_at DESC NULLS LAST) WHERE deleted_at IS NULL; ``` --- ### 3.2 client_contacts — 联系人表 | 字段 | 类型 | 约束 | 业务说明 | |------|------|------|----------| | id | UUID | PK | | | client_id | UUID | NOT NULL, FK→clients, CASCADE | | | sort_order | SMALLINT | NOT NULL DEFAULT 0 | 联系人1为主联系人(sort_order=0) | | name | VARCHAR(50) | NOT NULL | 联系人姓名 | | gender | VARCHAR(10) | NOT NULL DEFAULT 'male' | `male`=先生 / `female`=女士 | | phone_enc | BYTEA | NOT NULL | AES-256-GCM 加密手机号(电话1) | | phone_hash | VARCHAR(64) | NOT NULL | SHA-256 哈希(重复检测) | | phone_country_code | VARCHAR(10) | NOT NULL DEFAULT '+86' | 国际区号 | | phone_is_invalid | BOOLEAN | NOT NULL DEFAULT FALSE | 是否被标记为无效号码 | | phone2_enc | BYTEA | | 备用电话2 | | phone2_hash | VARCHAR(64) | | | | wechat | VARCHAR(100) | | 微信号 | | qq | VARCHAR(20) | | QQ号 | | remarks | VARCHAR(200) | | 联系人备注,最多200字 | | created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | | updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | | deleted_at | TIMESTAMPTZ | | 软删除(不影响客源本身) | | created_by | UUID | FK→staff, SET NULL | | **关键索引**: ```sql -- 关键:手机号哈希全局唯一索引(用于重复客源检测) CREATE INDEX idx_client_contacts_phone_hash ON client_contacts(phone_hash) WHERE deleted_at IS NULL; CREATE INDEX idx_client_contacts_phone2_hash ON client_contacts(phone2_hash) WHERE phone2_hash IS NOT NULL AND deleted_at IS NULL; CREATE INDEX idx_client_contacts_client ON client_contacts(client_id) WHERE deleted_at IS NULL; ``` **业务注意**: - `sort_order = 0` 的联系人为主联系人,姓名用于客源姓名显示 - 手机号标记无效(`phone_is_invalid = TRUE`)时,不影响记录存在,但该号码不再参与重复检测 - 联系人软删除后客源仍保留,但若所有联系人均被删则客源实际上无有效号码 --- ### 3.3 client_requirements — 需求信息表 一个客源可同时有多类需求(二手购房、新房、租房),每类需求独立一条记录。 | 字段 | 类型 | 约束 | 业务说明 | |------|------|------|----------| | id | UUID | PK | | | client_id | UUID | NOT NULL, FK→clients, CASCADE | | | requirement_type | VARCHAR(20) | NOT NULL | `second_hand`=二手 / `new_house`=新房 / `rental`=租房 | | is_primary | BOOLEAN | NOT NULL DEFAULT TRUE | 是否为主需求(用于列表展示) | | budget_min | NUMERIC(12,2) | | 最低预算(万元/元,依据需求类型) | | budget_max | NUMERIC(12,2) | | 最高预算 | | area_min | NUMERIC(8,2) | | 最小面积(㎡) | | area_max | NUMERIC(8,2) | | 最大面积 | | bedroom_counts | SMALLINT[] | | 可接受卧室数:如 [2,3](多选) | | floor_preferences | VARCHAR(20)[] | | 楼层偏好多选:`no_first`=不要一层 / `low`=低楼层 / `mid`=中楼层 / `high`=高楼层 / `no_top`=不要顶层 | | orientations | VARCHAR(10)[] | | 朝向多选:`east`/`south`/`west`/`north` | | decorations | VARCHAR(10)[] | | 装修偏好多选(枚举同 properties.decoration) | | building_age_ranges | VARCHAR(20)[] | | 楼龄多选:`within_5y`/`5_10y`/`10_15y`/`15_20y`/`over_20y` | | intent_district_ids | UUID[] | | 意向行政区 ID 数组 | | intent_business_area_ids | UUID[] | | 意向商圈 ID 数组 | | intent_complex_names | TEXT | | 意向小区(文本,逗号分隔,最多500字) | | transportation | VARCHAR(50) | | 交通要求(最多50字) | | intent_school_names | TEXT | | 意向学校(文本,逗号分隔) | | school_enrollment_date | DATE | | 入学时间(月份精度,取该月1日存储) | | traffic_preference | TEXT | | 交通备注 | | requirement_notes | VARCHAR(200) | | 需求备注(最多200字) | | created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | | updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | **关键索引**: ```sql CREATE INDEX idx_client_requirements_client ON client_requirements(client_id); CREATE INDEX idx_client_requirements_type ON client_requirements(requirement_type, client_id); -- 智能配房时按预算/面积范围查询 CREATE INDEX idx_client_requirements_budget ON client_requirements(budget_min, budget_max); CREATE INDEX idx_client_requirements_area ON client_requirements(area_min, area_max); ``` --- ### 3.4 client_follow_logs — 客源跟进日志 > 与 `follow_logs`(房源跟进)结构类似,独立存储以避免跨模块混淆。 | 字段 | 类型 | 约束 | 业务说明 | |------|------|------|----------| | id | UUID | PK | | | client_id | UUID | NOT NULL, FK→clients, CASCADE | | | log_type | VARCHAR(30) | NOT NULL | 见下方枚举 | | purpose | VARCHAR(50) | | 跟进目的(lookup_items 维护,23项) | | content | TEXT | | 跟进内容(最少6字,最多500字) | | log_tag | VARCHAR(50) | | 跟进标签:`has_recording`=有录音 / `has_photo`=有图片 / `not_satisfied`=对房源不满意 / `still_considering`=还在考虑 / `ready_to_deposit`=可交定金 | | change_detail | JSONB | | 修改跟进专用,格式:`{"field": "grade", "old": "C", "new": "B", "label": "等级"}` | | is_public | BOOLEAN | NOT NULL DEFAULT TRUE | FALSE=仅本人及上级可见 | | is_deletable | BOOLEAN | NOT NULL DEFAULT TRUE | 敏感信息查看类型为 FALSE,不可删除 | | operator_id | UUID | FK→staff, SET NULL | 操作人 | | operator_snapshot | JSONB | | `{name, store_group, role}`(防止人员调动后显示异常) | | created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | | deleted_at | TIMESTAMPTZ | | 仅 is_deletable=TRUE 时可软删 | **log_type 枚举**: ``` written = 写入跟进(经纪人主动写) modified = 修改跟进(字段变更自动生成) sensitive_view= 敏感信息查看(查看号码等,不可删) other = 其他跟进(系统自动:新增私客/状态变更等) system = 系统日志 ``` **关键索引**: ```sql CREATE INDEX idx_client_follow_logs_client_time ON client_follow_logs(client_id, created_at DESC) WHERE deleted_at IS NULL; CREATE INDEX idx_client_follow_logs_type ON client_follow_logs(client_id, log_type, created_at DESC) WHERE deleted_at IS NULL; CREATE INDEX idx_client_follow_logs_operator ON client_follow_logs(operator_id, created_at DESC) WHERE deleted_at IS NULL; -- 不可删记录(合规审计) CREATE INDEX idx_client_follow_sensitive ON client_follow_logs(client_id, created_at DESC) WHERE log_type = 'sensitive_view'; ``` --- ### 3.5 client_follow_log_attachments — 跟进附件 | 字段 | 类型 | 约束 | 业务说明 | |------|------|------|----------| | id | UUID | PK | | | follow_log_id | UUID | NOT NULL, FK→client_follow_logs, CASCADE | | | file_key | TEXT | NOT NULL | R2/S3 存储路径 | | file_name | VARCHAR(255) | NOT NULL | | | file_size | INTEGER | NOT NULL | bytes,最大 20MB | | file_type | VARCHAR(10) | CHECK | `bmp`/`jpg`/`png`/`gif` | | has_location | BOOLEAN | NOT NULL DEFAULT FALSE | 是否含 GPS 位置信息 | | sort_order | SMALLINT | NOT NULL DEFAULT 0 | | | created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | --- ### 3.6 client_viewings — 带看记录(客源侧视图) | 字段 | 类型 | 约束 | 业务说明 | |------|------|------|----------| | id | UUID | PK | | | client_id | UUID | NOT NULL, FK→clients, RESTRICT | | | property_id | UUID | NOT NULL, FK→properties, RESTRICT | | | viewing_type | VARCHAR(20) | NOT NULL DEFAULT 'viewing' | `appointment`=预约 / `viewing`=带看 / `revisit`=复看 / `empty`=空看 | | agent_id | UUID | FK→staff, SET NULL | 主带看经纪人 | | companion_ids | UUID[] | | 陪看人员 ID 数组(最多5人) | | cooperator_ids | UUID[] | | 合作带看人 ID 数组(最多5人) | | scheduled_at | TIMESTAMPTZ | | 预约时间 | | viewing_start_at | TIMESTAMPTZ | | 实际带看开始时间 | | viewing_end_at | TIMESTAMPTZ | | 结束时间 | | situation | TEXT | | 带看情况(必填,≥6字) | | client_intent | VARCHAR(20) | | 客户意向:`interested`=感兴趣 / `not_interested`=不感兴趣 / `negotiating`=谈判中 / `cancelled`=取消 | | viewing_progress | SMALLINT | | 带看进度(1=一看,2=二看...,冗余字段,触发器维护) | | created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | | deleted_at | TIMESTAMPTZ | | | | created_by | UUID | FK→staff, SET NULL | | **关键索引**: ```sql CREATE INDEX idx_client_viewings_client ON client_viewings(client_id, viewing_start_at DESC) WHERE deleted_at IS NULL; CREATE INDEX idx_client_viewings_property ON client_viewings(property_id) WHERE deleted_at IS NULL; CREATE INDEX idx_client_viewings_agent ON client_viewings(agent_id) WHERE deleted_at IS NULL; ``` --- ### 3.7 client_property_matches — 智能配房 | 字段 | 类型 | 约束 | 业务说明 | |------|------|------|----------| | id | UUID | PK | | | client_id | UUID | NOT NULL, FK→clients, CASCADE | | | property_id | UUID | NOT NULL, FK→properties, CASCADE | | | match_source | VARCHAR(20) | NOT NULL DEFAULT 'recorded' | `recorded`=录客配房(基于录入需求) / `system`=系统配房(算法推荐) | | match_group | VARCHAR(30) | | 分组:`quality_layout`=优质户型 / `price_reduced`=降价 / `hot`=热门 / `newly_listed`=新上 | | match_score | NUMERIC(5,2) | | 匹配度评分(0-100) | | match_reasons | JSONB | | 匹配原因详情,格式:`[{"key": "budget", "match": true}, ...]` | | status | VARCHAR(20) | NOT NULL DEFAULT 'suggested' | `suggested`=待推送 / `shared`=已分享 / `rejected`=已反馈不合适 / `viewed`=客户已查看 | | shared_at | TIMESTAMPTZ | | 分享时间 | | feedback | VARCHAR(50) | | 反馈原因(lookup_items 维护) | | calculated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 配房计算时间 | | created_by | UUID | FK→staff, SET NULL | | **关键索引**: ```sql CREATE UNIQUE INDEX idx_client_matches_pair ON client_property_matches(client_id, property_id); CREATE INDEX idx_client_matches_client ON client_property_matches(client_id, match_source, match_group); CREATE INDEX idx_client_matches_status ON client_property_matches(client_id, status) WHERE status != 'rejected'; ``` --- ### 3.8 client_status_logs — 状态变更日志(不可删) | 字段 | 类型 | 约束 | 业务说明 | |------|------|------|----------| | id | UUID | PK | | | client_id | UUID | NOT NULL, FK→clients, RESTRICT | | | change_type | VARCHAR(30) | NOT NULL | `status_change`=改状态 / `grade_change`=改等级 / `to_public`=转公客 / `to_transacted`=转成交 / `to_invalid`=转无效 / `owner_change`=改归属人 / `source_change`=改来源 | | old_value | JSONB | | 变更前快照,格式:`{"status": "buying", "label": "求购"}` | | new_value | JSONB | | 变更后快照 | | reason | TEXT | | 变更理由(改状态必填,最多200字) | | operator_id | UUID | NOT NULL, FK→staff, RESTRICT | | | operated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | | ⚠️ 无 deleted_at | — | — | 此表记录**不可删除** | **关键索引**: ```sql CREATE INDEX idx_client_status_logs_client ON client_status_logs(client_id, operated_at DESC); CREATE INDEX idx_client_status_logs_type ON client_status_logs(change_type, operated_at DESC); ``` --- ### 3.9 client_favorite_folders — 私客收藏夹 | 字段 | 类型 | 约束 | 业务说明 | |------|------|------|----------| | id | UUID | PK | | | staff_id | UUID | NOT NULL, FK→staff, CASCADE | 收藏夹所属经纪人 | | name | VARCHAR(10) | NOT NULL | 收藏夹名称,最多10字 | | is_default | BOOLEAN | NOT NULL DEFAULT FALSE | 系统默认收藏夹 | | sort_order | INTEGER | NOT NULL DEFAULT 0 | | | created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | | deleted_at | TIMESTAMPTZ | | | ```sql CREATE INDEX idx_favorite_folders_staff ON client_favorite_folders(staff_id) WHERE deleted_at IS NULL; -- 每个经纪人只能有一个默认收藏夹 CREATE UNIQUE INDEX idx_favorite_folders_default ON client_favorite_folders(staff_id) WHERE is_default = TRUE AND deleted_at IS NULL; ``` --- ### 3.10 client_folder_items — 收藏夹中的客源 | 字段 | 类型 | 约束 | 业务说明 | | ----------- | ---------------------- | --------------------------------------------- | ---- | | folder_id | UUID | NOT NULL, FK→client_favorite_folders, CASCADE | | | client_id | UUID | NOT NULL, FK→clients, CASCADE | | | added_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | | PRIMARY KEY | (folder_id, client_id) | | | ```sql CREATE INDEX idx_folder_items_client ON client_folder_items(client_id); ``` --- ### 3.11 client_school_preferences — 意向学校(多对多) > 单独拆表便于学校搜索,避免文本字段模糊查询。 | 字段 | 类型 | 约束 | 业务说明 | |------|------|------|----------| | id | UUID | PK | | | requirement_id | UUID | NOT NULL, FK→client_requirements, CASCADE | | | school_id | UUID | FK→schools, SET NULL | 从学校表选择,允许为 NULL(自由输入) | | school_name | VARCHAR(100) | NOT NULL | 学校名称(当 school_id 为 NULL 时为手动输入) | | created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | ```sql CREATE INDEX idx_school_prefs_requirement ON client_school_preferences(requirement_id); ``` --- ## 四、枚举常量 ### clients.status(客源状态) ``` buying = 求购(私客活跃态) renting = 求租(私客活跃态) buy_or_rent = 租购(私客活跃态) suspended = 暂缓(暂时无需求,不计入活跃统计) bought = 已购(成交客:我购) rented_done = 已租(成交客:我租) public = 公客(已转入公客池) invalid = 无效(号码无效/无意向等) ``` **状态流转规则**: ``` buying/renting/buy_or_rent → suspended (改状态操作,可逆) → public (手动转公 or 超时自动转公,不可逆) → bought/rented_done (转成交,不可逆) → invalid (转无效,需经理审批后可恢复) ``` ### clients.grade(等级) ``` A = A(急迫) B = B(较强) C = C(一般,默认值) D = D(较弱) E = E(暂不关注) ``` ### client_status_logs.change_type(变更类型) ``` status_change = 改状态(含改等级时同时改状态的情况) grade_change = 改等级 to_public = 转公客(manual=手动 or auto=自动) to_transacted = 转成交(记录成交信息) to_invalid = 转无效(含无效原因) owner_change = 改归属人 source_change = 改来源 merge = 合并客源(被合并的记录保留日志) ``` ### clients.activity_level(活跃度分层,系统计算) | 值 | 含义 | 触发条件(示例,以运营配置为准) | | ------------- | ------ | ------------------ | | `new_matched` | 新匹配 | 录入后 3 天内 | | `active_7d` | 7日活跃 | 最后跟进在 7 天内 | | `active_30d` | 30日活跃 | 最后跟进在 30 天内 | | `active_90d` | 90日活跃 | 最后跟进在 90 天内 | | `expiring` | 即将过期 | 距自动转公还有 N 天 | | `frozen` | 冻结(暂缓) | status = suspended | | `invalid` | 无效 | status = invalid | --- ## 五、查询模式 ### 5.1 私客列表页(求购 Tab)核心查询 ```sql -- 典型:当前经纪人名下 + 求购状态 + 等级筛选 + 按最后跟进排序 SELECT c.id, c.status, c.grade, c.activity_level, c.last_follow_at, c.commission_date, c.buying_purpose, cc.name AS contact_name, -- JOIN 主联系人 s.name AS owner_name, ou.name AS org_unit_name, COUNT(cpm.id) AS match_count -- 智能配房数量 FROM clients c JOIN client_contacts cc ON cc.client_id = c.id AND cc.sort_order = 0 AND cc.deleted_at IS NULL JOIN staff s ON s.id = c.owner_id JOIN org_units ou ON ou.id = c.org_unit_id LEFT JOIN client_property_matches cpm ON cpm.client_id = c.id AND cpm.status != 'rejected' WHERE c.client_type = 'private' AND c.owner_id = :current_staff_id -- 与我相关 AND c.status IN ('buying', 'buy_or_rent') AND c.deleted_at IS NULL GROUP BY c.id, cc.name, s.name, ou.name ORDER BY c.last_follow_at DESC NULLS LAST LIMIT 20 OFFSET :offset; ``` ### 5.2 重复客源检测(录入/编辑时触发) ```sql -- 手机号哈希碰撞检测(私客、成交客、公客三池同时检查) SELECT c.id, c.client_type, c.status, c.client_no, cc.name AS contact_name FROM client_contacts cc JOIN clients c ON cc.client_id = c.id WHERE cc.phone_hash = :new_phone_hash AND cc.deleted_at IS NULL AND c.deleted_at IS NULL AND c.status != 'invalid'; ``` ### 5.3 活跃度批量更新(Celery 定时任务,每日凌晨执行) ```sql -- 更新活跃度(以7日活跃为例) UPDATE clients SET activity_level = 'active_7d', updated_at = NOW() WHERE client_type = 'private' AND status NOT IN ('invalid', 'public', 'bought', 'rented_done') AND last_follow_at >= NOW() - INTERVAL '7 days' AND deleted_at IS NULL; ``` ### 5.4 私客自动转公(超时无跟进,Celery 定时任务) ```sql -- 查询应自动转公的私客(阈值由运营配置,假设30天) SELECT id FROM clients WHERE client_type = 'private' AND status IN ('buying', 'renting', 'buy_or_rent') AND last_follow_at < NOW() - INTERVAL '30 days' AND is_protected = FALSE AND deleted_at IS NULL; -- 后续在 Application 层批量更新 client_type='public', transfer_to_public_type='auto' ``` --- ## 六、触发器 ### 6.1 last_follow_at 自动维护 ```sql -- 每次写入跟进日志时,自动更新 clients.last_follow_at 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(); ``` ### 6.2 viewing_progress 自动维护 ```sql -- 每次新增带看记录时,自动更新 clients 的带看进度冗余字段 CREATE OR REPLACE FUNCTION update_client_viewing_progress() RETURNS TRIGGER AS $$ BEGIN UPDATE clients SET updated_at = NOW() WHERE id = NEW.client_id; -- Application 层根据 COUNT(viewings) 计算具体进度 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(); ``` --- ## 七、禁止操作 - ❌ **严禁硬删除 clients 记录**:无效/转公客/成交客均通过 status 和 soft delete 处理,历史跟进/带看依赖外键 - ❌ **严禁删除 client_status_logs**:状态变更为不可变审计日志 - ❌ **严禁删除 log_type='sensitive_view' 的跟进记录**:必须通过 `is_deletable=FALSE` 约束在应用层拦截 - ❌ **严禁明文存储联系人手机号**:必须走 `EncryptedPhoneField`,`phone_hash` 用于索引和重复检测 - ❌ **严禁跳过状态机流转**:如私客不可直接跳过「求购」变为「无效」而不生成 status log - ❌ **严禁在没有 `client_type` 过滤的情况下查询客源列表**:私客/公客/成交客数据量均较大,必须按类型隔离查询 - ❌ **严禁查询 clients 时不带 `deleted_at IS NULL`**:软删除过滤必须存在