docs: add OA overtime auto-duration and holiday cache design

Capture agreed rules for overtime kit auto/manual hours, ailcc sync
via in-process midnight loop, and dual-end calculation aligned with leave kit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-10 22:49:56 +08:00
1 parent efd7efbc69
commit 2f02a2d0f2
1 file changed
+237
@@ -0,0 +1,237 @@
# OA 加班套件 · 自动计算时长 + 中国节假日缓存 设计文档
**日期**: 2026-08-10
**状态**: 待实现
**范围**: `web-admin` 加班套件运行时时长联动;`backend` 时长重算/校验;全国节假日日历同步与落库
**前置**: 既有加班套件(手动时长);请假套件手改联动模式(`oa-leave-kit` / `oa_leave_hours`)
**界面模式**: 对齐请假套件发起态交互;视觉 Fresh Mint
---
## 1. 背景与目标
当前「加班」套件含加班人、开始时间、结束时间、时长(h),时长仅手动填写。客户要求:
- 按规则**自动计算**时长,并仍支持**手动修改**;
- 工作日 / 周末与节假日规则不同;
- 节假日数据来自第三方免费接口,并缓存到数据库。
### 1.1 目标
- 开始/结束变化时自动写入时长;手改后断开联动,可「恢复自动计算」(对齐请假套件)。
- 前后端同一套算法:发起页即时算;提交时后端 `apply` + `validate`。
- 每天本地 **00:00** 及**服务启动时**从 `holiday.ailcc.com` 拉取并 upsert 到本地表;计算只读本地库。
- 同步跑在**后端进程内后台线程**(非 APScheduler cron);进程退出则任务一并结束。
### 1.2 非目标
- 管理端可视化维护节假日日历。
- 薪资倍数 / 三倍工资展示。
- 租户级全局考勤配置中心(时段挂在**套件实例**属性)。
- 多实例分布式锁(本期 upsert 幂等即可)。
---
## 2. 产品决策(已确认)
| 项 | 决策 |
|----|------|
| 实现方案 | 前后端双端计算 + 本地节假日缓存 |
| 小数 | 按分钟累计,四舍五入 **1** 位小数(同请假) |
| 工作日窗口 | 只统计与 `[workdayOvertimeStart, 次日 workdayOvertimeEnd)` 的交集;白天正常工时不算加班 |
| 默认工作日窗口 | `17:30`~次日 `08:30` |
| 休息日 | 结束−开始,扣除与 `[restBreakStart, restBreakEnd)` 的交集 |
| 默认午休 | `11:30`~`13:30` |
| 跨天 | 按自然日切段,分段套用当日规则后相加 |
| 调休补班(ailcc type=4) | 按**工作日**规则 |
| 法定放假 / 周末 / 调休放假(1/2/3) | 按**休息日**规则 |
| 时段配置 | 套件属性可配,默认值如上 |
| 手改 | `__manual_<durationField>`;未手改自动覆盖;手改仅规范化一位小数;可恢复自动 |
| 节假日源 | `https://holiday.ailcc.com` |
| 同步时机 | 启动拉一次 + 进程内循环 sleep 至次日 00:00 再拉;**不注册 cron** |
| 库无日历行 | 回退:周一~五工作日,周六日休息日 |
---
## 3. 日类型与时长算法
### 3.1 日类型判定
读 `sys_cn_holiday_day`:
| `day_type` | 含义 | 加班规则 |
|------------|------|----------|
| 0 | 工作日 | 工作日 |
| 4 | 补班 | 工作日 |
| 1 | 周末 | 休息日 |
| 2 | 节日 | 休息日 |
| 3 | 调休放假 | 休息日 |
| 无记录 | — | 回退周末规则 |
`is_rest_day` 冗余列:type ∈ {1,2,3} → 1,否则 0。
### 3.2 算法步骤(唯一实现约定)
输入:`start`、`end`(`YYYY-MM-DD HH:mm`)、套件四时段、`is_rest_day(date)`。
1. 若无法解析或 `end <= start` → 返回 `null`(不写时长)。
2. 对半开区间 `[start, end)` 内每一时刻 `t`(实现可用区间合并,语义等同逐分钟):
- **若**存在锚点日 `A`:`A` 为工作日(含补班),且 `t ∈ [A + workdayOvertimeStart, (A+1) + workdayOvertimeEnd)` → **计入**;
- **否则若** `t` 所在日历日为休息日,且 `t` 不在该日 `[restBreakStart, restBreakEnd)` → **计入**;
- **否则**不计。
3. 同一时刻只计一次(先匹配工作日窗口,再匹配休息日)。
4. 总分钟 / 60,四舍五入到 1 位小数。
说明:工作日窗口允许跨到次日早晨;即使次日是休息日,落在「上一工作日窗口」内的凌晨时段仍按加班计入(不再扣次日午休,因已走工作日窗口分支)。
### 3.3 示例(默认时段;周五=工作日,周六=休息日)
| 场景 | 期望 |
|------|------|
| 工作日 18:00–20:00 | 2.0 |
| 工作日 14:00–18:00 | 0.5(仅 17:30 后) |
| 工作日 17:30–次日 08:30 | 15.0 |
| 周六 09:00–15:00 | 4.0(扣 11:30–13:30) |
| 周五 18:00–周六 12:00 | **17.5** |
跨天拆解(周五 18:00–周六 12:00):
- 周五 18:00–周六 08:30:落在周五工作日窗口 → 14.5h
- 周六 08:30–11:30:休息日非午休 → 3.0h
- 周六 11:30–12:00:午休 → 0
- 合计 **17.5h**
---
## 4. 节假日缓存
### 4.1 表 `sys_cn_holiday_day`
全国共用参考数据,**无 `tenant_no`**(系统日历豁免租户隔离)。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | VARCHAR(64) | UUID |
| `day_date` | DATE | 唯一 |
| `day_type` | TINYINT | 0/1/2/3/4,对齐 ailcc |
| `day_name` | VARCHAR(64) | 展示名 |
| `week` | TINYINT | 1–7 |
| `is_rest_day` | TINYINT | 0/1 |
| `raw_json` | TEXT | 原始片段 |
| `sync_time` | DATETIME | 最近同步 |
| `create_time` / `update_time` | DATETIME | |
唯一键:`uk_day_date(day_date)`。DDL 写入 `docs/sql/`。
### 4.2 同步策略
- 数据源:`CN_HOLIDAY_API_BASE`(默认 `https://holiday.ailcc.com`)。
- **不要只用** `/api/holiday/year/{year}`(文档明确**不返回补班**)。
- 使用 `GET /api/holiday/batch?d=日期列表&type=Y`,按月拆批(每月 ≤31,低于 50 上限)。
- 覆盖:**当年**;若当前月为 12 月则同时拉**下一年**(或始终拉当年+下一年,实现任选其一并在计划中写死)。
- Upsert by `day_date`;失败打日志并**保留旧缓存**。
- 超时与有限次重试(如 2 次)在服务层配置。
### 4.3 进程内后台循环(替代 cron)
不在 APScheduler 注册 Job。
在 FastAPI `lifespan`:
1. 启动后台 **daemon 线程**(或等价任务);
2. **立即**执行一次 `sync_cn_holidays()`(不阻塞 `yield` 前的就绪:可先起线程再 `yield`);
3. 循环:`sleep` 至下一个本地 **00:00** → 再 sync → 直到 stop 事件;
4. 应用关闭:set stop、join 短超时;线程随进程退出。
同步互斥锁:避免启动同步与 00:00 重叠双写。
多实例:各进程各自同步,upsert 幂等;本期不加分布式锁。
### 4.4 对内 API
- 服务:`is_rest_day(date) -> bool`;`list_days(start, end)`。
- HTTP:`GET /api/public/cn-holiday/range?start=YYYY-MM-DD&end=YYYY-MM-DD`
返回区间内每日 `dayType` / `isRestDay`(或仅返回有行的日期 + 前端回退)。无敏感数据,可走 public。
---
## 5. 加班套件 Schema / 运行时
### 5.1 套件属性(默认值)
```json
{
"type": "overtimeKit",
"props": {
"personField": "...",
"startField": "...",
"endField": "...",
"durationField": "...",
"workdayOvertimeStart": "17:30",
"workdayOvertimeEnd": "08:30",
"restBreakStart": "11:30",
"restBreakEnd": "13:30"
},
"children": [ "...既有四字段..." ]
}
```
设计器右侧可选编辑上述四时段;时长字段 `info` 改为提示可自动计算、可手改。
### 5.2 form_data 手改标记
与请假一致:`__manual_<durationField>: true`。
### 5.3 前端模块
| 文件 | 职责 |
|------|------|
| `oa-overtime-kit-rule.ts` | 默认属性 + DragRule props |
| `oa-overtime-hours.ts` | 纯函数计算(注入休息日判断 map/回调) |
| `oa-overtime-kit-runtime.ts` | binding、apply、manual、对齐样式 |
| `OaStartFormDialog.vue` | 联动与「恢复自动计算」(对齐 leave) |
| `OaOvertimeKit.vue` | 容器;可声明新 props |
发起时:按起止预取 holiday range 到内存,再算时长。
### 5.4 后端模块
| 文件 | 职责 |
|------|------|
| model + SQL | `sys_cn_holiday_day` |
| `cn_holiday_sync.py` | 拉 ailcc、upsert、后台循环入口 |
| `cn_holiday.py` | 只读判定 |
| `oa_overtime_hours.py` | 与前端对齐的 calc / apply / validate |
| `oa_engine` 提交路径 | 在 leave/trip 旁调用 overtime apply + validate |
| `public` API | range 查询 |
| `main.py` lifespan | 启动/停止后台同步线程 |
`apply`:未手改则覆盖时长;已手改则仅 `_round1`。
`validate`:结束晚于开始;手改时长合法。
---
## 6. 验收标准
1. 启动后库中有当年(及约定的下一年)日历,且含补班日(type=4)。
2. 进程内到 00:00 会再同步;**无** APScheduler overtime/holiday cron 注册;关停服务后面台循环停止。
3. 第 3.3 节示例时长与单测一致。
4. 手改断开 / 恢复自动计算行为与请假一致。
5. 提交时未手改时长由后端按规则写回。
6. ailcc 失败时仍可用旧缓存或周末回退完成计算。
---
## 7. 测试计划
- 前端:`oa-overtime-hours.test.ts`(假日 map 内存注入,不访问外网)。
- 后端:`test_oa_overtime_hours.py`、`test_cn_holiday_sync.py`(mock HTTP)。
- 可选:lifespan 启动路径断言调用一次 sync(mock)。
---
## 8. 风险与备注
- ailcc 免费限流:按月 batch,启动 + 每日一次足够。
- 服务长时间停机:重启时启动同步补齐。
- 算法跨午夜语义以 §3.2 为准,前后端单测共用同一组 §3.3 用例。