|
|
@@ -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/...` 登录类路径。
|