Files
nexus/Project/fonrey/DATA_MODEL/DATA_MODEL_CLIENT.md

575 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_urgent = A(急迫)
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`**:软删除过滤必须存在