# 钉钉模块 - 数据模型文档

> **版本**: v1.0  
> **最后更新**: 2026-03-17  
> **维护者**: FFOA 后端团队

---

## 数据摘要

### 最小必填项

| 字段 | 内容 |
|------|------|
| Schema 名称 | `platform_automation` |
| 业务域 | 钉钉同步、员工同步、年假洞察 |
| 核心实体 | `dingtalk_sync_configs` / `dingtalk_leave_codes` / `dingtalk_leave_quota_snapshots` / `dingtalk_annual_leave_release_plans` / `dingtalk_employees` |

### 实体字段清单

| 实体 | 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|------|
| dingtalk_annual_leave_release_plans | user_id | text | ✅ | 员工钉钉 `userId` |
| dingtalk_annual_leave_release_plans | year | int | ✅ | 年度 |
| dingtalk_annual_leave_release_plans | adjustment_days | decimal(10,2) | ✅ | 额度扣减天数 |
| dingtalk_annual_leave_release_plans | not_count_days | int | ✅ | 释放计划偏移天数 |
| dingtalk_annual_leave_release_plans | total_days | int | ✅ | 当前计划总天数 |
| dingtalk_annual_leave_release_plans | release_schedule | jsonb | ✅ | 释放计划数组 |
| dingtalk_annual_leave_release_plans | last_calculated_at | timestamptz | ✅ | 最近一次重算时间 |
| dingtalk_employees | user_id | text | ✅ | 员工钉钉 `userId` |
| dingtalk_employees | status | text | ✅ | 员工主数据状态 |
| dingtalk_leave_quota_snapshots | user_id | text | ✅ | 员工钉钉 `userId` |
| dingtalk_leave_quota_snapshots | leave_code | text | ✅ | 钉钉假期编码 |
| dingtalk_leave_quota_snapshots | remaining_days | decimal(10,2) | ✅ | 剩余天数 |

### 关系与约束

| 关系/约束 | 说明 |
|-----------|------|
| `dingtalk_leave_quota_snapshots(user_id, leave_code, quota_cycle, start_date, end_date)` 唯一 | 单个员工单个周期单个假期快照唯一 |
| `dingtalk_annual_leave_release_plans(user_id, year)` 唯一 | 单个员工单年度只有一份释放计划 |
| `dingtalk_employees.status` 为员工主数据 | 释放计划与总览筛选均读取该字段 |

---

## 数据说明

### dingtalk_leave_quota_snapshots

**用途**:

- 保存“当前时刻”的假期余额快照
- 供年假余额总览页和详情页读取

**写入规则**:

- 全量刷新时先清空整表，再写入新的快照
- 单员工刷新时只覆盖该员工记录
- 页面只读取当前快照，不保留历史版本

### dingtalk_annual_leave_release_plans

**用途**:

- 保存“一个员工某一年”的本地年假释放计划
- 供释放计划页、释放任务与年假详情读取

**核心字段说明**:

| 字段 | 说明 |
|------|------|
| `adjustment_days` | 额度扣减天数，只影响累计应释放额度 |
| `not_count_days` | 计划偏移天数，参与释放日期重算 |
| `release_schedule` | 数组元素格式为 `{ dayIndex, releaseDate }` |
| `last_calculated_at` | 最近一次真正重算的时间 |

**更新规则**:

1. 释放计划重算时，读取员工主数据计算并回写本表
2. 人工参数维护只修改 `adjustment_days` 与 `not_count_days`
3. 员工状态不冗余存储在本表，统一读取 `dingtalk_employees.status`

### dingtalk_employees

**用途**:

- 存放从钉钉同步到本地的员工主数据
- 为员工列表、年假计划筛选与计划计算提供基础信息

**状态语义**:

| 状态值 | 说明 |
|--------|------|
| `正常` | 默认参与年假释放计划与余额展示 |
| `停薪留职` | 当前代码存在该状态，但释放计划页默认不展示 |
| `顾问` | 当前代码存在该状态，但释放计划页默认不展示 |
| `已离职` | 不参与当前展示与计划计算 |

### DingtalkEmployeeEmploymentPeriod / DingtalkEmployeeSuspensionPeriod

`employment_periods` 维护员工"在职段"（支持离职再入职），`suspension_periods` 维护停薪留职区间。两者共同决定**累计司龄**与**年假窗口**。

**司龄计算口径**（事实源，与代码一致；2026-05-08 与 HR 确认）：

```
累计司龄(asOfDate) = Σ ( 段(p).end − 段(p).start )            // 仅 countInTenure=true 段
其中：
  段(p).end = p.leaveDate ?? asOfDate
  单位"天"，向下取整
```

- **离职到再入职的 gap 自动排除**：gap 不属于任何在职段，不会被累加。
- **停薪留职计入司龄、不扣减**：员工身份未中断，停薪期间继续累计司龄。钉钉自带的司龄字段会扣停薪，但本系统口径与之不同，以本系统为准。
- 历史曾短暂改为"扣停薪"（PR #241，2026-05-07），同日确认与 HR 口径冲突后回退（本 PR）。

**触发重算**：`employment_periods` 的 add/update/delete 触发司龄重算并写回 `dingtalk_employees.tenure_days`。`suspension_periods` 的 CRUD **不触发**司龄重算（与司龄解耦）。

**与年假的口径关系**：
- 年假反推 `planJoinDate` 用的累计天数与司龄同口径（不扣停薪），保证档次一致。
- 停薪留职期间**不释放年假**，由 `splitWindowsBySuspensions` 切窗实现——这是独立于司龄的另一条规则。
