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
| 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 步骤仅在用户明确要求时执行。
Files:
web-admin/src/utils/oa-overtime-hours.tsCreate: 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
)
})
})
cd web-admin && pnpm exec tsx --test src/utils/oa-overtime-hours.test.ts
Expected: cannot find module / calcOvertimeHours not exported.
oa-overtime-hours.tsexport 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 一致):
end <= start → null。A(!isRestDay(A))使 t ∈ [A+OT_start, (A+1)+OT_end) → 计入;[start,end) 覆盖的每个日历日 D,以及每个可能锚点 A = D-1, D:
overlap([start,end), window(A)) 累加;D 求 overlap([start,end), day(D)) - overlap(..., break(D)),再减去已落入任一工作日窗口的部分(或先算工作日窗口并集,休息日贡献 = 休息日有效区间 − 窗口并集)。round(minutes/60, 1)。cd web-admin && pnpm exec tsx --test src/utils/oa-overtime-hours.test.ts
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"
Files:
backend/app/services/oa_overtime_hours.pyCreate: 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
cd backend && source venv/bin/activate && pytest tests/test_oa_overtime_hours.py -v
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(仅用户要求时)
Files:
docs/sql/sys_cn_holiday_day.sqlbackend/app/models/cn_holiday.pyModify: 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='中国节假日日历缓存';
# 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(仅用户要求时)
Files:
backend/app/services/cn_holiday.pybackend/app/services/cn_holiday_sync.pybackend/tests/test_cn_holiday_sync.pyModify: 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
# 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 调用次数)。
cn_holiday.pydef 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}, ...];缺行用周末回退补齐区间每一天。"""
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]:
...
cd backend && source venv/bin/activate && pytest tests/test_cn_holiday_sync.py tests/test_oa_overtime_hours.py -v
Files:
backend/app/api/public.pybackend/app/main.pyModify: 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 路由一致)。
在 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 注册节假日任务。
在 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 参数名。)
# 启动后端后
curl -s "http://127.0.0.1:8000/api/cn-holiday/range?start=2026-10-01&end=2026-10-08"
# 期望 data.days 含国庆相关 isRestDay
Files:
web-admin/src/utils/oa-overtime-kit-rule.tsweb-admin/src/utils/oa-overtime-kit-runtime.tsModify: web-admin/src/components/oa-approval/OaOvertimeKit.vue
[ ] Step 1: Rule defaults + props panel
在 rule() 的 props 展开 DEFAULT_OVERTIME_WINDOWS;props() 返回四个 input(标题:工作日加班起、工作日加班止(次日)、休息日午休起、休息日午休止);时长 info 改为:可根据起止时间自动计算,支持手改。
扩展 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。
注释改为支持自动计算;defineProps 增加四个窗口字符串 props。
Files:
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。
设计器拖入加班套件 → 发起:选周五 18:00–20:00 见 2.0;手改后改时间不变;点恢复后重算。
Files:
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
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
| 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 |
workdayOvertimeStart/End、restBreakStart/End。__manual_。main.py 中 public router 实际 prefix 为准,实现时对照现有 /api/... 登录类路径。