Sync: expand data model and gitops notes

This commit is contained in:
2026-04-24 14:49:34 +08:00
parent 7550b4ee18
commit 75b9e25e68
13 changed files with 2418 additions and 318 deletions

View File

@@ -70,7 +70,59 @@
---
## 二、公共 SchemaShared / Public
## 二、领域概览Domain Overview
本节用业务语言描述系统的核心领域对象及其关系,作为各子模块数据模型的导读。
### 核心领域对象
| 领域对象 | 表/子文档 | 业务说明 |
|----------|-----------|----------|
| **Tenant租户** | `public.tenants` | 每家房产经纪公司对应一个租户数据完全隔离Schema-per-Tenant |
| **OrgUnit组织架构** | `org_units` → [DATA_MODEL_ORG.md](./DATA_MODEL_ORG.md) | 树形组织架构(总部/区域/城市/大区/分公司/门店/团队/虚拟团队),物化路径存储,支持权限继承 |
| **Staff员工** | `staff` → [DATA_MODEL_ORG.md](./DATA_MODEL_ORG.md) | 经纪人/店长/经理,绑定组织节点,手机号加密存储,与账号(登录)分离 |
| **District城区** | `districts` → [DATA_MODEL_COMPLEX.md](./DATA_MODEL_COMPLEX.md) | 行政区划,如「静安区」,是区域体系的顶层节点 |
| **BusinessArea商圈** | `business_areas` → [DATA_MODEL_COMPLEX.md](./DATA_MODEL_COMPLEX.md) | 商圈/板块,从属于城区,一个楼盘可归属多个商圈 |
| **School学校** | `schools` → [DATA_MODEL_COMPLEX.md](./DATA_MODEL_COMPLEX.md) | 对口学校数据库,是买家购房决策的核心参考,与楼盘多对多关联 |
| **Complex楼盘/小区)** | `complexes` → [DATA_MODEL_COMPLEX.md](./DATA_MODEL_COMPLEX.md) | 房源录入的基础底座,维护楼盘标准名称/坐标/锁定状态/别名等 |
| **Building楼栋/单元)** | `buildings` → [DATA_MODEL_COMPLEX.md](./DATA_MODEL_COMPLEX.md) | 楼盘下的物理楼栋,区分标准结构与非标结构 |
| **RoomUnit房号** | `room_units` → [DATA_MODEL_COMPLEX.md](./DATA_MODEL_COMPLEX.md) | 楼层+房间号,房源定位的最细粒度 |
| **Property房源** | `properties` → §3.3 | 系统核心表,每套二手房源的完整档案,支持出售/出租/出售兼出租三态 |
| **Client客源** | `clients` → [DATA_MODEL_CLIENT.md](./DATA_MODEL_CLIENT.md) | 买家/租客档案,分私客/公客/成交客,含活跃度评分与自动公客转换机制 |
| **Viewing带看** | `client_viewings` → [DATA_MODEL_CLIENT.md](./DATA_MODEL_CLIENT.md) | 经纪人带客户看房的完整记录 |
| **Match配对** | `client_property_matches` → [DATA_MODEL_CLIENT.md](./DATA_MODEL_CLIENT.md) | 系统/人工推荐的客源↔房源配对 |
### 领域关系快速导航
```
District (城区)
└─ BusinessArea (商圈)
└─ Complex (楼盘) ─── School (对口学校)
├─ Building (楼栋)
│ └─ RoomUnit (房号)
└─ Property (房源)
├─ PropertyContact (联系人/委托方)
├─ FollowLog (跟进日志)
├─ Viewing (带看记录) ──── Client (客源)
└─ Match (配对记录) ──────┘
OrgUnit (组织架构)
└─ Staff (员工/经纪人) ─── Property / Client / Viewing / Match
```
### 子文档索引
| 子文档 | 覆盖模块 | 状态 |
|--------|----------|------|
| [DATA_MODEL_ORG.md](./DATA_MODEL_ORG.md) | 组织人事org_units, staff, 异动/奖惩/教育/家庭等) | ✅ 完成 |
| [DATA_MODEL_COMPLEX.md](./DATA_MODEL_COMPLEX.md) | 楼盘/区域districts, business_areas, complexes, buildings, room_units, schools 等) | ✅ 完成 |
| [DATA_MODEL_CLIENT.md](./DATA_MODEL_CLIENT.md) | 客源管理clients, requirements, follow_logs, viewings, matches 等) | ✅ 完成 |
| 本文档 §3.3§3.16 | 房源核心properties 及配套 12 张表)、系统设置 | ✅ 完成 |
---
## 三、公共 SchemaShared / Public
```sql
-- ============================================================
@@ -107,7 +159,7 @@ CREATE INDEX idx_domains_primary ON public.domains(tenant_id) WHERE is_primary =
---
## 、租户 SchemaTenant Schema
## 、租户 SchemaTenant Schema
以下所有表均在每个租户的独立 Schema 内创建。
@@ -115,190 +167,56 @@ CREATE INDEX idx_domains_primary ON public.domains(tenant_id) WHERE is_primary =
### 3.1 组织人事模块Organization & HR
```sql
-- ============================================================
-- 组织架构:公司 → 区域 → 门店 → 组
-- ============================================================
> **详细模型** → 见 [`DATA_MODEL_ORG.md`](./DATA_MODEL_ORG.md)
> 该文件为权威定义,包含完整字段、枚举、查询模式和禁止操作。
-- 组织节点表(树形结构,支持无限层级)
CREATE TABLE org_units (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
type VARCHAR(20) NOT NULL
CHECK (type IN ('company','region','store','group')),
parent_id UUID REFERENCES org_units(id) ON DELETE RESTRICT,
path TEXT NOT NULL, -- 物化路径:/root_id/parent_id/self_id/
depth SMALLINT NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
**核心表概览**(开发时以 DATA_MODEL_ORG.md 为准):
CREATE INDEX idx_org_units_parent ON org_units(parent_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_org_units_path ON org_units USING gist(path gist_trgm_ops);
-- 注gist_trgm_ops 需要 pg_trgm 扩展,用于路径前缀查询
| 表名 | 说明 |
|------|------|
| `org_units` | 组织树节点(公司/事业部/大区/区域/片区/门店/店组/职能),物化路径树 |
| `staff` | 员工主表含加密手机号、角色、在职状态、Django auth 绑定 |
| `staff_personal_info` | 员工个人信息扩展证件、学历、婚育等1:1 |
| `staff_transfer_logs` | 人事异动不可变审计日志(入职/调动/离职/复职等) |
| `staff_reward_punish` | 奖惩记录 |
| `staff_work_experiences` | 工作经历 |
| `staff_educations` | 教育经历 |
| `staff_trainings` | 培训经历 |
| `staff_family_members` | 家庭成员 |
| `staff_accounts` | 第三方平台账号绑定58安居客/中国网络经纪人等) |
-- 员工表
CREATE TABLE staff (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_unit_id UUID NOT NULL REFERENCES org_units(id) ON DELETE RESTRICT,
name VARCHAR(50) NOT NULL,
phone_hash VARCHAR(64), -- SHA-256 哈希,用于唯一性校验
phone_enc BYTEA, -- AES-256-GCM 加密后的手机号
email VARCHAR(255),
role VARCHAR(30) NOT NULL
CHECK (role IN ('agent','store_manager','admin','operator','system')),
job_title VARCHAR(100), -- 职务描述
avatar_key TEXT, -- R2/S3 存储路径
is_active BOOLEAN NOT NULL DEFAULT TRUE,
joined_at DATE,
left_at DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
-- 关联 Django auth user用于登录认证
user_id INTEGER UNIQUE -- FK to django auth_user
);
CREATE INDEX idx_staff_org ON staff(org_unit_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_staff_role ON staff(role) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX idx_staff_phone_hash ON staff(phone_hash) WHERE deleted_at IS NULL;
```
**关键约束提示**
- `staff.phone_enc` AES-256-GCM 加密,`staff.phone_hash` SHA-256 用于唯一索引
- `staff_transfer_logs` **无 deleted_at**,不可删除
- `org_units` 路径查询:`WHERE path LIKE '/root/{target_id}/%'`
- 员工离职:`status = 'resigned'` + `deleted_at` 软删除,记录永久保留
---
### 3.2 区域与楼盘模块Region & Complex Management
```sql
-- ============================================================
-- 行政区 → 商圈 → 楼盘/小区 → 楼栋
-- 注:楼盘数据是房源录入的基础底座,数据质量直接影响房源录入效率
-- ============================================================
> **详细模型** → 见 [`DATA_MODEL_COMPLEX.md`](./DATA_MODEL_COMPLEX.md)
> 本节仅作概览,开发时以 DATA_MODEL_COMPLEX.md 为权威定义。
-- 城市/行政区
CREATE TABLE districts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) NOT NULL,
city VARCHAR(50) NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
**核心表概览**(开发时以 DATA_MODEL_COMPLEX.md 为准):
-- 商圈/板块
CREATE TABLE business_areas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
district_id UUID NOT NULL REFERENCES districts(id) ON DELETE RESTRICT,
name VARCHAR(100) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX idx_business_areas_district ON business_areas(district_id);
-- 地铁线路
CREATE TABLE metro_lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) NOT NULL,
color VARCHAR(7) -- 线路颜色 HEX
);
-- 地铁站
CREATE TABLE metro_stations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
metro_line_id UUID NOT NULL REFERENCES metro_lines(id) ON DELETE CASCADE,
name VARCHAR(50) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);
-- 学校
CREATE TABLE schools (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
district_id UUID REFERENCES districts(id) ON DELETE SET NULL,
name VARCHAR(100) NOT NULL,
type VARCHAR(20) -- 小学/初中/高中/九年一贯制 等
);
CREATE INDEX idx_schools_district ON schools(district_id);
-- 楼盘/小区(核心基础表)
CREATE TABLE complexes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
alias VARCHAR(200), -- 别名/曾用名
district_id UUID REFERENCES districts(id) ON DELETE SET NULL,
business_area_id UUID REFERENCES business_areas(id) ON DELETE SET NULL,
address VARCHAR(500),
latitude NUMERIC(10,7),
longitude NUMERIC(10,7),
-- 楼盘物理属性
developer VARCHAR(200), -- 开发商
property_company VARCHAR(200), -- 物业公司
property_fee NUMERIC(8,2), -- 物业费 元/㎡/月
green_rate NUMERIC(5,2), -- 绿化率 %
plot_ratio NUMERIC(5,2), -- 容积率
built_year SMALLINT, -- 竣工年份
ownership_years VARCHAR(20), -- 产权年限枚举
-- 配套信息
has_elevator BOOLEAN,
parking_info TEXT, -- 车位情况描述
-- 全文检索向量(定期更新)
search_vector TSVECTOR,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by UUID REFERENCES staff(id) ON DELETE SET NULL
);
CREATE INDEX idx_complexes_district ON complexes(district_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_complexes_name_trgm ON complexes USING gin(name gin_trgm_ops);
CREATE INDEX idx_complexes_search ON complexes USING gin(search_vector);
CREATE INDEX idx_complexes_geo ON complexes(latitude, longitude) WHERE deleted_at IS NULL;
-- 楼盘与商圈多对多(一个楼盘可跨多个商圈)
CREATE TABLE complex_business_areas (
complex_id UUID NOT NULL REFERENCES complexes(id) ON DELETE CASCADE,
business_area_id UUID NOT NULL REFERENCES business_areas(id) ON DELETE CASCADE,
PRIMARY KEY (complex_id, business_area_id)
);
-- 楼盘与学校关联
CREATE TABLE complex_schools (
complex_id UUID NOT NULL REFERENCES complexes(id) ON DELETE CASCADE,
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
school_zone VARCHAR(50), -- 学区情况:对口/参考等
PRIMARY KEY (complex_id, school_id)
);
-- 楼盘与地铁站关联
CREATE TABLE complex_metro_stations (
complex_id UUID NOT NULL REFERENCES complexes(id) ON DELETE CASCADE,
station_id UUID NOT NULL REFERENCES metro_stations(id) ON DELETE CASCADE,
distance_meters INTEGER, -- 步行距离(米)
PRIMARY KEY (complex_id, station_id)
);
-- 楼栋
CREATE TABLE buildings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
complex_id UUID NOT NULL REFERENCES complexes(id) ON DELETE CASCADE,
name VARCHAR(50) NOT NULL, -- 楼栋名,如"1号楼"
total_floors SMALLINT NOT NULL,
has_elevator BOOLEAN,
building_type VARCHAR(30), -- 楼型:板楼/塔楼/板塔结合
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_buildings_complex ON buildings(complex_id);
```
| 表名 | 说明 | 关键字段 |
|------|------|----------|
| `districts` | 城区/行政区 | `city`, `name`, `short_name`, `sort_order` |
| `business_areas` | 商圈/板块(从属于城区) | `district_id`, `name`, `latitude`, `longitude` |
| `metro_lines` | 地铁线路 | `city`, `name`, `color` |
| `metro_stations` | 地铁站点 | `metro_line_id`, `name`, `latitude`, `longitude` |
| `schools` | 学校(对口学区) | `district_id`, `name`, `type`, `nature`, `level` |
| `complexes` | 楼盘/小区(房源底座) | `name`, `district_id`, `address`, `latitude/longitude`, `lock_*`, `search_vector` |
| `complex_aliases` | 楼盘别名(含系统别名/用户自定义别名) | `complex_id`, `alias`, `is_system` |
| `complex_business_areas` | 楼盘↔商圈多对多(含主商圈标识) | `complex_id`, `business_area_id`, `is_primary` |
| `complex_schools` | 楼盘↔学校关联(含学区类型) | `complex_id`, `school_id`, `zone_type` |
| `complex_metro_stations` | 楼盘↔地铁站关联(含步行距离) | `complex_id`, `station_id`, `distance_meters` |
| `buildings` | 楼栋/单元 | `complex_id`, `name`, `is_standard`, `total_floors` |
| `room_units` | 房号/结构单元(楼层+房间号) | `building_id`, `floor`, `room_no`, `is_standard` |
| `complex_photos` | 楼盘照片(楼盘图/户型图/VR | `complex_id`, `category`, `file_key`, `is_cover` |
| `complex_attachments` | 楼盘附件 | `complex_id`, `file_key`, `file_name` |
| `complex_price_trends` | 楼盘价格走势(月度) | `complex_id`, `record_month`, `avg_unit_price` |
---
@@ -1066,123 +984,30 @@ CREATE TABLE property_completeness (
### 3.17 客源管理Client Management
```sql
-- ============================================================
-- 客源:私客为核心,公客/成交客为后续版本
-- ============================================================
> **详细模型** → 见 [`DATA_MODEL_CLIENT.md`](./DATA_MODEL_CLIENT.md)
> 该文件为权威定义,包含完整字段、枚举、状态机、查询模式和禁止操作。
CREATE TABLE clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
**核心表概览**(开发时以 DATA_MODEL_CLIENT.md 为准):
client_type VARCHAR(20) NOT NULL DEFAULT 'private'
CHECK (client_type IN ('private','public','transacted')),
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active','converted_public',
'transacted','invalid')),
| 表名 | 说明 |
|------|------|
| `clients` | 客源主表(私客/公客/成交客),含加密手机号哈希、活跃度、归属人 |
| `client_contacts` | 联系人1:N手机号加密+哈希,支持多联系人 |
| `client_requirements` | 需求信息(可多类型:二手/新房/租房),含预算/面积/商圈/朝向等偏好 |
| `client_follow_logs` | 跟进日志高写入频率5种类型敏感查看类型不可删 |
| `client_follow_log_attachments` | 跟进附件(图片/录音最大20MB |
| `client_viewings` | 带看/预约记录1:N含陪看人/合作带看人) |
| `client_property_matches` | 智能配房结果(录客配房/系统配房,匹配度评分) |
| `client_status_logs` | 状态变更不可变审计日志(改状态/改等级/转公/转成交/转无效等) |
| `client_favorite_folders` | 私客收藏夹(经纪人自定义分组) |
| `client_folder_items` | 收藏夹与客源的多对多关联 |
| `client_school_preferences` | 意向学校(拆表,支持精确查询) |
name VARCHAR(50) NOT NULL,
gender VARCHAR(10)
CHECK (gender IN ('male','female','unknown')),
-- 手机号加密存储
phone_enc BYTEA NOT NULL,
phone_hash VARCHAR(64) NOT NULL,
phone2_enc BYTEA,
phone2_hash VARCHAR(64),
-- 购房需求
purpose VARCHAR(10) NOT NULL
CHECK (purpose IN ('buy','rent')),
budget_min NUMERIC(12,2),
budget_max NUMERIC(12,2),
area_min NUMERIC(8,2),
area_max NUMERIC(8,2),
bedroom_needs SMALLINT[], -- 可接受的卧室数量数组
-- 意向区域(存 district/business_area ID 数组)
district_ids UUID[],
business_area_ids UUID[],
-- 活跃度分层(由系统计算)
activity_level VARCHAR(10)
CHECK (activity_level IN ('hot','warm','cold','frozen')),
last_active_at TIMESTAMPTZ,
-- 负责经纪人
agent_id UUID REFERENCES staff(id) ON DELETE SET NULL,
org_unit_id UUID REFERENCES org_units(id) ON DELETE SET NULL,
source VARCHAR(50),
remarks TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by UUID REFERENCES staff(id) ON DELETE SET NULL
);
CREATE INDEX idx_clients_agent ON clients(agent_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_clients_phone_hash ON clients(phone_hash) WHERE deleted_at IS NULL;
CREATE INDEX idx_clients_status ON clients(status, client_type) WHERE deleted_at IS NULL;
CREATE INDEX idx_clients_activity ON clients(activity_level, last_active_at DESC)
WHERE deleted_at IS NULL;
-- 客源跟进日志(复用结构,单独表避免与房源日志混合)
CREATE TABLE client_follow_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
purpose VARCHAR(50),
content TEXT,
log_tag VARCHAR(50),
is_public BOOLEAN NOT NULL DEFAULT TRUE,
operator_id UUID REFERENCES staff(id) ON DELETE SET NULL,
operator_snapshot JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_client_logs_client ON client_follow_logs(client_id, created_at DESC)
WHERE deleted_at IS NULL;
-- 智能配房记录(客源 ↔ 房源 匹配)
CREATE TABLE client_property_matches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
match_score NUMERIC(5,2), -- 匹配度评分
match_reason JSONB, -- 匹配原因详情
status VARCHAR(20) NOT NULL DEFAULT 'suggested'
CHECK (status IN ('suggested','shared','viewing','rejected')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES staff(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX idx_client_property_match
ON client_property_matches(client_id, property_id);
-- 带看记录
CREATE TABLE viewings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
property_id UUID NOT NULL REFERENCES properties(id) ON DELETE RESTRICT,
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE RESTRICT,
agent_id UUID REFERENCES staff(id) ON DELETE SET NULL,
viewing_type VARCHAR(20) NOT NULL DEFAULT 'first'
CHECK (viewing_type IN ('first','revisit','empty','interview')),
-- first=带看, revisit=复看, empty=空看, interview=面访
scheduled_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
result VARCHAR(20)
CHECK (result IN ('interested','not_interested',
'negotiating','cancelled')),
remarks TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_viewings_property ON viewings(property_id);
CREATE INDEX idx_viewings_client ON viewings(client_id);
```
**关键约束提示**
- `client_contacts.phone_hash` 是重复客源检测的唯一依据,录入前必须查重
- `client_status_logs` **无 deleted_at**,不可删除
- 私客超时(配置天数内无跟进)→ Celery 自动转公(`transfer_to_public_type = 'auto'`
- 活跃度 `activity_level` 由 Celery 每日凌晨批量计算,不实时更新
---
@@ -1290,7 +1115,7 @@ CREATE INDEX idx_number_holder_approvals_status ON number_holder_approvals(statu
---
## 、关键索引汇总与查询优化策略
## 、关键索引汇总与查询优化策略
### 4.1 房源列表页核心查询分析
@@ -1372,7 +1197,7 @@ CREATE TRIGGER trg_update_last_followed
---
## 、Redis 缓存策略
## 、Redis 缓存策略
### 5.1 缓存 Key 规范
@@ -1422,7 +1247,7 @@ CREATE TRIGGER trg_update_last_followed
---
## 、Django Model 层设计要点
## 、Django Model 层设计要点
### 6.1 抽象基类
@@ -1500,7 +1325,7 @@ class PropertyManager(ActiveManager):
---
## 、数据量与性能预测
## 、数据量与性能预测
| 表名 | 预估行数 | 增长速度 | 分区策略 |
|------|---------|---------|---------|
@@ -1514,7 +1339,7 @@ class PropertyManager(ActiveManager):
---
## 、必须在开发启动前明确的数据架构决策
## 、必须在开发启动前明确的数据架构决策
| 决策项 | 推荐方案 | 风险 |
|-------|---------|------|

View File

@@ -0,0 +1,574 @@
# 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`**:软删除过滤必须存在

View File

@@ -0,0 +1,547 @@
# Fonrey — 楼盘与区域数据模型DATA_MODEL_COMPLEX
> **所属系统**: Fonrey 房产经纪管理系统
> **版本**: v1.0
> **日期**: 2026-04-24
> **关联模块**: `apps/complex/` — 楼盘/小区、楼栋、结构(楼层+房号)、区域、学校
---
## 一、领域概览Domain Overview
### 核心概念
- **Complex楼盘/小区)**:房源录入的基础底座。每套房源必须归属于某一楼盘。楼盘数据由运营/数据管理员集中维护,质量直接影响房源录入效率和搜索精度。
- **Building楼栋/单元)**楼盘下的物理楼栋是组织房源位置的第二级。一个楼盘可有多个楼栋如「1号楼」「2栋2单元」
- **RoomUnit房号/结构单元)**:楼栋内特定楼层的某个房间标识,是房源定位的最细粒度。支持「标准结构」(经运营标准化)和「非标结构」(未归一化)两类。
- **District城区/行政区)**:行政区划,如静安区、闵行区。
- **BusinessArea商圈/板块)**:商圈是区域内的细分市场区域,如「南京西路商圈」,一个楼盘可跨多个商圈。
- **School学校**:楼盘对口学校,是买家购房决策的核心关注点。一个楼盘可关联多所学校,一所学校可对口多个楼盘。
- **MetroLine / MetroStation地铁线路/站点)**:楼盘与最近地铁站的距离关系,用于通勤筛选。
### 关键业务规则
1. **楼盘名称不可在编辑页修改**:楼盘名称(`name`)变更须通过「合并楼盘」或「申请流程」处理,防止经纪人随意改名造成数据混乱。
2. **数据锁定机制**:楼盘有 4 类锁(楼栋锁/房号锁/信息锁/标准房号锁),锁定后对应数据只有管理员可解锁修改。
3. **非标结构处理**:未与标准结构关联的房号为「非标」,系统记录非标数量,引导运营逐步消除。
4. **搜索依赖全文检索**:楼盘名称、别名、地址需维护 `search_vector``tsvector`)以支持模糊搜索和联想补全。
5. **地理坐标优先级**:楼盘坐标是区域聚合展示(地图找房)的核心数据,完整度目标 ≥ 90%。
6. **学校关联影响房源**:从楼盘详情删除对口学校,会级联删除该楼盘下所有房源的对应学区标注。
---
## 二、实体关系
```
District (城区/行政区)
└── 1:N ── BusinessArea (商圈/板块)
└── N:M ── Complex (through complex_business_areas)
Complex (楼盘)
├── N:M ── BusinessArea (through complex_business_areas)
├── N:M ── School (through complex_schools)
├── N:M ── MetroStation (through complex_metro_stations, 附带距离)
├── 1:N ── Building (楼栋/单元)
│ └── 1:N ── RoomUnit (楼层+房号)
├── 1:N ── ComplexPhoto (楼盘照片:楼盘图/户型图/VR)
├── 1:N ── ComplexAttachment(附件)
├── 1:N ── ComplexPriceTrend(价格走势,月度)
└── 1:N ── ComplexAlias (别名)
MetroLine (地铁线路)
└── 1:N ── MetroStation (站点)
```
---
## 三、Schema 定义
### 3.1 districts — 城区/行政区
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| city | VARCHAR(50) | NOT NULL | 所属城市(支持多城市扩展,如「上海」「北京」) |
| name | VARCHAR(50) | NOT NULL | 行政区名称,如「静安区」 |
| short_name | VARCHAR(20) | | 简称,如「静安」 |
| sort_order | INTEGER | NOT NULL DEFAULT 0 | 列表展示排序 |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | FALSE=已停用(不在筛选项中展示) |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
```sql
CREATE UNIQUE INDEX idx_districts_city_name ON districts(city, name) WHERE is_active = TRUE;
```
---
### 3.2 business_areas — 商圈/板块
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| district_id | UUID | NOT NULL, FK→districts, RESTRICT | 所属城区 |
| name | VARCHAR(100) | NOT NULL | 商圈名称 |
| sort_order | INTEGER | NOT NULL DEFAULT 0 | |
| latitude | NUMERIC(10,7) | | 商圈中心坐标(纬度) |
| longitude | NUMERIC(10,7) | | 商圈中心坐标(经度) |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
```sql
CREATE INDEX idx_business_areas_district ON business_areas(district_id) WHERE is_active = TRUE;
CREATE UNIQUE INDEX idx_business_areas_name ON business_areas(district_id, name);
```
---
### 3.3 metro_lines — 地铁线路
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| city | VARCHAR(50) | NOT NULL | 所属城市 |
| name | VARCHAR(50) | NOT NULL | 线路名如「1号线」 |
| color | VARCHAR(7) | | 线路颜色 HEX`#E3002B` |
| sort_order | INTEGER | NOT NULL DEFAULT 0 | |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | |
---
### 3.4 metro_stations — 地铁站点
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| metro_line_id | UUID | NOT NULL, FK→metro_lines, CASCADE | 所属线路 |
| name | VARCHAR(50) | NOT NULL | 站名 |
| latitude | NUMERIC(10,7) | | 站点坐标 |
| longitude | NUMERIC(10,7) | | |
| sort_order | INTEGER | NOT NULL DEFAULT 0 | 沿线排序 |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | |
```sql
CREATE INDEX idx_metro_stations_line ON metro_stations(metro_line_id) WHERE is_active = TRUE;
```
---
### 3.5 schools — 学校
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| district_id | UUID | FK→districts, SET NULL | 所属城区 |
| name | VARCHAR(100) | NOT NULL | 学校名称 |
| type | VARCHAR(20) | | 学校类型:`primary`=小学 / `middle`=初中 / `high`=高中 / `k9`=九年一贯制 / `k12`=十二年一贯制 |
| nature | VARCHAR(20) | | 学校性质:`public`=公立 / `private`=私立 / `international`=国际学校 |
| level | VARCHAR(20) | | 学校等级:`normal`=普通 / `key`=重点 / `top`=名校 |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
```sql
CREATE INDEX idx_schools_district ON schools(district_id) WHERE is_active = TRUE;
CREATE INDEX idx_schools_name_trgm ON schools USING gin(name gin_trgm_ops);
```
---
### 3.6 complexes — 楼盘/小区(核心基础表)
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| name | VARCHAR(200) | NOT NULL | 标准楼盘名称,**不可在编辑页修改**(需走合并/申请流程) |
| district_id | UUID | FK→districts, SET NULL | 所属城区 |
| address | VARCHAR(500) | | 详细地址(不可在编辑页修改,需走纠错流程) |
| address_summary | VARCHAR(100) | | 概要地址如「海波路1000弄」可编辑 |
| latitude | NUMERIC(10,7) | | 楼盘坐标(纬度),完整度目标 ≥ 90% |
| longitude | NUMERIC(10,7) | | |
| **物业属性** | | | |
| property_usage_types | VARCHAR(20)[] | | 物业类型多选:`residential`/`villa`/`commercial_residential`/`commercial`/`office`/`other` |
| building_structure | VARCHAR(30) | | 楼栋结构枚举(运营维护):`unit_room`=单元-房号 / `other`=其他 |
| building_type | VARCHAR(20) | | 建筑类型:`slab`=板楼 / `tower`=塔楼 / `slab_tower`=板塔结合 |
| land_use_years | VARCHAR(30) | | 土地使用年限如「70年」 |
| built_year | SMALLINT | | 竣工年份(可多选,存最早竣工年) |
| built_years | SMALLINT[] | | 竣工年份多值(楼盘分期竣工) |
| ownership_category | VARCHAR(30)[] | | 权属类别多选(运营维护枚举) |
| total_units | INTEGER | | 单元总数 |
| total_households | INTEGER | | 总户数 |
| **建设信息** | | | |
| total_floor_area | NUMERIC(12,2) | | 小区总建筑面积 |
| plot_area | NUMERIC(12,2) | | 小区占地面积 |
| plot_ratio | NUMERIC(5,2) | | 容积率 |
| green_rate | NUMERIC(5,2) | | 绿化率(% |
| developer | VARCHAR(200) | | 开发商 |
| **物业信息** | | | |
| property_company | VARCHAR(200) | | 物业公司 |
| property_fee | NUMERIC(8,2) | | 物业费(元/m²/月) |
| property_phone | VARCHAR(30) | | 物业电话 |
| **停车** | | | |
| parking_total | INTEGER | | 车位总数 |
| parking_underground | INTEGER | | 地下车位数 |
| parking_ratio | VARCHAR(20) | | 停车位配比如「100:63」 |
| **配套** | | | |
| water_type | VARCHAR(10) | | `civil`=民水 / `commercial`=商水 |
| electricity_type | VARCHAR(10) | | `civil`=民电 / `commercial`=商电 |
| has_central_heating | BOOLEAN | | 是否统一供暖 |
| has_gas | BOOLEAN | | 是否有燃气 |
| remarks | TEXT | | 备注 |
| **锁定状态** | | | |
| lock_building | BOOLEAN | NOT NULL DEFAULT FALSE | 楼栋锁(锁定后不可增删楼栋) |
| lock_room | BOOLEAN | NOT NULL DEFAULT FALSE | 房号锁 |
| lock_info | BOOLEAN | NOT NULL DEFAULT FALSE | 信息锁(锁定后基本信息只读) |
| lock_standard_room | BOOLEAN | NOT NULL DEFAULT FALSE | 标准房号锁 |
| **全文检索** | | | |
| search_vector | TSVECTOR | | 由触发器自动维护name + alias + address |
| **状态** | | | |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | FALSE=已停用楼盘 |
| 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 INDEX idx_complexes_district ON complexes(district_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_complexes_name_trgm ON complexes USING gin(name gin_trgm_ops); -- 模糊搜索
CREATE INDEX idx_complexes_search ON complexes USING gin(search_vector); -- 全文搜索
CREATE INDEX idx_complexes_geo ON complexes(latitude, longitude) WHERE deleted_at IS NULL AND latitude IS NOT NULL;
CREATE INDEX idx_complexes_active ON complexes(is_active) WHERE deleted_at IS NULL;
```
---
### 3.7 complex_aliases — 楼盘别名
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| complex_id | UUID | NOT NULL, FK→complexes, CASCADE | |
| alias | VARCHAR(200) | NOT NULL | 别名最多20字/条,多别名多行存储) |
| is_system | BOOLEAN | NOT NULL DEFAULT FALSE | TRUE=系统/标准别名只读FALSE=用户自定义 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| created_by | UUID | FK→staff, SET NULL | |
```sql
CREATE INDEX idx_complex_aliases_complex ON complex_aliases(complex_id);
CREATE INDEX idx_complex_aliases_alias_trgm ON complex_aliases USING gin(alias gin_trgm_ops);
```
---
### 3.8 complex_business_areas — 楼盘与商圈多对多
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| complex_id | UUID | NOT NULL, FK→complexes, CASCADE | |
| business_area_id | UUID | NOT NULL, FK→business_areas, CASCADE | |
| is_primary | BOOLEAN | NOT NULL DEFAULT FALSE | 主商圈(唯一)用于列表显示 |
| PRIMARY KEY | (complex_id, business_area_id) | | |
```sql
-- 主商圈只能有一个
CREATE UNIQUE INDEX idx_complex_biz_area_primary ON complex_business_areas(complex_id) WHERE is_primary = TRUE;
```
---
### 3.9 complex_schools — 楼盘与学校关联
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| complex_id | UUID | NOT NULL, FK→complexes, CASCADE | |
| school_id | UUID | NOT NULL, FK→schools, CASCADE | |
| zone_type | VARCHAR(30) | | 学区类型:`guaranteed`=对口 / `reference`=参考 / `lottery`=摇号 |
| PRIMARY KEY | (complex_id, school_id) | | |
```sql
CREATE INDEX idx_complex_schools_school ON complex_schools(school_id);
```
**业务注意**删除此关联记录时需同步清理对应房源的学区标注Application 层事务处理)
---
### 3.10 complex_metro_stations — 楼盘与地铁站关联
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| complex_id | UUID | NOT NULL, FK→complexes, CASCADE | |
| station_id | UUID | NOT NULL, FK→metro_stations, CASCADE | |
| distance_meters | INTEGER | | 步行距离(米) |
| PRIMARY KEY | (complex_id, station_id) | | |
```sql
CREATE INDEX idx_complex_metro_complex ON complex_metro_stations(complex_id);
CREATE INDEX idx_complex_metro_station ON complex_metro_stations(station_id);
```
---
### 3.11 buildings — 楼栋/单元
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| complex_id | UUID | NOT NULL, FK→complexes, CASCADE | |
| name | VARCHAR(50) | NOT NULL | 楼栋名如「1号楼」「A栋2单元」 |
| is_standard | BOOLEAN | NOT NULL DEFAULT FALSE | TRUE=标准结构(经运营核准) |
| property_usage_type | VARCHAR(20) | | 物业类型(可与楼盘不同,如商住楼盘内有纯商铺楼栋) |
| built_year | SMALLINT | | 竣工年份 |
| total_floors | SMALLINT | | 总层数 |
| land_use_years | VARCHAR(30) | | 土地使用年限 |
| has_elevator | BOOLEAN | | 是否有电梯 |
| school_id | UUID | FK→schools, SET NULL | 关联对口学校(楼栋级别的学区差异) |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| created_by | UUID | FK→staff, SET NULL | |
```sql
CREATE INDEX idx_buildings_complex ON buildings(complex_id) WHERE is_active = TRUE;
CREATE UNIQUE INDEX idx_buildings_name ON buildings(complex_id, name) WHERE is_active = TRUE;
```
---
### 3.12 room_units — 房号/结构单元(楼层+房间号)
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| building_id | UUID | NOT NULL, FK→buildings, CASCADE | |
| floor | SMALLINT | NOT NULL | 楼层(实际层数,地下为负数) |
| floor_name | VARCHAR(20) | | 楼层名称展示如「1层」「B1层」 |
| room_no | VARCHAR(30) | NOT NULL | 房号如「01」「101」 |
| display_no | VARCHAR(50) | | 展示用完整房号如「3-1-101」 |
| is_standard | BOOLEAN | NOT NULL DEFAULT FALSE | TRUE=已归一化为标准结构 |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | FALSE=已拆除/不存在 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
```sql
CREATE INDEX idx_room_units_building ON room_units(building_id) WHERE is_active = TRUE;
CREATE UNIQUE INDEX idx_room_units_unique ON room_units(building_id, floor, room_no) WHERE is_active = TRUE;
```
---
### 3.13 complex_photos — 楼盘照片
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| complex_id | UUID | NOT NULL, FK→complexes, CASCADE | |
| category | VARCHAR(20) | NOT NULL | `complex`=楼盘图 / `layout`=户型图 / `vr`=VR全景 / `other`=其他 |
| file_key | TEXT | NOT NULL | R2/S3 路径 |
| thumbnail_key | TEXT | | 缩略图路径 |
| file_name | VARCHAR(255) | | |
| file_size | INTEGER | | bytes |
| width | INTEGER | | |
| height | INTEGER | | |
| is_cover | BOOLEAN | NOT NULL DEFAULT FALSE | 楼盘封面图 |
| sort_order | SMALLINT | NOT NULL DEFAULT 0 | |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| created_by | UUID | FK→staff, SET NULL | |
```sql
CREATE INDEX idx_complex_photos_complex ON complex_photos(complex_id);
CREATE INDEX idx_complex_photos_category ON complex_photos(complex_id, category);
CREATE UNIQUE INDEX idx_complex_photos_cover ON complex_photos(complex_id) WHERE is_cover = TRUE;
```
---
### 3.14 complex_attachments — 楼盘附件
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| complex_id | UUID | NOT NULL, FK→complexes, CASCADE | |
| file_key | TEXT | NOT NULL | |
| file_name | VARCHAR(255) | NOT NULL | |
| file_size | INTEGER | | |
| file_type | VARCHAR(50) | | MIME type |
| sort_order | SMALLINT | NOT NULL DEFAULT 0 | |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| created_by | UUID | FK→staff, SET NULL | |
---
### 3.15 complex_price_trends — 楼盘价格走势(月度)
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| complex_id | UUID | NOT NULL, FK→complexes, CASCADE | |
| record_month | DATE | NOT NULL | 月份统一存为该月1日如 2026-04-01 |
| avg_sale_price | NUMERIC(12,2) | | 月均售价(万元/套) |
| avg_unit_price | NUMERIC(10,2) | | 月均单价(元/m² |
| transaction_count | INTEGER | | 成交套数 |
| listing_count | INTEGER | | 当月挂牌套数 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
```sql
CREATE UNIQUE INDEX idx_complex_price_trend_month ON complex_price_trends(complex_id, record_month);
CREATE INDEX idx_complex_price_trend_complex ON complex_price_trends(complex_id, record_month DESC);
```
---
## 四、枚举常量
### complexes.building_type建筑类型
```
slab = 板楼
tower = 塔楼
slab_tower = 板塔结合
```
### complexes.water_type / electricity_type
```
civil = 民水/民电(住宅水电费率)
commercial = 商水/商电(商业水电费率,费用较高,影响买家决策)
```
### complex_schools.zone_type学区类型
```
guaranteed = 对口(直升)
reference = 参考(可能入读)
lottery = 摇号(通过摇号入学)
```
### buildings.is_standard / room_units.is_standard
```
TRUE = 已标准化(楼栋/房号已经运营核准,可用于精准房源定位)
FALSE = 非标(用户自输入,未核准,存在歧义风险)
```
---
## 五、查询模式
### 5.1 楼盘名称联想搜索(录入房源时的自动补全)
```sql
-- 使用全文检索向量,支持中文分词近似匹配
SELECT id, name, address_summary, district_id
FROM complexes
WHERE search_vector @@ plainto_tsquery('simple', :keyword)
OR name ILIKE :keyword_prefix -- 前缀精确匹配优先
AND deleted_at IS NULL
AND is_active = TRUE
ORDER BY
ts_rank(search_vector, plainto_tsquery('simple', :keyword)) DESC,
name
LIMIT 20;
```
### 5.2 楼盘列表(含房源数量统计)
```sql
SELECT
c.id, c.name, c.address, c.latitude, c.longitude,
d.name AS district_name,
ba.name AS primary_business_area,
COUNT(DISTINCT b.id) AS building_count,
COUNT(DISTINCT p.id) FILTER (WHERE p.status IN ('for_sale','for_sale_rent')) AS sale_count,
COUNT(DISTINCT p.id) FILTER (WHERE p.status IN ('for_rent','for_sale_rent')) AS rent_count
FROM complexes c
LEFT JOIN districts d ON d.id = c.district_id
LEFT JOIN complex_business_areas cba ON cba.complex_id = c.id AND cba.is_primary = TRUE
LEFT JOIN business_areas ba ON ba.id = cba.business_area_id
LEFT JOIN buildings b ON b.complex_id = c.id AND b.is_active = TRUE
LEFT JOIN properties p ON p.complex_id = c.id AND p.deleted_at IS NULL
WHERE c.deleted_at IS NULL
AND c.district_id = ANY(:district_ids) -- 区域筛选
GROUP BY c.id, d.name, ba.name
ORDER BY c.name
LIMIT 20 OFFSET :offset;
```
### 5.3 查询楼盘下的楼层-房号矩阵(结构管理)
```sql
-- 选中单元后,加载楼层×房号矩阵
SELECT
ru.floor,
ru.floor_name,
ru.room_no,
ru.display_no,
ru.is_standard,
p.id AS property_id, -- 如果该房号已有房源,关联显示
p.status AS property_status
FROM room_units ru
LEFT JOIN properties p ON p.building_id = ru.building_id
AND p.room_no = ru.room_no
AND p.floor = ru.floor
AND p.deleted_at IS NULL
WHERE ru.building_id = :building_id
AND ru.is_active = TRUE
ORDER BY ru.floor DESC, ru.room_no;
```
---
## 六、触发器
### 6.1 楼盘全文检索向量(含别名)
```sql
CREATE OR REPLACE FUNCTION update_complex_search_vector()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', COALESCE(NEW.name, '')), 'A') ||
setweight(to_tsvector('simple', COALESCE(NEW.address_summary, '')), 'B') ||
setweight(to_tsvector('simple', COALESCE(NEW.address, '')), 'C');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_complex_search_vector
BEFORE INSERT OR UPDATE OF name, address_summary, address
ON complexes
FOR EACH ROW EXECUTE FUNCTION update_complex_search_vector();
-- 别名变更时同步更新楼盘 search_vector
CREATE OR REPLACE FUNCTION update_complex_search_on_alias()
RETURNS TRIGGER AS $$
BEGIN
UPDATE complexes
SET search_vector = (
setweight(to_tsvector('simple', COALESCE(name, '')), 'A') ||
setweight(to_tsvector('simple',
COALESCE((SELECT string_agg(alias, ' ') FROM complex_aliases WHERE complex_id = complexes.id), '')), 'B') ||
setweight(to_tsvector('simple', COALESCE(address_summary, '')), 'C') ||
setweight(to_tsvector('simple', COALESCE(address, '')), 'D')
),
updated_at = NOW()
WHERE id = COALESCE(NEW.complex_id, OLD.complex_id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_complex_alias_search
AFTER INSERT OR UPDATE OR DELETE ON complex_aliases
FOR EACH ROW EXECUTE FUNCTION update_complex_search_on_alias();
```
---
## 七、禁止操作
-**严禁直接修改 complexes.name**:楼盘名称变更必须走「楼盘合并」流程或「管理员申请」,通过 Application 层拦截任何直接 UPDATE `name` 字段的操作
-**严禁硬删除 complexes 记录**:有房源关联的楼盘不可删除(`RESTRICT` 外键),已有房源的楼盘软删除后房源仍可正常访问
-**严禁删除 complex_schools 关联而不清理房源学区标注**:必须在同一事务中清理对应 `property.school_ids` 数据
-**严禁在楼盘坐标为 NULL 时将其用于地图聚合**:坐标为空时不参与地图展示,过滤条件:`WHERE latitude IS NOT NULL`
-**严禁在 lock_info=TRUE 时绕过 Application 层直接修改楼盘信息字段**:锁定状态必须在服务层检查,不依赖数据库约束
-**严禁在没有 deleted_at IS NULL 过滤的情况下查询 complexes**:楼盘软删除过滤必须存在

View File

@@ -0,0 +1,341 @@
# Fonrey — 组织人事数据模型DATA_MODEL_ORG
> **所属系统**: Fonrey 房产经纪管理系统
> **版本**: v1.0
> **日期**: 2026-04-24
> **关联模块**: `apps/org/` — 组织架构、员工档案、人事异动、账号体系
---
## 一、领域概览Domain Overview
### 核心概念
- **OrgUnit组织节点**:公司组织树的节点,类型涵盖事业部 / 大区 / 区域 / 片区 / 门店 / 店组 / 职能。所有业务数据(房源、客源)最终归属到门店或店组级节点。
- **Staff员工**:系统的核心操作人员,与 Django `auth_user` 绑定登录账号,与 `org_units` 绑定岗位归属。员工的组织归属直接影响数据可见范围。
- **StaffTransferLog人事异动记录**:记录员工从入职到离职的全生命周期状态变化。每次异动(入职/调动/离职/复职)自动生成一条不可删除的日志。
- **StaffAccount账号信息**:员工的多平台登录账号体系,包括 Fonrey 主账号 / 58安居客 / 中国网络经纪人等。
### 关键业务规则
1. **组织层级约束**:店组级部门 **必须** 挂在门店下;经纪人/店管的所属部门 **只能** 是门店或店组。
2. **经纪人定义**:职务类别为「置业顾问」的员工即为经纪人,受业务规则特殊约束。
3. **人员异动强制日志**:入职、调动、离职、复职等操作均自动生成 `staff_transfer_logs` 记录,不可删除。
4. **账号与员工联动**:员工离职后,对应的 `auth_user.is_active` 设为 `False`,不可登录;复职后由管理员手动恢复。
5. **手机号敏感字段**:员工手机号 AES-256-GCM 加密存储SHA-256 哈希用于唯一性校验,通讯录展示脱敏格式。
6. **数据归属继承**:员工调动时,名下房源/客源默认跟随员工到新部门;离职时可选择转移给指定账号。
---
## 二、实体关系
```
OrgUnit (树形自引用,物化路径)
├── 1:N ── Staff (员工归属一个部门)
│ │
│ ├── 1:1 ── auth_user (Django 登录账号)
│ ├── 1:N ── StaffTransferLog (人事异动记录)
│ ├── 1:N ── StaffRewardPunish (奖惩记录)
│ ├── 1:N ── StaffAccount (第三方账号绑定)
│ └── 1:N ── StaffRemark (管理员备注)
└── 1:1 ── OrgUnit.parent_id (自引用)
```
---
## 三、Schema 定义
### 3.1 org_units — 组织节点表
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| name | VARCHAR(100) | NOT NULL | 部门/组织名称 |
| type | VARCHAR(20) | NOT NULL, CHECK | 枚举:`company` / `division`(事业部) / `region`(大区) / `area`(区域) / `district`(片区) / `store`(门店) / `group`(店组) / `functional`(职能) |
| parent_id | UUID | FK→self, RESTRICT | 父节点,根节点为 NULL |
| path | TEXT | NOT NULL | 物化路径:`/root_id/.../self_id/`,用于子树查询 |
| depth | SMALLINT | NOT NULL DEFAULT 0 | 节点深度(根=0最大支持 8 层 |
| sort_order | INTEGER | NOT NULL DEFAULT 0 | 同级排序 |
| attribute | VARCHAR(10) | | 直营/加盟,枚举:`direct` / `franchise` |
| address_city | VARCHAR(50) | | 部门所在城市 |
| address_district | VARCHAR(50) | | 部门所在县区 |
| address_detail | VARCHAR(200) | | 详细地址 |
| latitude | NUMERIC(10,7) | | 坐标(部门定位针) |
| longitude | NUMERIC(10,7) | | 坐标 |
| manager_id | UUID | FK→staff.id, SET NULL | 部门负责人循环依赖Application 层维护) |
| established_at | DATE | | 成立时间 |
| phone | VARCHAR(30) | | 部门联系电话 |
| ext_start | INTEGER | | 分机号范围:起始 |
| ext_end | INTEGER | | 分机号范围:结束 |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | FALSE = 已关闭部门,仍可在筛选中显示 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| deleted_at | TIMESTAMPTZ | | 软删除 |
**关键索引**
```sql
CREATE INDEX idx_org_units_parent ON org_units(parent_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_org_units_path_prefix ON org_units(path text_pattern_ops); -- 路径前缀查询
CREATE INDEX idx_org_units_type ON org_units(type) WHERE deleted_at IS NULL AND is_active = TRUE;
```
**业务注意**
- 查询某部门及所有下级:`WHERE path LIKE '/root_id/{target_id}/%'`
- 店组(`group`)的 `parent_id` 必须指向一个 `store` 节点,新增前需校验
---
### 3.2 staff — 员工表
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| org_unit_id | UUID | NOT NULL, FK→org_units | 当前所属组织节点(门店或店组) |
| user_id | INTEGER | UNIQUE, FK→auth_user | Django auth 登录账号 ID |
| name | VARCHAR(50) | NOT NULL | 真实姓名 |
| nickname | VARCHAR(50) | | 昵称(通讯录/显示名) |
| employee_no | VARCHAR(30) | UNIQUE | 员工工号,系统自动生成或手动录入 |
| role | VARCHAR(30) | NOT NULL, CHECK | 系统角色枚举:`agent`(经纪人) / `store_manager` / `area_manager` / `admin` / `operator` / `system` |
| job_title | VARCHAR(100) | | 职务名称,如「高级业务员」 |
| job_category | VARCHAR(50) | | 职务类别,如「置业顾问」(经纪人判定字段) |
| job_level | SMALLINT | | 职级(数字) |
| supervisor_id | UUID | FK→staff.id, SET NULL | 直属上级 |
| status | VARCHAR(20) | NOT NULL DEFAULT 'active' | `active`(在职) / `probation`(试用) / `resigned`(离职) / `frozen`(冻结) |
| phone_enc | BYTEA | | AES-256-GCM 加密手机号 |
| phone_hash | VARCHAR(64) | | SHA-256 哈希,用于唯一性索引 |
| phone_hide | BOOLEAN | NOT NULL DEFAULT FALSE | 通讯录是否隐藏手机号 |
| email | VARCHAR(255) | | 邮箱 |
| extension | VARCHAR(20) | | 分机号 |
| avatar_key | TEXT | | R2/S3 头像路径 |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | FALSE 时账号不可登录(联动 auth_user.is_active |
| is_system_admin | BOOLEAN | NOT NULL DEFAULT FALSE | 是否为系统管理员(影响权限上限) |
| first_joined_at | DATE | | 首次入职日期(计算工龄起点) |
| rejoined_at | DATE | | 最近复职日期 |
| resigned_at | DATE | | 最近离职日期 |
| joined_count | SMALLINT | NOT NULL DEFAULT 1 | 累计入职次数 |
| industry_exp_years | SMALLINT | | 行业经验(年) |
| mentor_id | UUID | FK→staff.id, SET NULL | 师傅(带教员工) |
| business_type | VARCHAR(50) | | 业务类型 |
| bank_name | VARCHAR(100) | | 银行名称 |
| bank_account | VARCHAR(50) | | 银行卡号(内部财务用) |
| partner_no | VARCHAR(50) | | 联号 |
| recruit_by_id | UUID | FK→staff.id, SET NULL | 招聘人 |
| recruit_source | VARCHAR(50) | | 招聘来源 |
| referrer_id | UUID | FK→staff.id, SET NULL | 转介人 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| deleted_at | TIMESTAMPTZ | | 软删除(离职员工仍保留记录) |
**关键索引**
```sql
CREATE UNIQUE INDEX idx_staff_employee_no ON staff(employee_no) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX idx_staff_phone_hash ON staff(phone_hash) WHERE deleted_at IS NULL;
CREATE INDEX idx_staff_org_unit ON staff(org_unit_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_staff_supervisor ON staff(supervisor_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_staff_status ON staff(status) WHERE deleted_at IS NULL;
```
**业务注意**
- `is_active = FALSE` 时对应 `auth_user.is_active` 同步设为 False通过 Django signal 实现
- 离职员工(`status = 'resigned'`)不可硬删除,保留档案以便房源/客源历史关联查询
- 经纪人判定:`job_category = '置业顾问'`,部分权限逻辑基于此字段
---
### 3.3 staff_personal_info — 员工个人信息扩展表
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| staff_id | UUID | UNIQUE, NOT NULL, FK→staff | 1:1 关系 |
| gender | VARCHAR(10) | | `male` / `female` / `unknown` |
| id_type | VARCHAR(20) | | 证件类型:`id_card`(身份证) / `passport` / `other` |
| id_number_enc | BYTEA | | 证件号码AES 加密) |
| id_number_hash | VARCHAR(64) | | SHA-256 哈希(实名认证比对用) |
| id_verified | BOOLEAN | NOT NULL DEFAULT FALSE | 是否实名认证通过 |
| id_verified_at | TIMESTAMPTZ | | 认证时间 |
| birthdate | DATE | | 出生日期 |
| native_place | VARCHAR(100) | | 籍贯 |
| domicile_type | VARCHAR(20) | | 户籍性质 |
| marital_status | VARCHAR(20) | | 婚姻状况 |
| political_status | VARCHAR(20) | | 政治面貌 |
| has_children | BOOLEAN | | 有无子女 |
| education_level | VARCHAR(20) | | 最高学历 |
| ethnicity | VARCHAR(20) | | 民族 |
| domicile_address | VARCHAR(200) | | 户口所在地 |
| residence_address | VARCHAR(200) | | 居住地址 |
| work_start_date | DATE | | 参加工作时间 |
| emergency_contact | VARCHAR(50) | | 紧急联系人 |
| emergency_phone_enc | BYTEA | | 紧急联系人电话(加密) |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_by | UUID | FK→staff.id, SET NULL | |
---
### 3.4 staff_transfer_logs — 人事异动记录
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| staff_id | UUID | NOT NULL, FK→staff, RESTRICT | 被操作员工 |
| transfer_type | VARCHAR(30) | NOT NULL, CHECK | 枚举见下方 |
| old_value | JSONB | | 变动前的值快照,格式:`{"field": "org_unit_id", "value": "...", "label": "门店A"}` |
| new_value | JSONB | | 变动后的值快照 |
| transfer_date | DATE | NOT NULL | 异动生效日期(可以是过去日期) |
| remarks | VARCHAR(50) | | 备注最多50字 |
| operator_id | UUID | NOT NULL, FK→staff, RESTRICT | 操作人 |
| operated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 系统操作时间 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| ⚠️ 无 deleted_at | — | — | 异动记录**不可删除** |
**transfer_type 枚举**
```
onboard = 入职
transfer = 调动(含平调/晋升/降职)
resign = 离职
rejoin = 复职
supervisor_change = 上级变动
role_change = 角色变更
freeze = 账号冻结
unfreeze = 账号恢复
```
**关键索引**
```sql
CREATE INDEX idx_transfer_logs_staff ON staff_transfer_logs(staff_id, transfer_date DESC);
CREATE INDEX idx_transfer_logs_type ON staff_transfer_logs(transfer_type, operated_at DESC);
CREATE INDEX idx_transfer_logs_operator ON staff_transfer_logs(operator_id);
```
---
### 3.5 staff_reward_punish — 奖惩记录
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| staff_id | UUID | NOT NULL, FK→staff | |
| rp_date | DATE | NOT NULL | 奖惩日期 |
| category | VARCHAR(50) | NOT NULL | 奖惩类别(枚举由 lookup 表维护) |
| name | VARCHAR(100) | NOT NULL | 奖惩名称(与类别联动) |
| remarks | TEXT | | 备注 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| created_by | UUID | FK→staff.id, SET NULL | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| deleted_at | TIMESTAMPTZ | | 软删除 |
---
### 3.6 staff_work_experiences / staff_educations / staff_trainings / staff_family_members
这四张表结构类似,均为 1:N 附属于 `staff`,存储员工档案中「工作经历」「教育经历」「培训经历」「家庭主要成员」信息。详见下方汇总:
| 表名 | 关键字段 |
|------|---------|
| `staff_work_experiences` | staff_id, company, job_title, start_date, end_date, reason, reference_name, reference_phone |
| `staff_educations` | staff_id, stage, school, major, start_date, end_date, enrollment_status, degree |
| `staff_trainings` | staff_id, training_name, training_date, certificate |
| `staff_family_members` | staff_id, relation(称谓), name, birthdate, occupation, work_unit, phone_enc |
---
### 3.7 staff_accounts — 员工第三方账号绑定
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| staff_id | UUID | NOT NULL, FK→staff | |
| platform | VARCHAR(30) | NOT NULL, CHECK | `fonrey`(主账号) / `58anjuke` / `cnreic`(中国网络经纪人) / `wechat_mp`(微信公众号) |
| account_no | VARCHAR(100) | | 账号/手机号 |
| is_real_name_match | BOOLEAN | | 实名信息一致性(中国网络经纪人专用) |
| is_bound | BOOLEAN | NOT NULL DEFAULT FALSE | 是否已绑定 |
| bound_at | TIMESTAMPTZ | | 绑定时间 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
---
## 四、枚举常量
### Staff.role系统角色
| 值 | 含义 | 数据可见范围默认 |
|----|------|----------------|
| `agent` | 一线经纪人 | 本人/本组 |
| `store_manager` | 店长 | 本门店 |
| `area_manager` | 区域经理 | 本区域 |
| `admin` | 系统管理员 | 全公司 |
| `operator` | 运营/行政 | 全公司(只读为主) |
| `system` | 系统账号(定时任务用) | — |
### Staff.status员工状态
```
active = 正式在职
probation = 试用期
resigned = 已离职(不可删除,保留档案)
frozen = 账号冻结(在职但无法登录)
```
### OrgUnit.type组织类型
```
company = 公司根节点(每个租户唯一)
division = 事业部
region = 大区
area = 区域
district = 片区
store = 门店(经纪人最小归属单位)
group = 店组(门店下的业务小组)
functional = 职能部门(行政/财务等)
```
---
## 五、查询模式
### 5.1 查询某部门及所有下级的在职员工
```sql
-- 利用物化路径高效查询子树
SELECT s.*
FROM staff s
JOIN org_units ou ON s.org_unit_id = ou.id
WHERE ou.path LIKE '/root_id/{target_org_unit_id}/%'
OR ou.id = '{target_org_unit_id}'
AND s.deleted_at IS NULL
AND s.status != 'resigned';
```
### 5.2 查询员工完整异动历史
```sql
SELECT stl.*,
s.name as operator_name,
ou.name as operator_org
FROM staff_transfer_logs stl
JOIN staff s ON stl.operator_id = s.id
JOIN org_units ou ON s.org_unit_id = ou.id
WHERE stl.staff_id = :staff_id
ORDER BY stl.transfer_date DESC, stl.operated_at DESC;
```
### 5.3 获取员工的直接上下级链
```sql
-- 直属上级
SELECT supervisor.* FROM staff
JOIN staff supervisor ON staff.supervisor_id = supervisor.id
WHERE staff.id = :staff_id AND supervisor.deleted_at IS NULL;
```
---
## 六、禁止操作
-**严禁硬删除 staff 记录**:离职员工需通过 `deleted_at + status = 'resigned'` 软删除,历史房源/跟进日志依赖 `staff.id` 外键
-**严禁删除 staff_transfer_logs**:异动记录为不可变审计日志
-**严禁直接修改 staff.user_id**:账号绑定关系变更需走专门的账号管理流程
-**严禁绕过组织层级约束**:店组不在门店下的数据操作需在 Application 层校验并拒绝
-**严禁明文存储员工手机号和证件号**:必须走 `EncryptedPhoneField` / `EncryptedIDField`

View File

@@ -0,0 +1,574 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2560 1980">
<defs>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&amp;display=swap');
text { font-family: 'JetBrains Mono', 'Noto Sans SC', 'PingFang SC', 'SF Mono', monospace; }
</style>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#1e293b" stroke-width="0.5"/>
</pattern>
<!-- Arrow markers per color -->
<marker id="arrow-cyan" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#22d3ee"/>
</marker>
<marker id="arrow-emerald" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#34d399"/>
</marker>
<marker id="arrow-violet" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#a78bfa"/>
</marker>
<marker id="arrow-amber" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#fbbf24"/>
</marker>
<marker id="arrow-slate" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#94a3b8"/>
</marker>
<marker id="arrow-orange" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#fb923c"/>
</marker>
</defs>
<!-- Background -->
<rect width="2560" height="1980" fill="#0f172a"/>
<rect width="2560" height="1980" fill="url(#grid)"/>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- MODULE BOUNDARIES -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- Org Module boundary (cyan) -->
<rect x="30" y="60" width="310" height="680" rx="12" fill="none" stroke="#22d3ee" stroke-width="1" stroke-dasharray="8,4" opacity="0.6"/>
<text x="44" y="80" fill="#22d3ee" font-size="10" font-weight="600">ORG / HR</text>
<!-- Region+Complex Module boundary (emerald) -->
<rect x="360" y="60" width="680" height="1200" rx="12" fill="none" stroke="#34d399" stroke-width="1" stroke-dasharray="8,4" opacity="0.6"/>
<text x="374" y="80" fill="#34d399" font-size="10" font-weight="600">REGION &amp; COMPLEX</text>
<!-- Property Module boundary (violet) -->
<rect x="1060" y="60" width="720" height="1560" rx="12" fill="none" stroke="#a78bfa" stroke-width="1" stroke-dasharray="8,4" opacity="0.6"/>
<text x="1074" y="80" fill="#a78bfa" font-size="10" font-weight="600">PROPERTY</text>
<!-- Client Module boundary (amber) -->
<rect x="1800" y="60" width="730" height="1200" rx="12" fill="none" stroke="#fbbf24" stroke-width="1" stroke-dasharray="8,4" opacity="0.6"/>
<text x="1814" y="80" fill="#fbbf24" font-size="10" font-weight="600">CLIENT</text>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- CONNECTION LINES (drawn before boxes) -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- OrgUnit → Staff (1:N) -->
<line x1="185" y1="220" x2="185" y2="320" stroke="#22d3ee" stroke-width="1.2" marker-end="url(#arrow-cyan)"/>
<text x="193" y="275" fill="#22d3ee" font-size="8">1:N</text>
<!-- Staff → Property (1:N, via created_by) -->
<line x1="335" y1="370" x2="1060" y2="370" stroke="#22d3ee" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-cyan)"/>
<text x="690" y="362" fill="#22d3ee" font-size="8">created_by</text>
<!-- Staff → Client (1:N, via agent) -->
<line x1="335" y1="410" x2="1800" y2="410" stroke="#22d3ee" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-cyan)"/>
<text x="1060" y="402" fill="#22d3ee" font-size="8">agent_id</text>
<!-- District → BusinessArea (1:N) -->
<line x1="560" y1="225" x2="560" y2="320" stroke="#34d399" stroke-width="1.2" marker-end="url(#arrow-emerald)"/>
<text x="568" y="277" fill="#34d399" font-size="8">1:N</text>
<!-- District → School (1:N) -->
<line x1="700" y1="175" x2="870" y2="175" stroke="#34d399" stroke-width="1.2" marker-end="url(#arrow-emerald)"/>
<text x="775" y="167" fill="#34d399" font-size="8">1:N</text>
<!-- BusinessArea ↔ Complex (N:M via complex_business_areas) -->
<line x1="560" y1="420" x2="560" y2="500" stroke="#34d399" stroke-width="1.2" marker-end="url(#arrow-emerald)"/>
<text x="568" y="464" fill="#34d399" font-size="8">N:M</text>
<!-- Complex → Complex_schools join label -->
<line x1="700" y1="570" x2="870" y2="400" stroke="#34d399" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-emerald)"/>
<text x="780" y="495" fill="#34d399" font-size="8">N:M</text>
<!-- Complex → Building (1:N) -->
<line x1="560" y1="700" x2="560" y2="790" stroke="#34d399" stroke-width="1.2" marker-end="url(#arrow-emerald)"/>
<text x="568" y="749" fill="#34d399" font-size="8">1:N</text>
<!-- Building → RoomUnit (1:N) -->
<line x1="560" y1="980" x2="560" y2="1060" stroke="#34d399" stroke-width="1.2" marker-end="url(#arrow-emerald)"/>
<text x="568" y="1024" fill="#34d399" font-size="8">1:N</text>
<!-- Complex → Property (1:N) -->
<line x1="720" y1="600" x2="1060" y2="300" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="885" y="445" fill="#a78bfa" font-size="8">1:N</text>
<!-- Property → PropertyContact (1:N) -->
<line x1="1300" y1="390" x2="1300" y2="490" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="1308" y="444" fill="#a78bfa" font-size="8">1:N</text>
<!-- Property → FollowLog (1:N) -->
<line x1="1420" y1="300" x2="1570" y2="300" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="1482" y="292" fill="#a78bfa" font-size="8">1:N</text>
<!-- Property → PropertyPhoto (1:N) -->
<line x1="1300" y1="670" x2="1300" y2="760" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="1308" y="718" fill="#a78bfa" font-size="8">1:N</text>
<!-- Property → KeyManagement (1:N) -->
<line x1="1420" y1="550" x2="1570" y2="550" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="1482" y="542" fill="#a78bfa" font-size="8">1:N</text>
<!-- Property → Commission (1:N) -->
<line x1="1420" y1="650" x2="1570" y2="750" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="1490" y="695" fill="#a78bfa" font-size="8">1:N</text>
<!-- Property → Inspection (1:N) -->
<line x1="1300" y1="940" x2="1300" y2="1020" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="1308" y="984" fill="#a78bfa" font-size="8">1:N</text>
<!-- Property → Marketing (1:1) -->
<line x1="1420" y1="870" x2="1570" y2="960" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="1490" y="910" fill="#a78bfa" font-size="8">1:1</text>
<!-- Property → ListingHistory (1:N) -->
<line x1="1180" y1="390" x2="1080" y2="490" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="1100" y="435" fill="#a78bfa" font-size="8">1:N</text>
<!-- Client → ClientRequirement (1:N) -->
<line x1="2050" y1="220" x2="2050" y2="310" stroke="#fbbf24" stroke-width="1.2" marker-end="url(#arrow-amber)"/>
<text x="2058" y="269" fill="#fbbf24" font-size="8">1:N</text>
<!-- Client → ClientFollowLog (1:N) -->
<line x1="1930" y1="220" x2="1830" y2="310" stroke="#fbbf24" stroke-width="1.2" marker-end="url(#arrow-amber)"/>
<text x="1850" y="260" fill="#fbbf24" font-size="8">1:N</text>
<!-- Client → Viewing (1:N) -->
<line x1="2170" y1="220" x2="2270" y2="310" stroke="#fbbf24" stroke-width="1.2" marker-end="url(#arrow-amber)"/>
<text x="2210" y="260" fill="#fbbf24" font-size="8">1:N</text>
<!-- Client → Match (1:N) -->
<line x1="2050" y1="490" x2="2050" y2="580" stroke="#fbbf24" stroke-width="1.2" marker-end="url(#arrow-amber)"/>
<text x="2058" y="538" fill="#fbbf24" font-size="8">1:N</text>
<!-- Property → Viewing (1:N) -->
<line x1="1780" y1="340" x2="2270" y2="340" stroke="#fb923c" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-orange)"/>
<text x="2020" y="332" fill="#fb923c" font-size="8">1:N</text>
<!-- Property → Match (1:N) -->
<line x1="1780" y1="620" x2="1950" y2="620" stroke="#fb923c" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-orange)"/>
<text x="1845" y="612" fill="#fb923c" font-size="8">1:N</text>
<!-- MetroStation → Complex (N:M) -->
<line x1="620" y1="1280" x2="620" y2="1200" stroke="#34d399" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-emerald)"/>
<text x="628" y="1244" fill="#34d399" font-size="8">N:M</text>
<!-- MetroLine → MetroStation (1:N) -->
<line x1="430" y1="1280" x2="500" y2="1280" stroke="#34d399" stroke-width="1.2" marker-end="url(#arrow-emerald)"/>
<text x="452" y="1272" fill="#34d399" font-size="8">1:N</text>
<!-- ComplexPriceTrend → Complex -->
<line x1="870" y1="900" x2="720" y2="660" stroke="#34d399" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-emerald)"/>
<text x="790" y="800" fill="#34d399" font-size="8">1:N</text>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ORG MODULE -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- OrgUnit -->
<rect x="80" y="100" width="210" height="120" rx="6" fill="#0f172a"/>
<rect x="80" y="100" width="210" height="120" rx="6" fill="rgba(8,51,68,0.4)" stroke="#22d3ee" stroke-width="1.5"/>
<line x1="80" y1="128" x2="290" y2="128" stroke="#22d3ee" stroke-width="0.5" opacity="0.6"/>
<text x="185" y="120" fill="white" font-size="11" font-weight="700" text-anchor="middle">org_units</text>
<text x="90" y="145" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="90" y="158" fill="#94a3b8" font-size="8">parent_id: uuid (FK→self)</text>
<text x="90" y="171" fill="#94a3b8" font-size="8">type: varchar(20)</text>
<text x="90" y="184" fill="#94a3b8" font-size="8">name, path, depth</text>
<text x="90" y="197" fill="#94a3b8" font-size="8">is_active: bool</text>
<!-- Staff -->
<rect x="80" y="320" width="210" height="150" rx="6" fill="#0f172a"/>
<rect x="80" y="320" width="210" height="150" rx="6" fill="rgba(8,51,68,0.4)" stroke="#22d3ee" stroke-width="1.5"/>
<line x1="80" y1="348" x2="290" y2="348" stroke="#22d3ee" stroke-width="0.5" opacity="0.6"/>
<text x="185" y="340" fill="white" font-size="11" font-weight="700" text-anchor="middle">staff</text>
<text x="90" y="365" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="90" y="378" fill="#94a3b8" font-size="8">FK org_unit_id</text>
<text x="90" y="391" fill="#94a3b8" font-size="8">name: varchar(50)</text>
<text x="90" y="404" fill="#94a3b8" font-size="8">phone_enc: text (AES)</text>
<text x="90" y="417" fill="#94a3b8" font-size="8">phone_hash: varchar(64)</text>
<text x="90" y="430" fill="#94a3b8" font-size="8">user_id: uuid (FK→auth)</text>
<text x="90" y="443" fill="#94a3b8" font-size="8">is_active, deleted_at</text>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- REGION + COMPLEX MODULE -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- District -->
<rect x="440" y="100" width="220" height="125" rx="6" fill="#0f172a"/>
<rect x="440" y="100" width="220" height="125" rx="6" fill="rgba(6,78,59,0.4)" stroke="#34d399" stroke-width="1.5"/>
<line x1="440" y1="128" x2="660" y2="128" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="550" y="120" fill="white" font-size="11" font-weight="700" text-anchor="middle">districts</text>
<text x="450" y="145" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="450" y="158" fill="#94a3b8" font-size="8">city: varchar(50)</text>
<text x="450" y="171" fill="#94a3b8" font-size="8">name: varchar(50)</text>
<text x="450" y="184" fill="#94a3b8" font-size="8">short_name: varchar(20)</text>
<text x="450" y="197" fill="#94a3b8" font-size="8">sort_order, is_active</text>
<!-- BusinessArea -->
<rect x="440" y="320" width="240" height="135" rx="6" fill="#0f172a"/>
<rect x="440" y="320" width="240" height="135" rx="6" fill="rgba(6,78,59,0.4)" stroke="#34d399" stroke-width="1.5"/>
<line x1="440" y1="348" x2="680" y2="348" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="560" y="340" fill="white" font-size="11" font-weight="700" text-anchor="middle">business_areas</text>
<text x="450" y="365" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="450" y="378" fill="#94a3b8" font-size="8">FK district_id</text>
<text x="450" y="391" fill="#94a3b8" font-size="8">name: varchar(100)</text>
<text x="450" y="404" fill="#94a3b8" font-size="8">latitude, longitude</text>
<text x="450" y="417" fill="#94a3b8" font-size="8">sort_order, is_active</text>
<!-- School -->
<rect x="700" y="100" width="220" height="135" rx="6" fill="#0f172a"/>
<rect x="700" y="100" width="220" height="135" rx="6" fill="rgba(6,78,59,0.4)" stroke="#34d399" stroke-width="1.5"/>
<line x1="700" y1="128" x2="920" y2="128" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="810" y="120" fill="white" font-size="11" font-weight="700" text-anchor="middle">schools</text>
<text x="710" y="145" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="710" y="158" fill="#94a3b8" font-size="8">FK district_id</text>
<text x="710" y="171" fill="#94a3b8" font-size="8">name: varchar(100)</text>
<text x="710" y="184" fill="#94a3b8" font-size="8">type: primary/middle/high</text>
<text x="710" y="197" fill="#94a3b8" font-size="8">nature: public/private</text>
<text x="710" y="210" fill="#94a3b8" font-size="8">level: normal/key/top</text>
<!-- Complex -->
<rect x="440" y="500" width="300" height="200" rx="6" fill="#0f172a"/>
<rect x="440" y="500" width="300" height="200" rx="6" fill="rgba(6,78,59,0.4)" stroke="#34d399" stroke-width="1.5"/>
<line x1="440" y1="528" x2="740" y2="528" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="590" y="520" fill="white" font-size="11" font-weight="700" text-anchor="middle">complexes</text>
<text x="450" y="545" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="450" y="558" fill="#94a3b8" font-size="8">name: varchar(200) [不可直接修改]</text>
<text x="450" y="571" fill="#94a3b8" font-size="8">FK district_id</text>
<text x="450" y="584" fill="#94a3b8" font-size="8">address, address_summary</text>
<text x="450" y="597" fill="#94a3b8" font-size="8">latitude, longitude</text>
<text x="450" y="610" fill="#94a3b8" font-size="8">lock_building/room/info: bool</text>
<text x="450" y="623" fill="#94a3b8" font-size="8">property_usage_types: varchar[]</text>
<text x="450" y="636" fill="#94a3b8" font-size="8">search_vector: tsvector</text>
<text x="450" y="649" fill="#94a3b8" font-size="8">developer, property_company</text>
<text x="450" y="662" fill="#94a3b8" font-size="8">deleted_at, created_by</text>
<text x="450" y="675" fill="#94a3b8" font-size="8">...</text>
<!-- complex_business_areas join table (small) -->
<rect x="440" y="465" width="220" height="30" rx="4" fill="#0f172a"/>
<rect x="440" y="465" width="220" height="30" rx="4" fill="rgba(6,78,59,0.2)" stroke="#34d399" stroke-width="1" stroke-dasharray="3,2"/>
<text x="550" y="484" fill="#34d399" font-size="8" text-anchor="middle">complex_business_areas (N:M) · is_primary</text>
<!-- complex_schools join table (small) -->
<rect x="700" y="310" width="210" height="30" rx="4" fill="#0f172a"/>
<rect x="700" y="310" width="210" height="30" rx="4" fill="rgba(6,78,59,0.2)" stroke="#34d399" stroke-width="1" stroke-dasharray="3,2"/>
<text x="805" y="329" fill="#34d399" font-size="8" text-anchor="middle">complex_schools · zone_type</text>
<!-- Building -->
<rect x="440" y="790" width="300" height="185" rx="6" fill="#0f172a"/>
<rect x="440" y="790" width="300" height="185" rx="6" fill="rgba(6,78,59,0.4)" stroke="#34d399" stroke-width="1.5"/>
<line x1="440" y1="818" x2="740" y2="818" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="590" y="810" fill="white" font-size="11" font-weight="700" text-anchor="middle">buildings</text>
<text x="450" y="835" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="450" y="848" fill="#94a3b8" font-size="8">FK complex_id</text>
<text x="450" y="861" fill="#94a3b8" font-size="8">name: varchar(50)</text>
<text x="450" y="874" fill="#94a3b8" font-size="8">is_standard: bool</text>
<text x="450" y="887" fill="#94a3b8" font-size="8">total_floors: smallint</text>
<text x="450" y="900" fill="#94a3b8" font-size="8">has_elevator: bool</text>
<text x="450" y="913" fill="#94a3b8" font-size="8">built_year: smallint</text>
<text x="450" y="926" fill="#94a3b8" font-size="8">property_usage_type</text>
<text x="450" y="939" fill="#94a3b8" font-size="8">is_active, created_at</text>
<text x="450" y="952" fill="#94a3b8" font-size="8">FK school_id</text>
<!-- RoomUnit -->
<rect x="440" y="1060" width="300" height="155" rx="6" fill="#0f172a"/>
<rect x="440" y="1060" width="300" height="155" rx="6" fill="rgba(6,78,59,0.4)" stroke="#34d399" stroke-width="1.5"/>
<line x1="440" y1="1088" x2="740" y2="1088" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="590" y="1080" fill="white" font-size="11" font-weight="700" text-anchor="middle">room_units</text>
<text x="450" y="1105" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="450" y="1118" fill="#94a3b8" font-size="8">FK building_id</text>
<text x="450" y="1131" fill="#94a3b8" font-size="8">floor: smallint</text>
<text x="450" y="1144" fill="#94a3b8" font-size="8">floor_name: varchar(20)</text>
<text x="450" y="1157" fill="#94a3b8" font-size="8">room_no: varchar(30)</text>
<text x="450" y="1170" fill="#94a3b8" font-size="8">display_no: varchar(50)</text>
<text x="450" y="1183" fill="#94a3b8" font-size="8">is_standard: bool</text>
<text x="450" y="1196" fill="#94a3b8" font-size="8">is_active</text>
<!-- ComplexPriceTrend -->
<rect x="770" y="800" width="250" height="140" rx="6" fill="#0f172a"/>
<rect x="770" y="800" width="250" height="140" rx="6" fill="rgba(6,78,59,0.3)" stroke="#34d399" stroke-width="1.5"/>
<line x1="770" y1="828" x2="1020" y2="828" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="895" y="820" fill="white" font-size="11" font-weight="700" text-anchor="middle">complex_price_trends</text>
<text x="780" y="845" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="780" y="858" fill="#94a3b8" font-size="8">FK complex_id</text>
<text x="780" y="871" fill="#94a3b8" font-size="8">record_month: date</text>
<text x="780" y="884" fill="#94a3b8" font-size="8">avg_unit_price: numeric(10,2)</text>
<text x="780" y="897" fill="#94a3b8" font-size="8">avg_sale_price: numeric(12,2)</text>
<text x="780" y="910" fill="#94a3b8" font-size="8">transaction_count: int</text>
<text x="780" y="923" fill="#94a3b8" font-size="8">listing_count: int</text>
<!-- MetroLine -->
<rect x="370" y="1270" width="200" height="105" rx="6" fill="#0f172a"/>
<rect x="370" y="1270" width="200" height="105" rx="6" fill="rgba(6,78,59,0.3)" stroke="#34d399" stroke-width="1.5"/>
<line x1="370" y1="1298" x2="570" y2="1298" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="470" y="1290" fill="white" font-size="11" font-weight="700" text-anchor="middle">metro_lines</text>
<text x="380" y="1315" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="380" y="1328" fill="#94a3b8" font-size="8">city: varchar(50)</text>
<text x="380" y="1341" fill="#94a3b8" font-size="8">name: varchar(50)</text>
<text x="380" y="1354" fill="#94a3b8" font-size="8">color: varchar(7) [HEX]</text>
<!-- MetroStation -->
<rect x="580" y="1270" width="240" height="120" rx="6" fill="#0f172a"/>
<rect x="580" y="1270" width="240" height="120" rx="6" fill="rgba(6,78,59,0.3)" stroke="#34d399" stroke-width="1.5"/>
<line x1="580" y1="1298" x2="820" y2="1298" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="700" y="1290" fill="white" font-size="11" font-weight="700" text-anchor="middle">metro_stations</text>
<text x="590" y="1315" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="590" y="1328" fill="#94a3b8" font-size="8">FK metro_line_id</text>
<text x="590" y="1341" fill="#94a3b8" font-size="8">name: varchar(50)</text>
<text x="590" y="1354" fill="#94a3b8" font-size="8">latitude, longitude</text>
<text x="590" y="1367" fill="#94a3b8" font-size="8">sort_order</text>
<!-- complex_metro_stations join (small) -->
<rect x="580" y="1200" width="240" height="28" rx="4" fill="#0f172a"/>
<rect x="580" y="1200" width="240" height="28" rx="4" fill="rgba(6,78,59,0.2)" stroke="#34d399" stroke-width="1" stroke-dasharray="3,2"/>
<text x="700" y="1218" fill="#34d399" font-size="8" text-anchor="middle">complex_metro_stations · distance_meters</text>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- PROPERTY MODULE -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- Property -->
<rect x="1080" y="100" width="340" height="290" rx="6" fill="#0f172a"/>
<rect x="1080" y="100" width="340" height="290" rx="6" fill="rgba(76,29,149,0.4)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1080" y1="128" x2="1420" y2="128" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1250" y="120" fill="white" font-size="11" font-weight="700" text-anchor="middle">properties</text>
<text x="1090" y="145" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1090" y="158" fill="#94a3b8" font-size="8">FK complex_id FK building_id</text>
<text x="1090" y="171" fill="#94a3b8" font-size="8">FK room_unit_id FK agent_id</text>
<text x="1090" y="184" fill="#94a3b8" font-size="8">listing_type: sale/rent/both</text>
<text x="1090" y="197" fill="#94a3b8" font-size="8">status: varchar(20)</text>
<text x="1090" y="210" fill="#94a3b8" font-size="8">sale_price: numeric(12,2)</text>
<text x="1090" y="223" fill="#94a3b8" font-size="8">rent_price: numeric(10,2)</text>
<text x="1090" y="236" fill="#94a3b8" font-size="8">floor, total_floors</text>
<text x="1090" y="249" fill="#94a3b8" font-size="8">area: numeric(8,2) [m²]</text>
<text x="1090" y="262" fill="#94a3b8" font-size="8">bedroom, living, bathroom</text>
<text x="1090" y="275" fill="#94a3b8" font-size="8">orientation, decoration</text>
<text x="1090" y="288" fill="#94a3b8" font-size="8">search_vector: tsvector</text>
<text x="1090" y="301" fill="#94a3b8" font-size="8">is_exclusive: bool</text>
<text x="1090" y="314" fill="#94a3b8" font-size="8">completeness_score: int</text>
<text x="1090" y="327" fill="#94a3b8" font-size="8">deleted_at, created_by</text>
<text x="1090" y="340" fill="#94a3b8" font-size="8">...</text>
<text x="1090" y="353" fill="#94a3b8" font-size="7">[89,000+ rows · partitioned by status]</text>
<text x="1090" y="370" fill="#94a3b8" font-size="7">UNIQUE (complex_id, building_id, floor, room_no)</text>
<!-- PropertyContact -->
<rect x="1080" y="490" width="280" height="155" rx="6" fill="#0f172a"/>
<rect x="1080" y="490" width="280" height="155" rx="6" fill="rgba(76,29,149,0.35)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1080" y1="518" x2="1360" y2="518" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1220" y="510" fill="white" font-size="11" font-weight="700" text-anchor="middle">property_contacts</text>
<text x="1090" y="535" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1090" y="548" fill="#94a3b8" font-size="8">FK property_id</text>
<text x="1090" y="561" fill="#94a3b8" font-size="8">name: varchar(50)</text>
<text x="1090" y="574" fill="#94a3b8" font-size="8">phone_enc: text (AES)</text>
<text x="1090" y="587" fill="#94a3b8" font-size="8">phone_hash: varchar(64)</text>
<text x="1090" y="600" fill="#94a3b8" font-size="8">role: owner/agent/tenant</text>
<text x="1090" y="613" fill="#94a3b8" font-size="8">is_primary: bool</text>
<!-- PropertyPhoto -->
<rect x="1080" y="760" width="280" height="155" rx="6" fill="#0f172a"/>
<rect x="1080" y="760" width="280" height="155" rx="6" fill="rgba(76,29,149,0.35)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1080" y1="788" x2="1360" y2="788" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1220" y="780" fill="white" font-size="11" font-weight="700" text-anchor="middle">property_photos</text>
<text x="1090" y="805" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1090" y="818" fill="#94a3b8" font-size="8">FK property_id</text>
<text x="1090" y="831" fill="#94a3b8" font-size="8">category: listing/vr/layout</text>
<text x="1090" y="844" fill="#94a3b8" font-size="8">file_key: text (R2/S3)</text>
<text x="1090" y="857" fill="#94a3b8" font-size="8">is_cover: bool</text>
<text x="1090" y="870" fill="#94a3b8" font-size="8">sort_order: smallint</text>
<text x="1090" y="883" fill="#94a3b8" font-size="8">width, height, file_size</text>
<!-- Inspection -->
<rect x="1080" y="1020" width="280" height="135" rx="6" fill="#0f172a"/>
<rect x="1080" y="1020" width="280" height="135" rx="6" fill="rgba(76,29,149,0.35)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1080" y1="1048" x2="1360" y2="1048" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1220" y="1040" fill="white" font-size="11" font-weight="700" text-anchor="middle">property_inspections</text>
<text x="1090" y="1065" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1090" y="1078" fill="#94a3b8" font-size="8">FK property_id FK staff_id</text>
<text x="1090" y="1091" fill="#94a3b8" font-size="8">inspected_at: timestamptz</text>
<text x="1090" y="1104" fill="#94a3b8" font-size="8">status: pending/done</text>
<text x="1090" y="1117" fill="#94a3b8" font-size="8">notes: text</text>
<text x="1090" y="1130" fill="#94a3b8" font-size="8">attachments: jsonb</text>
<!-- FollowLog (property) -->
<rect x="1440" y="100" width="300" height="155" rx="6" fill="#0f172a"/>
<rect x="1440" y="100" width="300" height="155" rx="6" fill="rgba(76,29,149,0.35)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1440" y1="128" x2="1740" y2="128" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1590" y="120" fill="white" font-size="11" font-weight="700" text-anchor="middle">property_follow_logs</text>
<text x="1450" y="145" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1450" y="158" fill="#94a3b8" font-size="8">FK property_id FK staff_id</text>
<text x="1450" y="171" fill="#94a3b8" font-size="8">log_type: call/visit/note...</text>
<text x="1450" y="184" fill="#94a3b8" font-size="8">content: text</text>
<text x="1450" y="197" fill="#94a3b8" font-size="8">sensitive_view: bool [不可删]</text>
<text x="1450" y="210" fill="#94a3b8" font-size="8">created_at, created_by</text>
<text x="1450" y="237" fill="#fb7185" font-size="7">⚠ NO DELETE (audit log)</text>
<!-- KeyManagement -->
<rect x="1440" y="490" width="300" height="120" rx="6" fill="#0f172a"/>
<rect x="1440" y="490" width="300" height="120" rx="6" fill="rgba(76,29,149,0.35)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1440" y1="518" x2="1740" y2="518" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1590" y="510" fill="white" font-size="11" font-weight="700" text-anchor="middle">property_keys</text>
<text x="1450" y="535" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1450" y="548" fill="#94a3b8" font-size="8">FK property_id FK holder_id</text>
<text x="1450" y="561" fill="#94a3b8" font-size="8">key_no: varchar(50)</text>
<text x="1450" y="574" fill="#94a3b8" font-size="8">status: held/returned</text>
<text x="1450" y="587" fill="#94a3b8" font-size="8">taken_at, returned_at</text>
<!-- Commission -->
<rect x="1440" y="720" width="300" height="135" rx="6" fill="#0f172a"/>
<rect x="1440" y="720" width="300" height="135" rx="6" fill="rgba(76,29,149,0.35)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1440" y1="748" x2="1740" y2="748" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1590" y="740" fill="white" font-size="11" font-weight="700" text-anchor="middle">property_commissions</text>
<text x="1450" y="765" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1450" y="778" fill="#94a3b8" font-size="8">FK property_id</text>
<text x="1450" y="791" fill="#94a3b8" font-size="8">commission_type: exclusive/open</text>
<text x="1450" y="804" fill="#94a3b8" font-size="8">rate: numeric(5,4)</text>
<text x="1450" y="817" fill="#94a3b8" font-size="8">start_date, end_date</text>
<text x="1450" y="830" fill="#94a3b8" font-size="8">signed_at, document_key</text>
<!-- Marketing -->
<rect x="1440" y="940" width="300" height="120" rx="6" fill="#0f172a"/>
<rect x="1440" y="940" width="300" height="120" rx="6" fill="rgba(76,29,149,0.35)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1440" y1="968" x2="1740" y2="968" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1590" y="960" fill="white" font-size="11" font-weight="700" text-anchor="middle">property_marketing</text>
<text x="1450" y="985" fill="#fbbf24" font-size="8">PK id: uuid [1:1 property]</text>
<text x="1450" y="998" fill="#94a3b8" font-size="8">FK property_id (UNIQUE)</text>
<text x="1450" y="1011" fill="#94a3b8" font-size="8">title: varchar(200)</text>
<text x="1450" y="1024" fill="#94a3b8" font-size="8">highlights: text[]</text>
<text x="1450" y="1037" fill="#94a3b8" font-size="8">published_at, platforms: jsonb</text>
<!-- ListingHistory -->
<rect x="1080" y="1250" width="300" height="120" rx="6" fill="#0f172a"/>
<rect x="1080" y="1250" width="300" height="120" rx="6" fill="rgba(76,29,149,0.35)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1080" y1="1278" x2="1380" y2="1278" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1230" y="1270" fill="white" font-size="11" font-weight="700" text-anchor="middle">listing_histories</text>
<text x="1090" y="1295" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1090" y="1308" fill="#94a3b8" font-size="8">FK property_id</text>
<text x="1090" y="1321" fill="#94a3b8" font-size="8">listed_at, delisted_at</text>
<text x="1090" y="1334" fill="#94a3b8" font-size="8">list_price: numeric(12,2)</text>
<text x="1090" y="1347" fill="#94a3b8" font-size="8">reason: varchar(50)</text>
<!-- Arrow from Property to ListingHistory -->
<line x1="1230" y1="390" x2="1230" y2="1250" stroke="#a78bfa" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-violet)"/>
<text x="1238" y="820" fill="#a78bfa" font-size="8">1:N</text>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- CLIENT MODULE -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- Client -->
<rect x="1870" y="100" width="320" height="250" rx="6" fill="#0f172a"/>
<rect x="1870" y="100" width="320" height="250" rx="6" fill="rgba(120,53,15,0.4)" stroke="#fbbf24" stroke-width="1.5"/>
<line x1="1870" y1="128" x2="2190" y2="128" stroke="#fbbf24" stroke-width="0.5" opacity="0.6"/>
<text x="2030" y="120" fill="white" font-size="11" font-weight="700" text-anchor="middle">clients</text>
<text x="1880" y="145" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1880" y="158" fill="#94a3b8" font-size="8">FK agent_id (staff)</text>
<text x="1880" y="171" fill="#94a3b8" font-size="8">client_type: private/public/closed</text>
<text x="1880" y="184" fill="#94a3b8" font-size="8">status: active/inactive/converted</text>
<text x="1880" y="197" fill="#94a3b8" font-size="8">name: varchar(50)</text>
<text x="1880" y="210" fill="#94a3b8" font-size="8">phone_enc: text (AES)</text>
<text x="1880" y="223" fill="#94a3b8" font-size="8">phone_hash: varchar(64)</text>
<text x="1880" y="236" fill="#94a3b8" font-size="8">activity_level: 1-5 (Celery daily)</text>
<text x="1880" y="249" fill="#94a3b8" font-size="8">is_protected: bool [防止转公客]</text>
<text x="1880" y="262" fill="#94a3b8" font-size="8">transfer_to_public_type: auto/manual</text>
<text x="1880" y="275" fill="#94a3b8" font-size="8">source: varchar(30)</text>
<text x="1880" y="288" fill="#94a3b8" font-size="8">deleted_at, created_by</text>
<text x="1880" y="301" fill="#94a3b8" font-size="8">...</text>
<text x="1880" y="325" fill="#94a3b8" font-size="7">[私客/公客/成交客 三态状态机]</text>
<!-- ClientRequirement -->
<rect x="1960" y="490" width="280" height="155" rx="6" fill="#0f172a"/>
<rect x="1960" y="490" width="280" height="155" rx="6" fill="rgba(120,53,15,0.35)" stroke="#fbbf24" stroke-width="1.5"/>
<line x1="1960" y1="518" x2="2240" y2="518" stroke="#fbbf24" stroke-width="0.5" opacity="0.6"/>
<text x="2100" y="510" fill="white" font-size="11" font-weight="700" text-anchor="middle">client_requirements</text>
<text x="1970" y="535" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1970" y="548" fill="#94a3b8" font-size="8">FK client_id</text>
<text x="1970" y="561" fill="#94a3b8" font-size="8">req_type: second_hand/new/rent</text>
<text x="1970" y="574" fill="#94a3b8" font-size="8">district_ids: uuid[]</text>
<text x="1970" y="587" fill="#94a3b8" font-size="8">price_min/max: numeric</text>
<text x="1970" y="600" fill="#94a3b8" font-size="8">area_min/max, bedrooms</text>
<text x="1970" y="613" fill="#94a3b8" font-size="8">school_ids: uuid[]</text>
<!-- ClientFollowLog -->
<rect x="1820" y="490" width="130" height="155" rx="6" fill="#0f172a"/>
<rect x="1820" y="490" width="130" height="155" rx="6" fill="rgba(120,53,15,0.35)" stroke="#fbbf24" stroke-width="1.5"/>
<line x1="1820" y1="518" x2="1950" y2="518" stroke="#fbbf24" stroke-width="0.5" opacity="0.6"/>
<text x="1885" y="510" fill="white" font-size="10" font-weight="700" text-anchor="middle">client_follow_logs</text>
<text x="1828" y="535" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1828" y="548" fill="#94a3b8" font-size="8">FK client_id</text>
<text x="1828" y="561" fill="#94a3b8" font-size="8">log_type</text>
<text x="1828" y="574" fill="#94a3b8" font-size="8">content: text</text>
<text x="1828" y="587" fill="#94a3b8" font-size="8">created_at</text>
<text x="1828" y="617" fill="#fb7185" font-size="7">⚠ NO DELETE</text>
<!-- Viewing -->
<rect x="2250" y="290" width="250" height="165" rx="6" fill="#0f172a"/>
<rect x="2250" y="290" width="250" height="165" rx="6" fill="rgba(120,53,15,0.35)" stroke="#fbbf24" stroke-width="1.5"/>
<line x1="2250" y1="318" x2="2500" y2="318" stroke="#fbbf24" stroke-width="0.5" opacity="0.6"/>
<text x="2375" y="310" fill="white" font-size="11" font-weight="700" text-anchor="middle">client_viewings</text>
<text x="2260" y="335" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="2260" y="348" fill="#94a3b8" font-size="8">FK client_id</text>
<text x="2260" y="361" fill="#94a3b8" font-size="8">FK property_id</text>
<text x="2260" y="374" fill="#94a3b8" font-size="8">FK agent_id (staff)</text>
<text x="2260" y="387" fill="#94a3b8" font-size="8">viewed_at: timestamptz</text>
<text x="2260" y="400" fill="#94a3b8" font-size="8">feedback: text</text>
<text x="2260" y="413" fill="#94a3b8" font-size="8">rating: smallint</text>
<text x="2260" y="426" fill="#94a3b8" font-size="8">status: planned/done/cancelled</text>
<!-- Match -->
<rect x="1960" y="700" width="280" height="150" rx="6" fill="#0f172a"/>
<rect x="1960" y="700" width="280" height="150" rx="6" fill="rgba(120,53,15,0.35)" stroke="#fbbf24" stroke-width="1.5"/>
<line x1="1960" y1="728" x2="2240" y2="728" stroke="#fbbf24" stroke-width="0.5" opacity="0.6"/>
<text x="2100" y="720" fill="white" font-size="11" font-weight="700" text-anchor="middle">client_property_matches</text>
<text x="1970" y="745" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1970" y="758" fill="#94a3b8" font-size="8">FK client_id</text>
<text x="1970" y="771" fill="#94a3b8" font-size="8">FK property_id</text>
<text x="1970" y="784" fill="#94a3b8" font-size="8">match_type: system/manual</text>
<text x="1970" y="797" fill="#94a3b8" font-size="8">score: numeric(5,2)</text>
<text x="1970" y="810" fill="#94a3b8" font-size="8">status: pending/sent/viewed</text>
<text x="1970" y="823" fill="#94a3b8" font-size="8">created_at</text>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- LEGEND -->
<!-- ═══════════════════════════════════════════════════════════ -->
<rect x="30" y="1850" width="900" height="100" rx="8" fill="rgba(15,23,42,0.8)" stroke="#334155" stroke-width="1"/>
<text x="50" y="1873" fill="#94a3b8" font-size="9" font-weight="600">LEGEND</text>
<!-- Org -->
<rect x="50" y="1885" width="14" height="14" rx="2" fill="rgba(8,51,68,0.4)" stroke="#22d3ee" stroke-width="1.5"/>
<text x="70" y="1896" fill="#22d3ee" font-size="8">ORG / HR</text>
<!-- Complex -->
<rect x="155" y="1885" width="14" height="14" rx="2" fill="rgba(6,78,59,0.4)" stroke="#34d399" stroke-width="1.5"/>
<text x="175" y="1896" fill="#34d399" font-size="8">REGION &amp; COMPLEX</text>
<!-- Property -->
<rect x="310" y="1885" width="14" height="14" rx="2" fill="rgba(76,29,149,0.4)" stroke="#a78bfa" stroke-width="1.5"/>
<text x="330" y="1896" fill="#a78bfa" font-size="8">PROPERTY</text>
<!-- Client -->
<rect x="420" y="1885" width="14" height="14" rx="2" fill="rgba(120,53,15,0.4)" stroke="#fbbf24" stroke-width="1.5"/>
<text x="440" y="1896" fill="#fbbf24" font-size="8">CLIENT</text>
<!-- Relationship lines -->
<line x1="50" y1="1920" x2="90" y2="1920" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#arrow-slate)"/>
<text x="96" y="1924" fill="#94a3b8" font-size="8">Foreign Key (FK)</text>
<line x1="210" y1="1920" x2="250" y2="1920" stroke="#94a3b8" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-slate)"/>
<text x="256" y="1924" fill="#94a3b8" font-size="8">Soft reference / optional FK</text>
<rect x="410" y="1913" width="14" height="14" rx="2" fill="rgba(15,23,42,0.5)" stroke="#34d399" stroke-width="1" stroke-dasharray="3,2"/>
<text x="430" y="1924" fill="#34d399" font-size="8">Join table (N:M)</text>
<text x="550" y="1924" fill="#fbbf24" font-size="8">PK Primary Key</text>
<text x="640" y="1924" fill="#fb7185" font-size="8">⚠ NO DELETE = append-only audit log</text>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- TITLE BLOCK -->
<!-- ═══════════════════════════════════════════════════════════ -->
<text x="1080" y="1930" fill="#475569" font-size="10" text-anchor="middle">Fonrey 房产经纪管理系统 — Entity Relationship Diagram · v1.0 · 2026-04-24 · Schema-per-Tenant (django-tenants)</text>
</svg>

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB