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

feat(oa): add leave kit with auto duration calculation

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 месяцев назад
Родитель
Сommit
3b46b10604

+ 228 - 0
backend/app/services/oa_leave_hours.py

@@ -0,0 +1,228 @@
+"""请假套件:双段工时重叠小时(与前端 oa-leave-hours.ts 对齐)"""
+from __future__ import annotations
+
+import re
+from datetime import datetime, timedelta
+from typing import Any, Dict, List, Optional, Tuple
+
+_DT_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})$")
+_HM_RE = re.compile(r"^(\d{1,2}):(\d{2})$")
+
+DEFAULT_LEAVE_WORK_HOURS = {
+    "morningStart": "08:30",
+    "morningEnd": "12:00",
+    "afternoonStart": "13:00",
+    "afternoonEnd": "17:30",
+}
+
+MANUAL_PREFIX = "__manual_"
+
+
+def _parse_hm(hm: Any) -> Optional[int]:
+    m = _HM_RE.match(str(hm or "").strip())
+    if not m:
+        return None
+    h, mi = int(m.group(1)), int(m.group(2))
+    if h < 0 or h > 23 or mi < 0 or mi > 59:
+        return None
+    return h * 60 + mi
+
+
+def _parse_dt(s: Any) -> Optional[datetime]:
+    m = _DT_RE.match(str(s or "").strip())
+    if not m:
+        return None
+    try:
+        return datetime(
+            int(m.group(1)),
+            int(m.group(2)),
+            int(m.group(3)),
+            int(m.group(4)),
+            int(m.group(5)),
+        )
+    except ValueError:
+        return None
+
+
+def _overlap_minutes_on_day(
+    day: datetime,
+    range_start: datetime,
+    range_end: datetime,
+    work_hours: Dict[str, str],
+) -> float:
+    day0 = datetime(day.year, day.month, day.day)
+    am_a = _parse_hm(work_hours.get("morningStart"))
+    am_b = _parse_hm(work_hours.get("morningEnd"))
+    pm_a = _parse_hm(work_hours.get("afternoonStart"))
+    pm_b = _parse_hm(work_hours.get("afternoonEnd"))
+    if None in (am_a, am_b, pm_a, pm_b):
+        return 0.0
+    if am_a >= am_b or pm_a >= pm_b:
+        return 0.0
+    segments = [
+        (day0 + timedelta(minutes=am_a), day0 + timedelta(minutes=am_b)),
+        (day0 + timedelta(minutes=pm_a), day0 + timedelta(minutes=pm_b)),
+    ]
+    total = 0.0
+    for a, b in segments:
+        lo = max(range_start, a)
+        hi = min(range_end, b)
+        if hi > lo:
+            total += (hi - lo).total_seconds() / 60.0
+    return total
+
+
+def calc_leave_hours(
+    start_str: Any,
+    end_str: Any,
+    work_hours: Optional[Dict[str, str]] = None,
+) -> Optional[float]:
+    """返回 1 位小数小时;非法或 end<=start 返回 None。"""
+    wh = dict(DEFAULT_LEAVE_WORK_HOURS)
+    if isinstance(work_hours, dict):
+        wh.update({k: str(work_hours.get(k) or wh[k]) for k in DEFAULT_LEAVE_WORK_HOURS})
+    start = _parse_dt(start_str)
+    end = _parse_dt(end_str)
+    if not start or not end or end <= start:
+        return None
+    minutes = 0.0
+    cursor = datetime(start.year, start.month, start.day)
+    last = datetime(end.year, end.month, end.day)
+    while cursor <= last:
+        minutes += _overlap_minutes_on_day(cursor, start, end, wh)
+        cursor += timedelta(days=1)
+    return round(minutes / 60.0, 1)
+
+
+def _extract_rules(form_schema: Any) -> List[Dict[str, Any]]:
+    if isinstance(form_schema, list):
+        return [r for r in form_schema if isinstance(r, dict)]
+    if isinstance(form_schema, dict):
+        rules = form_schema.get("rule") or form_schema.get("rules") or form_schema.get("fields")
+        if isinstance(rules, list):
+            return [r for r in rules if isinstance(r, dict)]
+        if form_schema.get("type") or form_schema.get("field"):
+            return [form_schema]
+    return []
+
+
+def _walk(rules: List[Dict[str, Any]], out: List[Dict[str, Any]]) -> None:
+    for r in rules:
+        if not isinstance(r, dict):
+            continue
+        out.append(r)
+        children = r.get("children")
+        if isinstance(children, list):
+            _walk([c for c in children if isinstance(c, dict)], out)
+        props = r.get("props") if isinstance(r.get("props"), dict) else {}
+        nested = props.get("rule")
+        if isinstance(nested, list):
+            _walk([c for c in nested if isinstance(c, dict)], out)
+
+
+def _kit_child_fields(kit: Dict[str, Any]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
+    """返回 (start_field, end_field, duration_field)。"""
+    props = kit.get("props") if isinstance(kit.get("props"), dict) else {}
+    start_f = props.get("startField")
+    end_f = props.get("endField")
+    dur_f = props.get("durationField")
+    if start_f and end_f and dur_f:
+        return str(start_f), str(end_f), str(dur_f)
+
+    children = kit.get("children")
+    if not isinstance(children, list):
+        children = []
+        p_rule = props.get("rule")
+        if isinstance(p_rule, list):
+            children = p_rule
+
+    date_fields: List[str] = []
+    duration_field: Optional[str] = None
+    for c in children:
+        if not isinstance(c, dict):
+            continue
+        field = c.get("field")
+        if not field:
+            continue
+        t = str(c.get("type") or "")
+        if t in ("datePicker", "date", "timePicker", "time"):
+            date_fields.append(str(field))
+        elif t in ("inputNumber", "number") and duration_field is None:
+            duration_field = str(field)
+        elif t in ("inputNumber", "number") and "时长" in str(c.get("title") or ""):
+            duration_field = str(field)
+
+    start_field = date_fields[0] if len(date_fields) >= 1 else None
+    end_field = date_fields[1] if len(date_fields) >= 2 else None
+    return start_field, end_field, duration_field
+
+
+def _round1(v: Any) -> Optional[float]:
+    try:
+        n = float(v)
+    except (TypeError, ValueError):
+        return None
+    if n < 0:
+        return None
+    return round(n, 1)
+
+
+def apply_leave_kit_hours(form_schema: Any, form_data: Dict[str, Any]) -> Dict[str, Any]:
+    """
+    遍历 leaveKit:未手改则覆盖时长;已手改(form_data[__manual_<durationField>])则仅规范化一位小数。
+    """
+    data = dict(form_data or {})
+    flat: List[Dict[str, Any]] = []
+    _walk(_extract_rules(form_schema), flat)
+    for rule in flat:
+        if str(rule.get("type") or "") != "leaveKit":
+            continue
+        start_f, end_f, dur_f = _kit_child_fields(rule)
+        if not start_f or not end_f or not dur_f:
+            continue
+        props = rule.get("props") if isinstance(rule.get("props"), dict) else {}
+        wh = {
+            "morningStart": str(props.get("morningStart") or DEFAULT_LEAVE_WORK_HOURS["morningStart"]),
+            "morningEnd": str(props.get("morningEnd") or DEFAULT_LEAVE_WORK_HOURS["morningEnd"]),
+            "afternoonStart": str(
+                props.get("afternoonStart") or DEFAULT_LEAVE_WORK_HOURS["afternoonStart"]
+            ),
+            "afternoonEnd": str(props.get("afternoonEnd") or DEFAULT_LEAVE_WORK_HOURS["afternoonEnd"]),
+        }
+        manual_key = MANUAL_PREFIX + dur_f
+        is_manual = bool(data.get(manual_key))
+        if is_manual:
+            rounded = _round1(data.get(dur_f))
+            if rounded is not None:
+                data[dur_f] = rounded
+            continue
+        hours = calc_leave_hours(data.get(start_f), data.get(end_f), wh)
+        if hours is not None:
+            data[dur_f] = hours
+    return data
+
+
+def validate_leave_kit_form_values(form_schema: Any, form_data: Dict[str, Any]) -> List[str]:
+    """提交校验:结束>=开始;手改时长合法。"""
+    errors: List[str] = []
+    data = form_data or {}
+    flat: List[Dict[str, Any]] = []
+    _walk(_extract_rules(form_schema), flat)
+    for rule in flat:
+        if str(rule.get("type") or "") != "leaveKit":
+            continue
+        start_f, end_f, dur_f = _kit_child_fields(rule)
+        if not start_f or not end_f or not dur_f:
+            continue
+        start = _parse_dt(data.get(start_f))
+        end = _parse_dt(data.get(end_f))
+        if data.get(start_f) and data.get(end_f):
+            if not start or not end:
+                errors.append("请假起止时间格式须为 YYYY-MM-DD HH:mm")
+            elif end <= start:
+                errors.append("请假结束时间须晚于开始时间")
+        if MANUAL_PREFIX + dur_f in data or data.get(MANUAL_PREFIX + dur_f):
+            if data.get(dur_f) is not None and data.get(dur_f) != "":
+                if _round1(data.get(dur_f)) is None:
+                    errors.append("请假时长须为非负数字(1 位小数)")
+    return errors

+ 96 - 0
backend/tests/test_oa_leave_hours.py

@@ -0,0 +1,96 @@
+"""oa_leave_hours unit tests"""
+from app.services.oa_leave_hours import (
+    apply_leave_kit_hours,
+    calc_leave_hours,
+    validate_leave_kit_form_values,
+)
+
+
+def test_calc_same_day_sample():
+    assert calc_leave_hours("2026-07-30 09:00", "2026-07-30 15:00") == 5.0
+
+
+def test_calc_morning_only():
+    assert calc_leave_hours("2026-07-30 08:30", "2026-07-30 12:00") == 3.5
+
+
+def test_calc_lunch_gap():
+    assert calc_leave_hours("2026-07-30 11:00", "2026-07-30 14:00") == 2.0
+
+
+def test_calc_invalid():
+    assert calc_leave_hours("2026-07-30 15:00", "2026-07-30 09:00") is None
+    assert calc_leave_hours("", "2026-07-30 09:00") is None
+
+
+def test_apply_overwrites_when_not_manual():
+    schema = {
+        "rule": [
+            {
+                "type": "leaveKit",
+                "props": {
+                    "morningStart": "08:30",
+                    "morningEnd": "12:00",
+                    "afternoonStart": "13:00",
+                    "afternoonEnd": "17:30",
+                },
+                "children": [
+                    {"type": "select", "field": "leaveType", "title": "请假类型"},
+                    {"type": "datePicker", "field": "leaveStart", "title": "开始时间"},
+                    {"type": "datePicker", "field": "leaveEnd", "title": "结束时间"},
+                    {"type": "inputNumber", "field": "leaveDuration", "title": "时长(h)"},
+                ],
+            }
+        ]
+    }
+    data = {
+        "leaveType": "事假",
+        "leaveStart": "2026-07-30 09:00",
+        "leaveEnd": "2026-07-30 15:00",
+        "leaveDuration": 1,
+    }
+    out = apply_leave_kit_hours(schema, data)
+    assert out["leaveDuration"] == 5.0
+
+
+def test_apply_respects_manual():
+    schema = {
+        "rule": [
+            {
+                "type": "leaveKit",
+                "props": {},
+                "children": [
+                    {"type": "datePicker", "field": "leaveStart"},
+                    {"type": "datePicker", "field": "leaveEnd"},
+                    {"type": "inputNumber", "field": "leaveDuration"},
+                ],
+            }
+        ]
+    }
+    data = {
+        "leaveStart": "2026-07-30 09:00",
+        "leaveEnd": "2026-07-30 15:00",
+        "leaveDuration": 8,
+        "__manual_leaveDuration": True,
+    }
+    out = apply_leave_kit_hours(schema, data)
+    assert out["leaveDuration"] == 8.0
+
+
+def test_validate_end_before_start():
+    schema = {
+        "rule": [
+            {
+                "type": "leaveKit",
+                "children": [
+                    {"type": "datePicker", "field": "s"},
+                    {"type": "datePicker", "field": "e"},
+                    {"type": "inputNumber", "field": "d"},
+                ],
+            }
+        ]
+    }
+    errs = validate_leave_kit_form_values(
+        schema, {"s": "2026-07-30 15:00", "e": "2026-07-30 09:00", "d": 1}
+    )
+    assert any("结束" in e for e in errs)

+ 202 - 0
docs/superpowers/plans/2026-07-30-oa-leave-kit.md

@@ -0,0 +1,202 @@
+# OA Leave Kit(请假套件)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:** 在审批「2 表单设计」增加「套件」菜单与请假套件:拖入后得到套件容器(工时属性)+ 四个基础子字段;发起时按双段工时自动算时长(可手改);提交时后端权威重算/校验。
+
+**Architecture:** FormCreate 扩展 `leaveKit` 布局容器(`input: false`,可拖入子组件,默认带 4 子字段)+ `menu: 'kit'`。工时在容器 `props`。纯函数 `calcLeaveHours` 前后端各一份。发起页对 schema 中 leaveKit 绑定 watch;`start_instance` 调用 `apply_leave_kit_hours`。
+
+**Tech Stack:** Vue 3 · `@form-create/designer` · Element Plus · FastAPI · pytest · Node `node:test`(前端算法单测)
+
+**Spec:** `docs/superpowers/specs/2026-07-30-oa-leave-kit-design.md`  
+**Demo:** `docs/mockups/oa-form-kit-tab-demo.html`
+
+---
+
+## File map
+
+| Path | Responsibility |
+|------|----------------|
+| Create: `web-admin/src/utils/oa-leave-hours.ts` | 重叠工时计算纯函数 |
+| Create: `web-admin/src/utils/oa-leave-hours.test.ts` | 算法单测 |
+| Create: `web-admin/src/components/oa-approval/OaLeaveKit.vue` | 套件容器 UI(设计器/运行时) |
+| Create: `web-admin/src/utils/oa-leave-kit-rule.ts` | DragRule:menu=kit,默认 4 子字段 + 工时 props |
+| Create: `web-admin/src/utils/oa-form-create-leave-kit.ts` | `addMenu('kit')` + `setupOaLeaveKit` |
+| Create: `web-admin/src/utils/oa-leave-kit-runtime.ts` | 从 schema 解析 leaveKit、应用自动时长、手改标记 |
+| Modify: `web-admin/src/main.ts` | 注册 leaveKit |
+| Modify: `web-admin/src/oa-fc-host/main.ts` | 同上 |
+| Modify: `web-admin/src/views/oa-approval/start/index.vue` | 发起页联动时长 |
+| Create: `backend/app/services/oa_leave_hours.py` | 同算法 + `apply_leave_kit_hours` |
+| Create: `backend/tests/test_oa_leave_hours.py` | pytest |
+| Modify: `backend/app/services/oa_publish.py` | `LAYOUT_TYPES` 加 `leaveKit` |
+| Modify: `backend/app/services/oa_engine.py` | `start_instance` 调用 apply |
+| Modify: `docs/superpowers/specs/2026-07-30-oa-leave-kit-design.md` | 状态 → 实现中/已实现 |
+
+---
+
+### Task 1: Leave hours algorithm (TDD, frontend)
+
+**Files:**
+- Create: `web-admin/src/utils/oa-leave-hours.ts`
+- Create: `web-admin/src/utils/oa-leave-hours.test.ts`
+
+- [ ] **Step 1: Write failing tests**
+
+```ts
+// web-admin/src/utils/oa-leave-hours.test.ts
+import { describe, it } from 'node:test'
+import assert from 'node:assert/strict'
+import { calcLeaveHours, DEFAULT_LEAVE_WORK_HOURS } from './oa-leave-hours'
+
+describe('calcLeaveHours', () => {
+  const wh = DEFAULT_LEAVE_WORK_HOURS // 08:30-12:00, 13:00-17:30
+
+  it('same day morning+afternoon sample 09:00-15:00 => 5.0', () => {
+    assert.equal(calcLeaveHours('2026-07-30 09:00', '2026-07-30 15:00', wh), 5.0)
+  })
+
+  it('only morning', () => {
+    assert.equal(calcLeaveHours('2026-07-30 08:30', '2026-07-30 12:00', wh), 3.5)
+  })
+
+  it('gap across lunch not counted', () => {
+    assert.equal(calcLeaveHours('2026-07-30 11:00', '2026-07-30 14:00', wh), 2.0)
+  })
+
+  it('end before start => null', () => {
+    assert.equal(calcLeaveHours('2026-07-30 15:00', '2026-07-30 09:00', wh), null)
+  })
+
+  it('invalid => null', () => {
+    assert.equal(calcLeaveHours('', '2026-07-30 09:00', wh), null)
+  })
+})
+```
+
+- [ ] **Step 2: Run tests — expect FAIL**
+
+```bash
+cd web-admin && pnpm exec tsx --test src/utils/oa-leave-hours.test.ts
+```
+
+- [ ] **Step 3: Implement `oa-leave-hours.ts`**
+
+Export:
+- `DEFAULT_LEAVE_WORK_HOURS = { morningStart, morningEnd, afternoonStart, afternoonEnd }`
+- `calcLeaveHours(start, end, workHours): number | null` — 1 decimal, null if invalid/end≤start
+- Overlap minutes with morning+afternoon segments per calendar day
+
+- [ ] **Step 4: Run tests — expect PASS**
+
+- [ ] **Step 5: Commit**(仅当用户要求 commit 时执行;本会话默认可暂缓)
+
+---
+
+### Task 2: Backend leave hours + publish whitelist
+
+**Files:**
+- Create: `backend/app/services/oa_leave_hours.py`
+- Create: `backend/tests/test_oa_leave_hours.py`
+- Modify: `backend/app/services/oa_publish.py` — add `leaveKit` to `LAYOUT_TYPES`
+- Modify: `backend/app/services/oa_engine.py` — after formulas, call `apply_leave_kit_hours`
+
+- [ ] **Step 1: pytest failing** for `calc_leave_hours` 09:00–15:00 → 5.0
+
+- [ ] **Step 2: Implement** `calc_leave_hours` + `apply_leave_kit_hours(schema, data)`:
+  - Walk rules for `type == leaveKit`
+  - Find children fields: prefer props `startField`/`endField`/`durationField` if set, else by title/order (select, datePicker, datePicker, inputNumber)
+  - If duration props `durationManual` truthy: validate ≥0 one decimal only
+  - Else overwrite duration with calc result
+
+- [ ] **Step 3: Wire `start_instance`**
+
+- [ ] **Step 4: pytest PASS**
+
+---
+
+### Task 3: FormCreate leaveKit container + kit menu
+
+**Files:**
+- Create: `OaLeaveKit.vue` — 虚线框 + 标题「请假套件」+ `<slot />`
+- Create: `oa-leave-kit-rule.ts` — DragRule
+- Create: `oa-form-create-leave-kit.ts` — addMenu + component + addDragRule
+- Modify: `main.ts`, `oa-fc-host/main.ts`
+
+DragRule essentials:
+```ts
+{
+  menu: 'kit',
+  icon: 'icon-date',
+  label: '请假',
+  name: 'leaveKit',
+  input: false,
+  drag: true,
+  mask: false,
+  inside: true,
+  rule() {
+    return {
+      type: 'leaveKit',
+      props: { ...DEFAULT_LEAVE_WORK_HOURS },
+      children: [ /* select, 2x datePicker datetime, inputNumber duration */ ]
+    }
+  },
+  props() {
+    return [ /* 4x time inputs for morning/afternoon */ ]
+  }
+}
+```
+
+`setupOaLeaveKit`:
+```ts
+FcDesigner.addMenu({ name: 'kit', title: '套件', list: [] })
+FcDesigner.component('leaveKit', OaLeaveKit)
+formCreate.component('leaveKit', OaLeaveKit)
+FcDesigner.addDragRule(leaveKitDragRule)
+```
+
+子字段 field 用 uniqueId;时长 `props.precision=1`, `props.durationManual=false`;日期 `props.type='datetime'`, format/valueFormat=`YYYY-MM-DD HH:mm`。
+
+---
+
+### Task 4: Start form runtime linkage
+
+**Files:**
+- Create: `oa-leave-kit-runtime.ts` — `listLeaveKits(schema)`, `applyLeaveKitDurations(schema, formData, { respectManual })`
+- Modify: `start/index.vue` — on form-create change / watch formData,未手改时写回时长;用户改时长字段时设 `durationManual`(若无法写回 schema,用并行隐藏字段或 formData 旁路 `__leaveDurationManual_<field>`;优先改 rule props 若 api 允许)
+
+手改标记落地(择一,实现时选可工作的):
+1. 时长字段 `props.durationManual`(设计器可读)+ 提交时由前端把 manual 状态塞进 formData 特殊 key,后端读取;或
+2. formData 增加 `__manual_<durationField>: true`,后端识别后不覆盖。
+
+**采用 2(更简单跑通)**:发起人手改时长 → 设 `formData['__manual_' + durationField] = true`;后端 `apply_leave_kit_hours` 检查该 key。
+
+「恢复自动计算」:首期发起页可不做按钮,手改后仅断开;设计器 Demo 已有示意。Spec 中的恢复按钮可作为二期(或 start 页时长旁小链,有则加)。
+
+---
+
+### Task 5: Spec status + graphify + smoke checklist
+
+- Update design doc status
+- `./scripts/update-graph.sh frontend`(若改了 web-admin)
+- Manual: designer 套件 Tab → 拖请假 → 配工时 → 发起算时长
+
+---
+
+## Spec coverage
+
+| Spec 项 | Task |
+|---------|------|
+| 套件 Tab 双列卡片 | T3 |
+| 4 基础字段 + 套件工时 | T3 |
+| 双段重叠算法 1 位小数 | T1/T2 |
+| 手改后不联动 | T4 + T2 |
+| 发布白名单 leaveKit | T2 |
+| 提交权威 | T2 |
+| 多套 field 唯一 | T3 uniqueId |
+| 恢复自动计算 | T4 可选/二期 |
+
+---
+
+## Execution
+
+用户已要求继续开发:本会话采用 **Inline Execution**(executing-plans)。

+ 268 - 0
docs/superpowers/specs/2026-07-30-oa-leave-kit-design.md

@@ -0,0 +1,268 @@
+# OA 表单设计 · 请假套件(Leave Kit)设计文档
+
+**日期**: 2026-07-30  
+**状态**: 已实现(计划见 `docs/superpowers/plans/2026-07-30-oa-leave-kit.md`)  
+**范围**: `web-admin` 审批设计器「2 表单设计」套件 Tab + 发起/详情运行时;`backend` 时长重算与发布/提交校验  
+**界面模式**: 对齐 FormCreate 左侧控件库;视觉 Fresh Mint  
+**Demo**: `docs/mockups/oa-form-kit-tab-demo.html`  
+**前置**: `docs/superpowers/specs/2026-07-14-oa-approval-dingtalk-design.md` §4.5 / §4.6 / §5.4  
+
+---
+
+## 1. 背景与目标
+
+客户希望在「2 表单设计」左侧「组件」旁增加 **套件** Tab:将常用业务字段组合为一键插入的预制包。首个套件为 **请假套件**。
+
+### 1.1 目标
+
+- 左侧增加 **套件** Tab(位于「组件」之后),展示模式与「组件」一致(双列小卡片)。
+- **请假套件** = 4 个基础组件拼装 + 套件级属性(上午/下午上班时段)。
+- 时长按工时时段重叠自动计算(小时,1 位小数);支持手改后断开联动。
+- Schema / form_data 仍符合双端契约;子字段可参与条件分支。
+
+### 1.2 非目标(首期)
+
+- 午休单独配置、节假日/工作日历、按工作日扣减。
+- 租户全局考勤设置(工时挂在**套件实例**属性)。
+- 出差 / 加班 / 报销等其它套件(可灰显占位)。
+- 一体复合控件(单 type 包四字段对象值)——已否决,采用方案 1。
+
+---
+
+## 2. 产品决策(已确认)
+
+| 项 | 决策 |
+|----|------|
+| 左侧 UI | Tab:`组件` → `套件` → `大纲`;套件独立 Tab(不出现在「组件」里);双列小卡片 |
+| 组成 | 4 个**基础组件**拼装;套件容器上配置工时属性 |
+| 字段 | 请假类型、开始时间、结束时间、时长(h) |
+| 类型选项 | 内置:事假、调休、病假、年假、产假、陪产假、婚假、例假、丧假、哺乳假;设计器可维护 |
+| 计量单位 | 全部按**小时** |
+| 时间精度 | 开始/结束:年月日时分(`YYYY-MM-DD HH:mm`) |
+| 工时模型 | 上午时段 + 下午时段;间隙(午休)自然不计 |
+| 默认时段 | 上午 `08:30–12:00`,下午 `13:00–17:30` |
+| 配置位置 | **套件实例属性**(每个审批应用各自配置) |
+| 时长手改 | 默认可改;手改后不再随起止/工时自动重算;提供「恢复自动计算」 |
+| 小数 | 保留 **1** 位小数 |
+| 实现方案 | 方案 1:套件容器 + 展开/内嵌基础子字段(非一体 `leaveKit` 值对象) |
+
+---
+
+## 3. 信息模型
+
+### 3.1 心智模型
+
+```
+┌─ 请假套件(组装体 / 容器)─────────────────────┐
+│  套件属性:上午时段、下午时段                    │
+│                                                 │
+│  · 基础·下拉      → 请假类型                     │
+│  · 基础·日期时间  → 开始时间                     │
+│  · 基础·日期时间  → 结束时间                     │
+│  · 基础·数字      → 时长(h)                    │
+└─────────────────────────────────────────────────┘
+```
+
+- 选中**套件容器**:配置工时时段等套件属性(设计器右侧);运行时/发起页**不**额外渲染套件边框或标题,子字段与单独拖入的标准组件样式一致。
+- 选中**子字段**:配置标题、必填、选项等(与普通组件相同)。
+
+### 3.2 Schema 结构(示意)
+
+容器类型建议登记为 `leaveKit`(布局类,不计可填字段数),或复用 `group` 并带 `_fc_kit: 'leave'` 标记。推荐独立 `leaveKit` 便于发布校验与选中态属性面板。
+
+```json
+{
+  "type": "leaveKit",
+  "props": {
+    "morningStart": "08:30",
+    "morningEnd": "12:00",
+    "afternoonStart": "13:00",
+    "afternoonEnd": "17:30"
+  },
+  "children": [
+    {
+      "type": "select",
+      "field": "leaveType",
+      "title": "请假类型",
+      "options": [
+        { "label": "事假", "value": "事假" },
+        { "label": "调休", "value": "调休" },
+        { "label": "病假", "value": "病假" },
+        { "label": "年假", "value": "年假" },
+        { "label": "产假", "value": "产假" },
+        { "label": "陪产假", "value": "陪产假" },
+        { "label": "婚假", "value": "婚假" },
+        { "label": "例假", "value": "例假" },
+        { "label": "丧假", "value": "丧假" },
+        { "label": "哺乳假", "value": "哺乳假" }
+      ],
+      "$required": true
+    },
+    {
+      "type": "datePicker",
+      "field": "leaveStart",
+      "title": "开始时间",
+      "props": { "type": "datetime", "format": "YYYY-MM-DD HH:mm", "valueFormat": "YYYY-MM-DD HH:mm" },
+      "$required": true
+    },
+    {
+      "type": "datePicker",
+      "field": "leaveEnd",
+      "title": "结束时间",
+      "props": { "type": "datetime", "format": "YYYY-MM-DD HH:mm", "valueFormat": "YYYY-MM-DD HH:mm" },
+      "$required": true
+    },
+    {
+      "type": "inputNumber",
+      "field": "leaveDuration",
+      "title": "时长(h)",
+      "props": {
+        "precision": 1,
+        "min": 0,
+        "durationManual": false
+      },
+      "$required": true
+    }
+  ]
+}
+```
+
+说明:
+
+- 子字段 `field` 插入时生成唯一 ID(同表单可插入多套请假套件,自动去重后缀)。
+- 子字段通过 `props.kitRef` 或父 `leaveKit` 关联,用于运行时读取工时配置。
+- `durationManual` 存在时长字段 props;发起人手改后置 `true`。
+
+### 3.3 form_data
+
+```json
+{
+  "leaveType": "事假",
+  "leaveStart": "2026-07-30 09:00",
+  "leaveEnd": "2026-07-30 15:00",
+  "leaveDuration": 5.5
+}
+```
+
+| 字段 | JSON 值形态 |
+|------|-------------|
+| 请假类型 | `string`(选项 value) |
+| 开始/结束 | `string`,`YYYY-MM-DD HH:mm` |
+| 时长 | `number`,一位小数 |
+
+工时时段与 `durationManual` **不**作为业务展示主字段写入 form_data 的独立 key(配置在 schema);若运行时需回传手改状态,可在时长字段旁用约定 props 或并行隐藏字段,实现计划中二选一并保持双端一致。
+
+---
+
+## 4. 时长算法
+
+### 4.1 规则
+
+对闭开或闭闭区间,实现统一为:将 `[start, end]` 与每一天的上午段、下午段求交集,累加分钟数,再 `/ 60`,**四舍五入到 1 位小数**。
+
+- 若 `end <= start`:时长视为无效(前端提示;后端拒绝或置 0 并报错)。
+- 与任一时段无重叠:结果为 `0.0`。
+- 跨天:按自然日切分后分别与两段求交再求和。
+
+示例(默认工时):
+
+- 开始 `09:00`,结束 `15:00`(同日)  
+  → 上午 `09:00–12:00`(3h)+ 下午 `13:00–15:00`(2h)= **5.0h**  
+  (`12:00–13:00` 不计)
+
+### 4.2 手改
+
+| 状态 | 行为 |
+|------|------|
+| `durationManual = false` | 起止或套件工时变化时自动重算并写回时长 |
+| `durationManual = true` | 不再自动重算;保留用户输入 |
+| 「恢复自动计算」 | 清 `durationManual`,按当前起止与工时重算 |
+
+### 4.3 前后端一致性
+
+- 前端发起页:即时预览(未手改时)。
+- 后端提交:  
+  - **未手改**:按同一算法重算并**覆盖**提交时长后落库。  
+  - **已手改**:信任提交时长,校验 `number ≥ 0` 且一位小数。  
+- 禁止任意 `eval`;算法放在独立纯函数模块(可与 `oa_formula` 并列,如 `oa_leave_hours.py` + 前端 `oa-leave-hours.ts`)。
+
+---
+
+## 5. 设计器与运行时交互
+
+### 5.1 套件 Tab
+
+- `FcDesigner.addMenu({ name: 'kit', title: '套件' })`(或等价配置),保证顺序在「组件」相关菜单之后。
+- `addDragRule` 注册请假套件;视觉与基础组件卡片一致。
+- 其它套件可 `coming` 占位,不注册拖拽或禁用。
+
+### 5.2 拖入与编辑
+
+| 操作 | 行为 |
+|------|------|
+| 拖入/点击请假 | 插入 `leaveKit` + 4 子字段 |
+| 选中容器 | 右侧:上午/下午四个时间;校验段内 start &lt; end |
+| 选中子字段 | 常规属性;类型字段可维护 options |
+| 删除子字段 | 允许;套件不完整时发布 **警告可发**(不强制阻断) |
+| 再拖一套 | **允许**;field 自动唯一 |
+
+### 5.3 发起 / 详情 / 打印
+
+- 四个子字段按基础组件渲染与打印。
+- 套件容器不输出独立 form 值。
+- 详情只读展示已保存时长;不再因工时变更重算历史单。
+
+### 5.4 条件分支
+
+子字段与普通字段相同:勾选「参与流程条件分支」后可在流程设计中引用(类型、时长等)。
+
+---
+
+## 6. 校验清单
+
+### 6.1 设计器 / 发布
+
+- `leaveKit`(或约定 group 标记)加入布局类白名单。
+- 子 type 须为已允许的 `select` / `datePicker` / `inputNumber`(或项目现用等价 type)。
+- 工时四段格式 `HH:mm`;上午/下午各自 start &lt; end;下午起点 &lt; 上午终点时仅 **提示**(不强制)。
+
+### 6.2 提交
+
+- 结束 ≥ 开始。
+- 类型 ∈ 当前 options。
+- 时长规则见 §4.3。
+- 必填子字段非空。
+
+---
+
+## 7. 实现落点(指引,非任务拆解)
+
+| 层 | 落点 |
+|----|------|
+| 设计器宿主 | `web-admin/src/oa-fc-host/`:菜单「套件」、拖拽规则、容器组件与属性 |
+| 主应用注册 | 与金额/人员同类:`setupOaLeaveKit` + `main.ts` / `oa-fc-host/main.ts` |
+| 算法前端 | `web-admin/src/utils/oa-leave-hours.ts` |
+| 算法后端 | `backend/app/services/oa_leave_hours.py`;发起提交路径调用 |
+| 发布白名单 | `backend/app/services/oa_publish.py` → `LAYOUT_TYPES` 增加 `leaveKit` |
+| Demo | `docs/mockups/oa-form-kit-tab-demo.html` |
+
+---
+
+## 8. 验收标准
+
+1. 「2 表单设计」左侧出现 **套件** Tab,样式与组件双列卡片一致;可插入请假套件。  
+2. 画布出现类型 / 开始 / 结束 / 时长四个基础字段;选中套件可配默认双段工时。  
+3. 类型含约定 10 项且可在设计器维护选项。  
+4. 未手改时,改起止后时长按重叠工时更新为 1 位小数;示例 09:00–15:00 → 5.0。  
+5. 手改时长后改起止不再变;「恢复自动计算」后恢复联动。  
+6. 提交后后端对未手改单据重算覆盖;已手改保留用户值(合法前提下)。  
+7. 手机预览可用;打印/详情展示四个字段值。  
+8. 子字段可勾选参与条件分支。
+
+---
+
+## 9. 开放问题(实现时可定)
+
+- 手改状态是仅存 schema props,还是提交时附带隐藏字段:实现计划择一并写测试。  
+- Element Plus `datePicker` datetime 的 `valueFormat` 与后端解析严格对齐。  
+- 同一套件内删除「开始」后,时长字段是否自动禁用编辑提示:建议弱提示即可。

+ 29 - 0
web-admin/src/components/oa-approval/OaLeaveKit.vue

@@ -0,0 +1,29 @@
+<!-- 请假套件:仅组合容器,不改变子字段标准展示样式;工时在 props -->
+<template>
+  <div class="oa-leave-kit" v-bind="$attrs">
+    <slot />
+  </div>
+</template>
+
+<script setup lang="ts">
+  defineOptions({ name: 'OaLeaveKit', inheritAttrs: false })
+
+  /** 工时属性由 FormCreate 写入 props,供 schema / 运行时读取,本组件不展示 */
+  defineProps<{
+    morningStart?: string
+    morningEnd?: string
+    afternoonStart?: string
+    afternoonEnd?: string
+    startField?: string
+    endField?: string
+    durationField?: string
+    typeField?: string
+  }>()
+</script>
+
+<style scoped lang="scss">
+  /* 无边框/底色/标题:子字段视觉与标准组件一致 */
+  .oa-leave-kit {
+    width: 100%;
+  }
+</style>

+ 22 - 0
web-admin/src/utils/oa-form-create-leave-kit.ts

@@ -0,0 +1,22 @@
+import FcDesigner from '@form-create/designer'
+import OaLeaveKit from '@/components/oa-approval/OaLeaveKit.vue'
+import { leaveKitDragRule } from '@/utils/oa-leave-kit-rule'
+
+const FLAG = '__oaLeaveKitConfigured'
+
+export function setupOaLeaveKit(formCreate?: { component?: (name: string, c: unknown) => void }) {
+  if ((FcDesigner as any)[FLAG]) return
+
+  if (typeof FcDesigner.addMenu === 'function') {
+    FcDesigner.addMenu({ name: 'kit', title: '套件', list: [] })
+  }
+
+  FcDesigner.component('leaveKit', OaLeaveKit)
+  if (typeof formCreate?.component === 'function') {
+    formCreate.component('leaveKit', OaLeaveKit)
+  }
+  if (typeof FcDesigner.addDragRule === 'function') {
+    FcDesigner.addDragRule(leaveKitDragRule)
+  }
+  ;(FcDesigner as any)[FLAG] = true
+}

+ 27 - 0
web-admin/src/utils/oa-leave-hours.test.ts

@@ -0,0 +1,27 @@
+import { describe, it } from 'node:test'
+import assert from 'node:assert/strict'
+import { calcLeaveHours, DEFAULT_LEAVE_WORK_HOURS } from './oa-leave-hours'
+
+describe('calcLeaveHours', () => {
+  const wh = DEFAULT_LEAVE_WORK_HOURS
+
+  it('same day morning+afternoon sample 09:00-15:00 => 5.0', () => {
+    assert.equal(calcLeaveHours('2026-07-30 09:00', '2026-07-30 15:00', wh), 5.0)
+  })
+
+  it('only morning', () => {
+    assert.equal(calcLeaveHours('2026-07-30 08:30', '2026-07-30 12:00', wh), 3.5)
+  })
+
+  it('gap across lunch not counted', () => {
+    assert.equal(calcLeaveHours('2026-07-30 11:00', '2026-07-30 14:00', wh), 2.0)
+  })
+
+  it('end before start => null', () => {
+    assert.equal(calcLeaveHours('2026-07-30 15:00', '2026-07-30 09:00', wh), null)
+  })
+
+  it('invalid => null', () => {
+    assert.equal(calcLeaveHours('', '2026-07-30 09:00', wh), null)
+  })
+})

+ 100 - 0
web-admin/src/utils/oa-leave-hours.ts

@@ -0,0 +1,100 @@
+/** 请假套件:双段工时重叠小时数(与 backend oa_leave_hours 对齐) */
+
+export type LeaveWorkHours = {
+  morningStart: string
+  morningEnd: string
+  afternoonStart: string
+  afternoonEnd: string
+}
+
+export const DEFAULT_LEAVE_WORK_HOURS: LeaveWorkHours = {
+  morningStart: '08:30',
+  morningEnd: '12:00',
+  afternoonStart: '13:00',
+  afternoonEnd: '17:30'
+}
+
+const DT_RE = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})$/
+
+function parseHm(hm: string): number | null {
+  const m = String(hm || '')
+    .trim()
+    .match(/^(\d{1,2}):(\d{2})$/)
+  if (!m) return null
+  const h = Number(m[1])
+  const min = Number(m[2])
+  if (h < 0 || h > 23 || min < 0 || min > 59) return null
+  return h * 60 + min
+}
+
+function parseDt(s: string): Date | null {
+  const m = String(s || '')
+    .trim()
+    .match(DT_RE)
+  if (!m) return null
+  const d = new Date(
+    Number(m[1]),
+    Number(m[2]) - 1,
+    Number(m[3]),
+    Number(m[4]),
+    Number(m[5]),
+    0,
+    0
+  )
+  return Number.isNaN(d.getTime()) ? null : d
+}
+
+function overlapMinutesOnDay(
+  day: Date,
+  rangeStart: Date,
+  rangeEnd: Date,
+  workHours: LeaveWorkHours
+): number {
+  const y = day.getFullYear()
+  const mo = day.getMonth()
+  const da = day.getDate()
+  const day0 = new Date(y, mo, da, 0, 0, 0, 0).getTime()
+  const amA = parseHm(workHours.morningStart)
+  const amB = parseHm(workHours.morningEnd)
+  const pmA = parseHm(workHours.afternoonStart)
+  const pmB = parseHm(workHours.afternoonEnd)
+  if (amA == null || amB == null || pmA == null || pmB == null) return 0
+  if (amA >= amB || pmA >= pmB) return 0
+
+  const segments: [number, number][] = [
+    [day0 + amA * 60000, day0 + amB * 60000],
+    [day0 + pmA * 60000, day0 + pmB * 60000]
+  ]
+  const rs = rangeStart.getTime()
+  const re = rangeEnd.getTime()
+  let total = 0
+  for (const [a, b] of segments) {
+    const lo = Math.max(rs, a)
+    const hi = Math.min(re, b)
+    if (hi > lo) total += (hi - lo) / 60000
+  }
+  return total
+}
+
+/**
+ * 计算请假时长(小时,1 位小数)。
+ * end <= start 或非法输入返回 null。
+ */
+export function calcLeaveHours(
+  startStr: string,
+  endStr: string,
+  workHours: LeaveWorkHours = DEFAULT_LEAVE_WORK_HOURS
+): number | null {
+  const start = parseDt(startStr)
+  const end = parseDt(endStr)
+  if (!start || !end || end.getTime() <= start.getTime()) return null
+
+  let minutes = 0
+  const cursor = new Date(start.getFullYear(), start.getMonth(), start.getDate())
+  const last = new Date(end.getFullYear(), end.getMonth(), end.getDate())
+  while (cursor.getTime() <= last.getTime()) {
+    minutes += overlapMinutesOnDay(cursor, start, end, workHours)
+    cursor.setDate(cursor.getDate() + 1)
+  }
+  return Math.round((minutes / 60) * 10) / 10
+}

+ 153 - 0
web-admin/src/utils/oa-leave-kit-rule.ts

@@ -0,0 +1,153 @@
+import { DEFAULT_LEAVE_WORK_HOURS } from '@/utils/oa-leave-hours'
+
+/** 与 FormCreate 官方 unique 同类 */
+let _uidSeq = 0
+function uniqueId() {
+  const num = 370 + ++_uidSeq
+  return (
+    'F' +
+    Math.random().toString(36).slice(3, 6) +
+    Number(`${Date.now()}`).toString(36) +
+    num.toString(36) +
+    'k'
+  )
+}
+
+export const LEAVE_TYPE_OPTIONS = [
+  { label: '事假', value: '事假' },
+  { label: '调休', value: '调休' },
+  { label: '病假', value: '病假' },
+  { label: '年假', value: '年假' },
+  { label: '产假', value: '产假' },
+  { label: '陪产假', value: '陪产假' },
+  { label: '婚假', value: '婚假' },
+  { label: '例假', value: '例假' },
+  { label: '丧假', value: '丧假' },
+  { label: '哺乳假', value: '哺乳假' }
+]
+
+function buildLeaveKitChildren() {
+  const typeField = uniqueId()
+  const startField = uniqueId()
+  const endField = uniqueId()
+  const durationField = uniqueId()
+  return {
+    startField,
+    endField,
+    durationField,
+    typeField,
+    children: [
+      {
+        type: 'select',
+        field: typeField,
+        title: '请假类型',
+        info: '',
+        $required: true,
+        options: LEAVE_TYPE_OPTIONS.map((o) => ({ ...o })),
+        props: {
+          placeholder: '请选择请假类型',
+          clearable: true
+        }
+      },
+      {
+        type: 'datePicker',
+        field: startField,
+        title: '开始时间',
+        info: '',
+        $required: true,
+        props: {
+          type: 'datetime',
+          format: 'YYYY-MM-DD HH:mm',
+          valueFormat: 'YYYY-MM-DD HH:mm',
+          placeholder: '请选择开始时间'
+        }
+      },
+      {
+        type: 'datePicker',
+        field: endField,
+        title: '结束时间',
+        info: '',
+        $required: true,
+        props: {
+          type: 'datetime',
+          format: 'YYYY-MM-DD HH:mm',
+          valueFormat: 'YYYY-MM-DD HH:mm',
+          placeholder: '请选择结束时间'
+        }
+      },
+      {
+        type: 'inputNumber',
+        field: durationField,
+        title: '时长(h)',
+        info: '可按工时自动计算;手改后不再自动联动',
+        $required: true,
+        props: {
+          precision: 1,
+          min: 0,
+          controlsPosition: 'right',
+          align: 'left'
+        },
+        class: 'oa-leave-duration'
+      }
+    ]
+  }
+}
+
+/** FormCreate Designer DragRule:请假套件 */
+export const leaveKitDragRule = {
+  menu: 'kit' as const,
+  icon: 'icon-date',
+  label: '请假',
+  name: 'leaveKit',
+  input: false,
+  drag: true,
+  dragBtn: true,
+  mask: false,
+  inside: true,
+  rule() {
+    const built = buildLeaveKitChildren()
+    return {
+      type: 'leaveKit',
+      props: {
+        ...DEFAULT_LEAVE_WORK_HOURS,
+        startField: built.startField,
+        endField: built.endField,
+        durationField: built.durationField,
+        typeField: built.typeField
+      },
+      children: built.children
+    }
+  },
+  props() {
+    return [
+      {
+        type: 'input',
+        field: 'morningStart',
+        title: '上午开始',
+        value: DEFAULT_LEAVE_WORK_HOURS.morningStart,
+        props: { placeholder: 'HH:mm' }
+      },
+      {
+        type: 'input',
+        field: 'morningEnd',
+        title: '上午结束',
+        value: DEFAULT_LEAVE_WORK_HOURS.morningEnd,
+        props: { placeholder: 'HH:mm' }
+      },
+      {
+        type: 'input',
+        field: 'afternoonStart',
+        title: '下午开始',
+        value: DEFAULT_LEAVE_WORK_HOURS.afternoonStart,
+        props: { placeholder: 'HH:mm' }
+      },
+      {
+        type: 'input',
+        field: 'afternoonEnd',
+        title: '下午结束',
+        value: DEFAULT_LEAVE_WORK_HOURS.afternoonEnd,
+        props: { placeholder: 'HH:mm' }
+      }
+    ]
+  }
+}

+ 212 - 0
web-admin/src/utils/oa-leave-kit-runtime.ts

@@ -0,0 +1,212 @@
+import {
+  calcLeaveHours,
+  DEFAULT_LEAVE_WORK_HOURS,
+  type LeaveWorkHours
+} from '@/utils/oa-leave-hours'
+
+export const LEAVE_MANUAL_PREFIX = '__manual_'
+
+export type LeaveKitBinding = {
+  startField: string
+  endField: string
+  durationField: string
+  workHours: LeaveWorkHours
+}
+
+function asRules(schema: unknown): Record<string, unknown>[] {
+  if (Array.isArray(schema)) return schema.filter((r) => r && typeof r === 'object') as any
+  if (schema && typeof schema === 'object') {
+    const s = schema as any
+    const rules = s.rule || s.rules || s.fields
+    if (Array.isArray(rules)) return rules.filter((r: any) => r && typeof r === 'object')
+    if (s.type || s.field) return [s]
+  }
+  return []
+}
+
+function walk(rules: Record<string, unknown>[], out: Record<string, unknown>[]) {
+  for (const r of rules) {
+    out.push(r)
+    const children = r.children
+    if (Array.isArray(children)) {
+      walk(
+        children.filter((c) => c && typeof c === 'object') as Record<string, unknown>[],
+        out
+      )
+    }
+    const props = r.props as Record<string, unknown> | undefined
+    if (props && Array.isArray(props.rule)) {
+      walk(
+        props.rule.filter((c) => c && typeof c === 'object') as Record<string, unknown>[],
+        out
+      )
+    }
+  }
+}
+
+function resolveFields(kit: Record<string, unknown>): LeaveKitBinding | null {
+  const props = (kit.props && typeof kit.props === 'object' ? kit.props : {}) as Record<
+    string,
+    any
+  >
+  let startField = props.startField as string | undefined
+  let endField = props.endField as string | undefined
+  let durationField = props.durationField as string | undefined
+
+  const children = (
+    Array.isArray(kit.children)
+      ? kit.children
+      : Array.isArray(props.rule)
+        ? props.rule
+        : []
+  ) as Record<string, any>[]
+
+  if (!startField || !endField || !durationField) {
+    const dateFields: string[] = []
+    let dur: string | undefined
+    for (const c of children) {
+      if (!c?.field) continue
+      const t = String(c.type || '')
+      if (t === 'datePicker' || t === 'date' || t === 'timePicker' || t === 'time') {
+        dateFields.push(String(c.field))
+      } else if ((t === 'inputNumber' || t === 'number') && !dur) {
+        dur = String(c.field)
+      }
+    }
+    startField = startField || dateFields[0]
+    endField = endField || dateFields[1]
+    durationField = durationField || dur
+  }
+
+  if (!startField || !endField || !durationField) return null
+
+  return {
+    startField,
+    endField,
+    durationField,
+    workHours: {
+      morningStart: String(props.morningStart || DEFAULT_LEAVE_WORK_HOURS.morningStart),
+      morningEnd: String(props.morningEnd || DEFAULT_LEAVE_WORK_HOURS.morningEnd),
+      afternoonStart: String(props.afternoonStart || DEFAULT_LEAVE_WORK_HOURS.afternoonStart),
+      afternoonEnd: String(props.afternoonEnd || DEFAULT_LEAVE_WORK_HOURS.afternoonEnd)
+    }
+  }
+}
+
+export function listLeaveKitBindings(schema: unknown): LeaveKitBinding[] {
+  const flat: Record<string, unknown>[] = []
+  walk(asRules(schema), flat)
+  const out: LeaveKitBinding[] = []
+  for (const r of flat) {
+    if (String(r.type || '') !== 'leaveKit') continue
+    const b = resolveFields(r)
+    if (b) out.push(b)
+  }
+  return out
+}
+
+/** 未手改时写回时长;返回是否有变更 */
+export function applyLeaveKitDurationsToFormData(
+  schema: unknown,
+  formData: Record<string, unknown>
+): { data: Record<string, unknown>; changed: boolean } {
+  const data = { ...formData }
+  let changed = false
+  for (const kit of listLeaveKitBindings(schema)) {
+    const manualKey = LEAVE_MANUAL_PREFIX + kit.durationField
+    if (data[manualKey]) continue
+    const hours = calcLeaveHours(
+      String(data[kit.startField] || ''),
+      String(data[kit.endField] || ''),
+      kit.workHours
+    )
+    if (hours == null) continue
+    if (data[kit.durationField] !== hours) {
+      data[kit.durationField] = hours
+      changed = true
+    }
+  }
+  return { data, changed }
+}
+
+export function markLeaveDurationManual(
+  formData: Record<string, unknown>,
+  durationField: string,
+  manual = true
+) {
+  const data = { ...formData }
+  const key = LEAVE_MANUAL_PREFIX + durationField
+  if (manual) data[key] = true
+  else delete data[key]
+  return data
+}
+
+export function isLeaveDurationField(schema: unknown, field: string): boolean {
+  return listLeaveKitBindings(schema).some((k) => k.durationField === field)
+}
+
+export function findLeaveKitByStartOrEnd(
+  schema: unknown,
+  field: string
+): LeaveKitBinding | null {
+  return (
+    listLeaveKitBindings(schema).find(
+      (k) => k.startField === field || k.endField === field
+    ) || null
+  )
+}
+
+/**
+ * 运行时补齐请假套件子规则:时长数字框默认左对齐(覆盖 Element Plus 默认居中)。
+ * 对已发布且未带 align 的旧 schema 同样生效。
+ */
+export function ensureLeaveKitRuleProps(rules: unknown): any[] {
+  if (!Array.isArray(rules)) return []
+  return rules.map((raw) => {
+    if (!raw || typeof raw !== 'object') return raw
+    const rule: any = { ...(raw as object) }
+    if (String(rule.type || '') === 'leaveKit') {
+      const binding = resolveFields(rule)
+      const patchChildren = (list: any[]): any[] =>
+        list.map((c) => {
+          if (!c || typeof c !== 'object') return c
+          const child: any = { ...c }
+          const t = String(child.type || '')
+          const isDuration =
+            (binding && child.field === binding.durationField) ||
+            ((t === 'inputNumber' || t === 'number') &&
+              String(child.title || '').includes('时长'))
+          if (isDuration) {
+            child.props = { ...(child.props || {}), align: 'left' }
+            const cls = String(child.class || child.className || '')
+            if (!cls.includes('oa-leave-duration')) {
+              child.class = (cls ? cls + ' ' : '') + 'oa-leave-duration'
+            }
+          }
+          if (Array.isArray(child.children)) {
+            child.children = patchChildren(child.children)
+          }
+          return child
+        })
+      if (Array.isArray(rule.children)) {
+        rule.children = patchChildren(rule.children)
+      }
+      if (Array.isArray(rule.props?.rule)) {
+        rule.props = {
+          ...rule.props,
+          rule: patchChildren(rule.props.rule)
+        }
+      }
+    }
+    if (Array.isArray(rule.children)) {
+      rule.children = ensureLeaveKitRuleProps(rule.children)
+    }
+    if (Array.isArray(rule.props?.rule)) {
+      rule.props = {
+        ...rule.props,
+        rule: ensureLeaveKitRuleProps(rule.props.rule)
+      }
+    }
+    return rule
+  })
+}