Files
nexus/Project/fonrey/DATA_MODEL/DATA_MODEL_ORG.md
2026-04-30 06:57:02 +08:00

343 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.
# Fonrey — 组织人事数据模型DATA_MODEL_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 | | 软删除时间戳NULL=未删除非NULL=已软删除 |
**关键索引**
```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() | 记录创建时间(系统自动,等同 operated_at |
| ⚠️ 无 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 | | 软删除时间戳NULL=未删除非NULL=已软删除 |
---
### 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`