2026-08-10-oa-overtime-auto-duration.md 21 KB

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

// 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
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
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
cd web-admin && pnpm exec tsx --test src/utils/oa-overtime-hours.test.ts
  • Step 5: Commit(仅用户要求时)
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 相同数值)

# 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
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

-- 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
# 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

# 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
# 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
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:

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。

生成日期列表:

def iter_month_dates(year: int, month: int) -> List[str]:
    ...
  • Step 5: Run tests PASS
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

@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 之后:

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 旁:

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: 手动冒烟
# 启动后端后
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:

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):

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

// 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> 时,在表单上方或时长旁显示:

<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:

./scripts/update-graph.sh backend
./scripts/update-graph.sh frontend
  • Step 3: 全量回归
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/... 登录类路径。