Просмотр исходного кода

docs: add OA overtime auto-duration implementation plan

Break down holiday cache sync, dual-end hour calculation, and start-form
wiring into TDD tasks aligned with the approved design spec.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 месяцев назад
Родитель
Сommit
c750072f6f
1 измененных файлов с 635 добавлено и 0 удалено
  1. 635 0
      docs/superpowers/plans/2026-08-10-oa-overtime-auto-duration.md

+ 635 - 0
docs/superpowers/plans/2026-08-10-oa-overtime-auto-duration.md

@@ -0,0 +1,635 @@
+# OA Overtime Auto Duration + CN Holiday Cache Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 加班套件按工作日窗口 / 休息日午休规则自动计算时长(可手改、可恢复),并用进程内后台循环从 holiday.ailcc.com 同步中国节假日到本地库供双端计算。
+
+**Architecture:** 纯函数 `calcOvertimeHours` 前后端各一份(休息日判定可注入);`sys_cn_holiday_day` 缓存 ailcc batch;lifespan 起 daemon 线程(启动同步 + sleep 至次日 00:00);发起页预取 range 后联动时长;`oa_engine` 提交路径 `apply`/`validate` 对齐请假套件。
+
+**Tech Stack:** Vue 3 · FormCreate · FastAPI · SQLAlchemy · pytest · Node `node:test` · urllib/requests HTTP
+
+**Spec:** `docs/superpowers/specs/2026-08-10-oa-overtime-auto-duration-design.md`
+
+---
+
+## File map
+
+| Path | Responsibility |
+|------|----------------|
+| Create: `docs/sql/sys_cn_holiday_day.sql` | 建表 DDL |
+| Create: `backend/app/models/cn_holiday.py` | SQLAlchemy 模型 |
+| Modify: `backend/app/models/models.py` | import 模型 |
+| Modify: `backend/app/core/config.py` | `CN_HOLIDAY_API_BASE` 等 |
+| Create: `backend/app/services/cn_holiday.py` | `is_rest_day` / `list_days` |
+| Create: `backend/app/services/cn_holiday_sync.py` | ailcc batch upsert + 后台循环 |
+| Create: `backend/tests/test_cn_holiday_sync.py` | mock HTTP 同步单测 |
+| Create: `backend/app/services/oa_overtime_hours.py` | calc / apply / validate |
+| Create: `backend/tests/test_oa_overtime_hours.py` | 算法单测 |
+| Modify: `backend/app/api/public.py` | `GET /cn-holiday/range` |
+| Modify: `backend/app/main.py` | lifespan 启停同步线程 |
+| Modify: `backend/app/services/oa_engine.py` | 提交时 apply/validate overtime |
+| Create: `web-admin/src/utils/oa-overtime-hours.ts` | 前端纯函数 |
+| Create: `web-admin/src/utils/oa-overtime-hours.test.ts` | 前端算法单测 |
+| Modify: `web-admin/src/utils/oa-overtime-kit-rule.ts` | 默认四时段 + props 面板 |
+| Modify: `web-admin/src/utils/oa-overtime-kit-runtime.ts` | binding 含时段、apply、manual |
+| Modify: `web-admin/src/components/oa-approval/OaOvertimeKit.vue` | props 声明 |
+| Modify: `web-admin/src/api/oaApproval.ts`(或新建 `cnHoliday.ts`) | range API |
+| Modify: `web-admin/src/components/oa-approval/OaStartFormDialog.vue` | 预取假日、联动、恢复自动 |
+| Modify: `docs/superpowers/specs/2026-08-10-oa-overtime-auto-duration-design.md` | 状态 → 已实现 |
+| Run: `./scripts/update-graph.sh backend` 与 `web-admin` | 图谱增量 |
+
+**约定:** 始终同步**当年 + 下一年**。Commit 步骤仅在用户明确要求时执行。
+
+---
+
+### Task 1: Frontend overtime hours algorithm (TDD)
+
+**Files:**
+- Create: `web-admin/src/utils/oa-overtime-hours.ts`
+- Create: `web-admin/src/utils/oa-overtime-hours.test.ts`
+
+- [ ] **Step 1: Write failing tests**
+
+```ts
+// web-admin/src/utils/oa-overtime-hours.test.ts
+import { describe, it } from 'node:test'
+import assert from 'node:assert/strict'
+import {
+  calcOvertimeHours,
+  DEFAULT_OVERTIME_WINDOWS,
+  type RestDayResolver
+} from './oa-overtime-hours'
+
+/** 固定:2026-08-07 周五工作日,08-08 周六休息日(不用真实日历) */
+const restMap: Record<string, boolean> = {
+  '2026-08-07': false,
+  '2026-08-08': true,
+  '2026-08-09': true
+}
+const isRest: RestDayResolver = (ymd) => restMap[ymd] ?? ymd.endsWith('') // fallback below
+function isRestDay(ymd: string): boolean {
+  if (ymd in restMap) return restMap[ymd]
+  const d = new Date(ymd + 'T00:00:00')
+  const w = d.getDay()
+  return w === 0 || w === 6
+}
+
+describe('calcOvertimeHours', () => {
+  const w = DEFAULT_OVERTIME_WINDOWS
+
+  it('weekday 18:00-20:00 => 2.0', () => {
+    assert.equal(
+      calcOvertimeHours('2026-08-07 18:00', '2026-08-07 20:00', w, isRestDay),
+      2.0
+    )
+  })
+
+  it('weekday 14:00-18:00 => 0.5', () => {
+    assert.equal(
+      calcOvertimeHours('2026-08-07 14:00', '2026-08-07 18:00', w, isRestDay),
+      0.5
+    )
+  })
+
+  it('weekday 17:30 to next 08:30 => 15.0', () => {
+    assert.equal(
+      calcOvertimeHours('2026-08-07 17:30', '2026-08-08 08:30', w, isRestDay),
+      15.0
+    )
+  })
+
+  it('Saturday 09:00-15:00 => 4.0', () => {
+    assert.equal(
+      calcOvertimeHours('2026-08-08 09:00', '2026-08-08 15:00', w, isRestDay),
+      4.0
+    )
+  })
+
+  it('Fri 18:00 - Sat 12:00 => 17.5', () => {
+    assert.equal(
+      calcOvertimeHours('2026-08-07 18:00', '2026-08-08 12:00', w, isRestDay),
+      17.5
+    )
+  })
+
+  it('end <= start => null', () => {
+    assert.equal(
+      calcOvertimeHours('2026-08-07 20:00', '2026-08-07 18:00', w, isRestDay),
+      null
+    )
+  })
+})
+```
+
+- [ ] **Step 2: Run — expect FAIL**
+
+```bash
+cd web-admin && pnpm exec tsx --test src/utils/oa-overtime-hours.test.ts
+```
+
+Expected: cannot find module / calcOvertimeHours not exported.
+
+- [ ] **Step 3: Implement `oa-overtime-hours.ts`**
+
+```ts
+export type OvertimeWindows = {
+  workdayOvertimeStart: string // HH:mm
+  workdayOvertimeEnd: string
+  restBreakStart: string
+  restBreakEnd: string
+}
+
+export const DEFAULT_OVERTIME_WINDOWS: OvertimeWindows = {
+  workdayOvertimeStart: '17:30',
+  workdayOvertimeEnd: '08:30',
+  restBreakStart: '11:30',
+  restBreakEnd: '13:30'
+}
+
+export type RestDayResolver = (ymd: string) => boolean
+
+/** 解析 YYYY-MM-DD HH:mm → Date | null;calc:按 spec §3.2 区间合并,返回 1 位小数或 null */
+export function calcOvertimeHours(
+  startStr: string,
+  endStr: string,
+  windows: OvertimeWindows = DEFAULT_OVERTIME_WINDOWS,
+  isRestDay: RestDayResolver
+): number | null
+```
+
+实现要点(与 spec §3.2 一致):
+
+1. 解析失败或 `end <= start` → `null`。
+2. 用分钟时间戳遍历/合并区间;对每个候选分钟:
+   - 若存在工作日锚点 `A`(`!isRestDay(A)`)使 `t ∈ [A+OT_start, (A+1)+OT_end)` → 计入;
+   - else 若日历日休息且不在午休 → 计入。
+3. 高效做法:枚举 `[start,end)` 覆盖的每个日历日 `D`,以及每个可能锚点 `A = D-1, D`:
+   - 对工作日锚点求 `overlap([start,end), window(A))` 累加;
+   - 对休息日 `D` 求 `overlap([start,end), day(D)) - overlap(..., break(D))`,再减去已落入任一工作日窗口的部分(或先算工作日窗口并集,休息日贡献 = 休息日有效区间 − 窗口并集)。
+4. `round(minutes/60, 1)`。
+
+- [ ] **Step 4: Run — expect PASS**
+
+```bash
+cd web-admin && pnpm exec tsx --test src/utils/oa-overtime-hours.test.ts
+```
+
+- [ ] **Step 5: Commit**(仅用户要求时)
+
+```bash
+git add web-admin/src/utils/oa-overtime-hours.ts web-admin/src/utils/oa-overtime-hours.test.ts
+git commit -m "feat(web-admin): add overtime hours calculator with tests"
+```
+
+---
+
+### Task 2: Backend overtime hours (TDD, mirror frontend)
+
+**Files:**
+- Create: `backend/app/services/oa_overtime_hours.py`
+- Create: `backend/tests/test_oa_overtime_hours.py`
+
+- [ ] **Step 1: Write failing tests**(用例与 Task 1 相同数值)
+
+```python
+# backend/tests/test_oa_overtime_hours.py
+from app.services.oa_overtime_hours import calc_overtime_hours, DEFAULT_OVERTIME_WINDOWS
+
+REST = {
+    "2026-08-07": False,
+    "2026-08-08": True,
+    "2026-08-09": True,
+}
+
+def is_rest(ymd: str) -> bool:
+    if ymd in REST:
+        return REST[ymd]
+    from datetime import datetime
+    return datetime.strptime(ymd, "%Y-%m-%d").weekday() >= 5
+
+def test_weekday_evening():
+    assert calc_overtime_hours("2026-08-07 18:00", "2026-08-07 20:00", DEFAULT_OVERTIME_WINDOWS, is_rest) == 2.0
+
+def test_weekday_partial_window():
+    assert calc_overtime_hours("2026-08-07 14:00", "2026-08-07 18:00", DEFAULT_OVERTIME_WINDOWS, is_rest) == 0.5
+
+def test_full_weekday_window():
+    assert calc_overtime_hours("2026-08-07 17:30", "2026-08-08 08:30", DEFAULT_OVERTIME_WINDOWS, is_rest) == 15.0
+
+def test_saturday_with_break():
+    assert calc_overtime_hours("2026-08-08 09:00", "2026-08-08 15:00", DEFAULT_OVERTIME_WINDOWS, is_rest) == 4.0
+
+def test_fri_to_sat():
+    assert calc_overtime_hours("2026-08-07 18:00", "2026-08-08 12:00", DEFAULT_OVERTIME_WINDOWS, is_rest) == 17.5
+
+def test_invalid():
+    assert calc_overtime_hours("2026-08-07 20:00", "2026-08-07 18:00", DEFAULT_OVERTIME_WINDOWS, is_rest) is None
+```
+
+- [ ] **Step 2: Run — expect FAIL**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_oa_overtime_hours.py -v
+```
+
+- [ ] **Step 3: Implement `oa_overtime_hours.py`**
+
+导出:
+
+- `DEFAULT_OVERTIME_WINDOWS` dict(四键与前端一致)
+- `MANUAL_PREFIX = "__manual_"`
+- `calc_overtime_hours(start, end, windows, is_rest_day_fn) -> Optional[float]`
+- `apply_overtime_kit_hours(form_schema, form_data, is_rest_day_fn) -> dict`  
+  (未手改覆盖时长;手改则 `_round1`;从 overtimeKit props 读四时段与 field 绑定,解析方式对齐 `oa_leave_hours._kit_child_fields` / `_fc_kit_field`)
+- `validate_overtime_kit_form_values(form_schema, form_data) -> List[str]`  
+  (结束晚于开始;手改时长合法)
+
+- [ ] **Step 4: Run — expect PASS**
+
+- [ ] **Step 5: Commit**(仅用户要求时)
+
+---
+
+### Task 3: DB model + DDL
+
+**Files:**
+- Create: `docs/sql/sys_cn_holiday_day.sql`
+- Create: `backend/app/models/cn_holiday.py`
+- Modify: `backend/app/models/models.py`(尾部增加 `from app.models.cn_holiday import SysCnHolidayDay  # noqa`)
+
+- [ ] **Step 1: Write DDL**
+
+```sql
+-- docs/sql/sys_cn_holiday_day.sql
+CREATE TABLE IF NOT EXISTS `sys_cn_holiday_day` (
+  `id` VARCHAR(64) NOT NULL COMMENT 'UUID',
+  `day_date` DATE NOT NULL COMMENT '日历日',
+  `day_type` TINYINT NOT NULL COMMENT '0工作日1周末2节日3调休放假4补班',
+  `day_name` VARCHAR(64) DEFAULT NULL COMMENT '名称',
+  `week` TINYINT DEFAULT NULL COMMENT '1-7周一到周日',
+  `is_rest_day` TINYINT NOT NULL DEFAULT 0 COMMENT '1休息0工作',
+  `raw_json` TEXT COMMENT '原始JSON片段',
+  `sync_time` DATETIME DEFAULT NULL COMMENT '同步时间',
+  `create_time` DATETIME DEFAULT NULL,
+  `update_time` DATETIME DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_day_date` (`day_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='中国节假日日历缓存';
+```
+
+- [ ] **Step 2: Model**
+
+```python
+# backend/app/models/cn_holiday.py
+from sqlalchemy import Column, String, Date, DateTime, Text, SmallInteger, UniqueConstraint
+from app.core.database import Base
+
+class SysCnHolidayDay(Base):
+    __tablename__ = "sys_cn_holiday_day"
+    __table_args__ = (UniqueConstraint("day_date", name="uk_day_date"),)
+
+    id = Column(String(64), primary_key=True)
+    day_date = Column(Date, nullable=False)
+    day_type = Column(SmallInteger, nullable=False)
+    day_name = Column(String(64), nullable=True)
+    week = Column(SmallInteger, nullable=True)
+    is_rest_day = Column(SmallInteger, nullable=False, default=0)
+    raw_json = Column(Text, nullable=True)
+    sync_time = Column(DateTime, nullable=True)
+    create_time = Column(DateTime, nullable=True)
+    update_time = Column(DateTime, nullable=True)
+```
+
+- [ ] **Step 3: 在本地/测试库执行 DDL**(部署环境执行 `docs/sql/sys_cn_holiday_day.sql`)
+
+- [ ] **Step 4: Commit**(仅用户要求时)
+
+---
+
+### Task 4: cn_holiday read service + sync (TDD sync)
+
+**Files:**
+- Create: `backend/app/services/cn_holiday.py`
+- Create: `backend/app/services/cn_holiday_sync.py`
+- Create: `backend/tests/test_cn_holiday_sync.py`
+- Modify: `backend/app/core/config.py`
+
+- [ ] **Step 1: Add settings**
+
+```python
+# in Settings
+CN_HOLIDAY_API_BASE: str = "https://holiday.ailcc.com"
+CN_HOLIDAY_HTTP_TIMEOUT_SEC: int = 15
+CN_HOLIDAY_HTTP_RETRIES: int = 2
+```
+
+- [ ] **Step 2: Failing test for upsert mapping**
+
+```python
+# backend/tests/test_cn_holiday_sync.py
+from datetime import date
+from app.services.cn_holiday_sync import map_ailcc_day_row, _is_rest_from_type
+
+def test_type_rest_flags():
+    assert _is_rest_from_type(1) == 1
+    assert _is_rest_from_type(2) == 1
+    assert _is_rest_from_type(3) == 1
+    assert _is_rest_from_type(0) == 0
+    assert _is_rest_from_type(4) == 0
+
+def test_map_row():
+    row = map_ailcc_day_row(
+        "2026-10-01",
+        holiday={"holiday": True, "name": "国庆节(休)", "date": "2026-10-01"},
+        type_info={"type": 2, "name": "国庆节(休)", "week": 4},
+    )
+    assert row["day_date"] == date(2026, 10, 1)
+    assert row["day_type"] == 2
+    assert row["is_rest_day"] == 1
+```
+
+另写 `test_sync_upsert_mocked`:mock `fetch_batch` 返回一个月数据,用 SQLite 或现有 test DB session upsert 后 `is_rest_day` 可读(若项目无 SQLite fixture,则只单测纯函数 + mock `Session.merge` 调用次数)。
+
+- [ ] **Step 3: Implement `cn_holiday.py`**
+
+```python
+def is_rest_day(db, d: date) -> bool:
+    """查表;无行则 weekday()>=5。day_type in (1,2,3) 或 is_rest_day==1 → True;type 0/4 → False。"""
+
+def list_day_flags(db, start: date, end: date) -> List[dict]:
+    """返回 [{dayDate, dayType, isRestDay, dayName}, ...];缺行用周末回退补齐区间每一天。"""
+```
+
+- [ ] **Step 4: Implement `cn_holiday_sync.py`**
+
+关键 API:
+
+```python
+def sync_cn_holidays(db_session_factory) -> int:
+    """拉当年+下一年各月 batch?type=Y,upsert,返回写入/更新行数。失败打日志并 raise 或返回 -1(调用方决定)。"""
+
+def start_cn_holiday_sync_loop(db_session_factory) -> None:
+    """daemon 线程:立即 sync 一次;再循环 wait 到次日 00:00;threading.Event 停止。"""
+
+def stop_cn_holiday_sync_loop(timeout: float = 2.0) -> None:
+    ...
+```
+
+HTTP:`GET {base}/api/holiday/batch?d=2026-01-01,2026-01-02,...&type=Y`  
+解析 `code==0` 的 `holiday` + `type`;对当月每一天都写入(无 holiday 对象时用 type 推断:type 1 → 周末休息等)。
+
+互斥:`threading.Lock` 包住整次 sync。
+
+生成日期列表:
+
+```python
+def iter_month_dates(year: int, month: int) -> List[str]:
+    ...
+```
+
+- [ ] **Step 5: Run tests PASS**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_cn_holiday_sync.py tests/test_oa_overtime_hours.py -v
+```
+
+- [ ] **Step 6: Commit**(仅用户要求时)
+
+---
+
+### Task 5: Public range API + lifespan + engine hook
+
+**Files:**
+- Modify: `backend/app/api/public.py`
+- Modify: `backend/app/main.py`
+- Modify: `backend/app/services/oa_engine.py`(`apply_leave_kit_hours` 附近)
+
+- [ ] **Step 1: Public endpoint**
+
+```python
+@router.get("/cn-holiday/range", response_model=dict)
+def cn_holiday_range(
+    start: str = Query(..., description="YYYY-MM-DD"),
+    end: str = Query(..., description="YYYY-MM-DD"),
+    db: Session = Depends(get_db),
+):
+    # 解析日期;非法 400
+    days = list_day_flags(db, start_d, end_d)
+    return {"code": 0, "data": {"days": days}}
+```
+
+路径最终为 `/api/cn-holiday/range`(随现有 public 挂载前缀;若 public 无统一 `/api` 前缀,按 `main.py` 实际 include 对齐,与其它 public 路由一致)。
+
+- [ ] **Step 2: lifespan**
+
+在 `lifespan` 中 `init_scheduled_tasks` 之后:
+
+```python
+from app.services.cn_holiday_sync import start_cn_holiday_sync_loop, stop_cn_holiday_sync_loop
+try:
+    start_cn_holiday_sync_loop(SessionLocal)
+except Exception as e:
+    logging.warning("节假日同步线程启动失败: %s", e)
+
+yield
+
+try:
+    stop_cn_holiday_sync_loop()
+except Exception:
+    pass
+# 再 shutdown_scheduler
+```
+
+**不要** `scheduler.add_job` 注册节假日任务。
+
+- [ ] **Step 3: Engine**
+
+在 leave/trip apply 旁:
+
+```python
+from app.services.oa_overtime_hours import (
+    apply_overtime_kit_hours,
+    validate_overtime_kit_form_values,
+)
+from app.services.cn_holiday import is_rest_day as cn_is_rest_day
+
+def _rest(ymd: str) -> bool:
+    from datetime import datetime
+    d = datetime.strptime(ymd, "%Y-%m-%d").date()
+    return cn_is_rest_day(db, d)  # db 为当前 start_instance 会话
+
+data = apply_overtime_kit_hours(schema, data, _rest)
+ot_errs = validate_overtime_kit_form_values(schema, data)
+if ot_errs:
+    raise OaEngineError(ot_errs[0])
+```
+
+(将 `db` 闭包进 `_rest`;若 `start_instance` 当时无 db 变量名,用实际 Session 参数名。)
+
+- [ ] **Step 4: 手动冒烟**
+
+```bash
+# 启动后端后
+curl -s "http://127.0.0.1:8000/api/cn-holiday/range?start=2026-10-01&end=2026-10-08"
+# 期望 data.days 含国庆相关 isRestDay
+```
+
+- [ ] **Step 5: Commit**(仅用户要求时)
+
+---
+
+### Task 6: Overtime kit rule/runtime + designer props
+
+**Files:**
+- Modify: `web-admin/src/utils/oa-overtime-kit-rule.ts`
+- Modify: `web-admin/src/utils/oa-overtime-kit-runtime.ts`
+- Modify: `web-admin/src/components/oa-approval/OaOvertimeKit.vue`
+
+- [ ] **Step 1: Rule defaults + props panel**
+
+在 `rule()` 的 `props` 展开 `DEFAULT_OVERTIME_WINDOWS`;`props()` 返回四个 `input`(标题:工作日加班起、工作日加班止(次日)、休息日午休起、休息日午休止);时长 `info` 改为:`可根据起止时间自动计算,支持手改`。
+
+- [ ] **Step 2: Runtime**
+
+扩展 `OvertimeKitBinding`:
+
+```ts
+export type OvertimeKitBinding = {
+  personField?: string
+  startField: string
+  endField: string
+  durationField: string
+  windows: OvertimeWindows
+}
+```
+
+`listOvertimeKitBindings` 必须解析 start/end/duration(对齐 leave 的 resolve,含 `_fc_kit_field`: overtimeStart/End/Duration)。
+
+新增(对齐 leave):
+
+```ts
+export const OVERTIME_MANUAL_PREFIX = '__manual_'
+
+export function applyOvertimeKitDurationsToFormData(
+  schema: unknown,
+  formData: Record<string, unknown>,
+  isRestDay: RestDayResolver
+): { data: Record<string, unknown>; changed: boolean }
+
+export function markOvertimeDurationManual(formData, durationField, manual = true)
+export function isOvertimeDurationField(schema, field)
+export function findOvertimeKitByStartOrEnd(schema, field)
+```
+
+保留既有 `applyOvertimeKitDefaults` / `ensureOvertimeKitRuleProps`。
+
+- [ ] **Step 3: OaOvertimeKit.vue**
+
+注释改为支持自动计算;`defineProps` 增加四个窗口字符串 props。
+
+- [ ] **Step 4: Commit**(仅用户要求时)
+
+---
+
+### Task 7: Start form dialog wiring + API client
+
+**Files:**
+- Create: `web-admin/src/api/cnHoliday.ts`(或写入 `oaApproval.ts`)
+- Modify: `web-admin/src/components/oa-approval/OaStartFormDialog.vue`
+
+- [ ] **Step 1: API**
+
+```ts
+// web-admin/src/api/cnHoliday.ts
+import request from '@/utils/http' // 与项目现有 request 封装一致
+
+export type CnHolidayDay = {
+  dayDate: string
+  dayType: number
+  isRestDay: boolean
+  dayName?: string
+}
+
+export function fetchCnHolidayRange(start: string, end: string) {
+  return request.get<{ days: CnHolidayDay[] }>({
+    url: '/api/cn-holiday/range',
+    params: { start, end }
+  })
+}
+```
+
+(`request` 导入路径以仓库现有 `src/api/*.ts` 为准,保持一致。)
+
+- [ ] **Step 2: Dialog 状态**
+
+- `overtimeKitSyncing` / `overtimeKitPrevSnapshot`
+- `overtimeRestMap: Record<string, boolean>` 缓存
+- `async function ensureOvertimeHolidayRange(startYmd, endYmd)`:缺日期则 `fetchCnHolidayRange`,写入 map(`isRestDay`)
+- `syncOvertimeKitDurations`:逻辑镜像 `syncLeaveKitDurations`(手改标记 + `applyOvertimeKitDurationsToFormData`);apply 前用当前 kits 的起止调用 `ensureOvertimeHolidayRange`
+- `watch(formData, ...)` 已有 leave/trip 处追加 `syncOvertimeKitDurations`
+- 打开表单 `onPick`/加载详情时 reset snapshot;`applyOvertimeKitDefaults` 之后跑一次 sync
+- UI:当任一 overtime binding 存在 `__manual_<dur>` 时,在表单上方或时长旁显示:
+
+```vue
+<el-button link type="primary" @click="restoreOvertimeAuto">
+  <el-icon><RefreshRight /></el-icon>
+  恢复加班时长自动计算
+</el-button>
+```
+
+`restoreOvertimeAuto`:对所有 overtime kit `markOvertimeDurationManual(..., false)` 后 `syncOvertimeKitDurations`。
+
+- [ ] **Step 3: 手工验收**
+
+设计器拖入加班套件 → 发起:选周五 18:00–20:00 见 2.0;手改后改时间不变;点恢复后重算。
+
+- [ ] **Step 4: Commit**(仅用户要求时)
+
+---
+
+### Task 8: Spec status + graphify
+
+**Files:**
+- Modify: `docs/superpowers/specs/2026-08-10-oa-overtime-auto-duration-design.md` 状态 → `已实现`
+- Run graph update
+
+- [ ] **Step 1: 更新 spec 状态行**
+
+- [ ] **Step 2:**
+
+```bash
+./scripts/update-graph.sh backend
+./scripts/update-graph.sh frontend
+```
+
+- [ ] **Step 3: 全量回归**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_oa_overtime_hours.py tests/test_cn_holiday_sync.py -v
+cd web-admin && pnpm exec tsx --test src/utils/oa-overtime-hours.test.ts
+```
+
+- [ ] **Step 4: Commit**(仅用户要求时,含 graphify 产物若仓库惯例提交)
+
+---
+
+## Spec coverage checklist
+
+| Spec 项 | Task |
+|---------|------|
+| 自动算 + 手改 + 恢复 | 1,2,6,7 |
+| 工作日窗口 / 休息日午休 / 跨天 17.5 | 1,2 |
+| 补班按工作日、放假按休息日 | 4 `is_rest_day` + 1/2 resolver |
+| 套件属性可配四时段 | 6 |
+| ailcc batch 落库、当年+下一年 | 3,4 |
+| 启动 + 次日 0 点循环、无 cron | 4,5 |
+| public range API | 5,7 |
+| engine apply/validate | 5 |
+| 单测 | 1,2,4,8 |
+
+## Placeholder / consistency review
+
+- 前后端默认窗口键名统一:`workdayOvertimeStart/End`、`restBreakStart/End`。
+- Manual 前缀统一 `__manual_`。
+- 同步范围写死「当年+下一年」。
+- API 路径以 `main.py` 中 public router 实际 prefix 为准,实现时对照现有 `/api/...` 登录类路径。