瀏覽代碼

docs(oa): add trip kit (出差套件) design spec

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 月之前
父節點
當前提交
feec1ca0bf
共有 1 個文件被更改,包括 373 次插入 和 0 次删除
  1. 373 0
      docs/superpowers/specs/2026-07-30-oa-trip-kit-design.md

+ 373 - 0
docs/superpowers/specs/2026-07-30-oa-trip-kit-design.md

@@ -0,0 +1,373 @@
+# OA 表单设计 · 出差套件(Trip Kit)设计文档
+
+**日期**: 2026-07-30  
+**状态**: 待实现  
+**范围**: `web-admin` 审批设计器「套件」Tab + 发起/详情/打印运行时;`backend` 发布白名单、提交时长重算与行程时间校验  
+**界面模式**: 对齐请假/加班套件;视觉 Fresh Mint  
+**前置**:  
+- `docs/superpowers/specs/2026-07-30-oa-leave-kit-design.md`(套件容器、手改断开)  
+- `docs/superpowers/specs/2026-07-30-oa-overtime-kit-design.md`(扁平套件注册模式)  
+
+---
+
+## 1. 背景与目标
+
+套件 Tab 增加 **出差套件**:事由 + **可增删多行程** + 汇总天数 / 备注 / 同行人 / 外部人员。行程时长与出差天数按钉钉半日制自动计算,支持手改断开。
+
+### 1.1 目标
+
+- 一键插入出差套件(容器 `tripKit`)。  
+- 发起页可「新增行程」,至少保留 1 条;可删至不少于 1 条。  
+- 行程时长、出差天数:未手改时自动算;手改后断开;可「恢复自动计算」。  
+- Schema / form_data 符合双端契约;子字段(非行程数组内嵌项)可参与条件分支。  
+- 审批通过后 **不** 回写用户或其它业务表。  
+
+### 1.2 非目标(首期)
+
+- 回写 `sys_user` / 考勤 / 出差台账表。  
+- 多行程时间重叠校验。  
+- 城市高德/行政区选择器(城市为手填文本)。  
+- 工作日历、节假日扣减。  
+- 「单程往返」参与时长计算。  
+
+---
+
+## 2. 产品决策(已确认)
+
+| 项 | 决策 |
+|----|------|
+| 实现方案 | 方案 1:套件容器 + 子字段拼装;行程为自定义列表控件(数组值) |
+| 容器类型 | `tripKit`(布局类,不计可填字段数) |
+| 行程时长算法 | 钉钉半日制(上午/下午) |
+| 出差天数 | 默认 = Σ 各行程时长;可手改断开 + 恢复自动 |
+| 交通工具默认 | 飞机 / 火车 / 汽车 / 其他(设计器可维护 options) |
+| 单程往返默认 | 单程 / 往返(设计器可维护;不参与时长) |
+| 行程条数 | ≥ 1;可增删;至少保留 1 条 |
+| 落库 / 回写 | 仅审批 form 数据;不回写业务表 |
+| 时间校验 | 单行程结束须晚于开始(`endPoint > startPoint`,时长 > 0);不校验行程间重叠 |
+| 时长精度 | **0.5** 天步进 |
+
+---
+
+## 3. 信息模型
+
+### 3.1 心智模型(三部分)
+
+```
+┌─ 出差套件(tripKit)─────────────────────────────────────┐
+│  ① 出差事由(textarea)                                    │
+│  ② 行程列表(自定义 tripList,≥1 条)                       │
+│       每条:交通工具 / 单程往返 / 出发城市 / 目的城市         │
+│             开始(日期+上午|下午) / 结束(日期+上午|下午)       │
+│             时长(天)                                       │
+│  ③ 出差天数 / 出差备注 / 同行人(person 多选) / 外部人员       │
+└──────────────────────────────────────────────────────────┘
+```
+
+- 选中**套件容器**:设计器右侧可配默认交通工具 / 单程往返 options(或挂在对应子字段上维护,与请假类型一致)。  
+- 运行时/发起页 **不** 额外渲染套件边框或标题;行程列表自带分区标题与「新增行程」按钮。  
+- 行程卡片内字段为列表行内控件,**不是**画布上可单独拖拽的独立 FormCreate 节点(避免 N×M field 爆炸)。  
+
+### 3.2 顶层子字段
+
+| 语义 key | title | type | 必填默认 | 说明 |
+|----------|-------|------|----------|------|
+| `tripReason` | 出差事由 | textarea | 是 | 第一部分 |
+| `tripList` | 行程 | 自定义 `tripItinerary`(或等价 type) | 是(≥1 条) | 值形态见 §3.4 |
+| `tripDays` | 出差天数 | inputNumber | 是 | Σ 行程时长;手改标记 |
+| `tripRemark` | 出差备注 | textarea | 否 | |
+| `tripCompanions` | 同行人 | `person` multiple | 否 | 复用人员组件 |
+| `tripExternal` | 外部人员 | input | 否 | 手填文本 |
+
+语义识别:`props._fc_kit_field`;容器 `props` 可挂各 `*Field` id。
+
+### 3.3 单条行程字段
+
+| key | title | 控件 | 必填默认 |
+|-----|-------|------|----------|
+| `transport` | 交通工具 | select | 是 |
+| `tripType` | 单程往返 | select | 是 |
+| `fromCity` | 出发城市 | input | 是 |
+| `toCity` | 目的城市 | input | 是 |
+| `startDate` | 开始日期 | datePicker 仅日期 `YYYY-MM-DD` | 是 |
+| `startHalf` | 开始时段 | select:上午 / 下午 | 是 |
+| `endDate` | 结束日期 | datePicker 仅日期 | 是 |
+| `endHalf` | 结束时段 | select:上午 / 下午 | 是 |
+| `duration` | 时长(天) | inputNumber step 0.5 | 是 |
+| `durationManual` | (内部) | boolean | — | 手改标记,可不单独展示 |
+
+默认 options:
+
+- 交通工具:`飞机`、`火车`、`汽车`、`其他`  
+- 单程往返:`单程`、`往返`  
+- 上下午:`上午`、`下午`  
+
+### 3.4 Schema 示意
+
+```json
+{
+  "type": "tripKit",
+  "props": {
+    "tripReasonField": "...",
+    "tripListField": "...",
+    "tripDaysField": "...",
+    "tripRemarkField": "...",
+    "tripCompanionsField": "...",
+    "tripExternalField": "..."
+  },
+  "children": [
+    {
+      "type": "input",
+      "field": "...",
+      "title": "出差事由",
+      "props": { "type": "textarea", "_fc_kit_field": "tripReason", "rows": 3 },
+      "$required": true
+    },
+    {
+      "type": "tripItinerary",
+      "field": "...",
+      "title": "行程",
+      "props": {
+        "_fc_kit_field": "tripList",
+        "transportOptions": [
+          { "label": "飞机", "value": "飞机" },
+          { "label": "火车", "value": "火车" },
+          { "label": "汽车", "value": "汽车" },
+          { "label": "其他", "value": "其他" }
+        ],
+        "tripTypeOptions": [
+          { "label": "单程", "value": "单程" },
+          { "label": "往返", "value": "往返" }
+        ]
+      },
+      "$required": true
+    },
+    {
+      "type": "inputNumber",
+      "field": "...",
+      "title": "出差天数",
+      "props": {
+        "_fc_kit_field": "tripDays",
+        "precision": 1,
+        "step": 0.5,
+        "min": 0,
+        "daysManual": false
+      },
+      "$required": true
+    },
+    {
+      "type": "input",
+      "field": "...",
+      "title": "出差备注",
+      "props": { "type": "textarea", "_fc_kit_field": "tripRemark", "rows": 2 },
+      "$required": false
+    },
+    {
+      "type": "person",
+      "field": "...",
+      "title": "同行人",
+      "props": { "_fc_kit_field": "tripCompanions", "multiple": true },
+      "$required": false
+    },
+    {
+      "type": "input",
+      "field": "...",
+      "title": "外部人员",
+      "props": { "_fc_kit_field": "tripExternal", "placeholder": "请输入外部人员" },
+      "$required": false
+    }
+  ]
+}
+```
+
+说明:
+
+- 子字段 `field` 插入时生成唯一 ID(同表单可插多套出差套件)。  
+- `tripItinerary` 为项目自定义 FormCreate 控件(注册方式对齐 `person` / kit 子控件)。  
+- 若实现时发现 `input`+`props.type=textarea` 与现网约定不一致,改用项目已有 textarea 等价 type,语义不变。  
+
+### 3.5 form_data
+
+```json
+{
+  "FxxxReason": "客户拜访",
+  "FxxxList": [
+    {
+      "transport": "飞机",
+      "tripType": "往返",
+      "fromCity": "合肥",
+      "toCity": "上海",
+      "startDate": "2026-08-01",
+      "startHalf": "上午",
+      "endDate": "2026-08-02",
+      "endHalf": "下午",
+      "duration": 2,
+      "durationManual": false
+    }
+  ],
+  "FxxxDays": 2,
+  "FxxxRemark": "",
+  "FxxxCompanions": [{ "id": "...", "name": "张三" }],
+  "FxxxExternal": "李四(客户)"
+}
+```
+
+| 字段 | JSON 值形态 |
+|------|-------------|
+| 出差事由 / 备注 / 外部人员 | `string` |
+| 行程列表 | `array` of objects(§3.3) |
+| 开始/结束日期 | `string`,`YYYY-MM-DD` |
+| 上下午 / 交通 / 单程往返 | `string`(选项 value) |
+| 行程时长 / 出差天数 | `number`,0.5 步进 |
+| 同行人 | 与 `person` 多选一致(`{id,name}[]`) |
+
+手改状态:
+
+- 行程行:`durationManual`(可存行内,或并行约定;前后端一致即可)。  
+- 出差天数:时长字段 props `daysManual` 或并行隐藏字段;**实现计划择一**并写测试。  
+
+---
+
+## 4. 时长算法(钉钉半日制)
+
+### 4.1 点值映射
+
+| 位置 | 上午 | 下午 |
+|------|------|------|
+| 开始 | 日 + **0.0** | 日 + **0.5** |
+| 结束 | 日 + **0.5** | 日 + **1.0** |
+
+将日期转为「日序号」`D`(相对任意 epoch 的整天差即可),则:
+
+```
+startPoint = D(startDate) + (startHalf == "下午" ? 0.5 : 0)
+endPoint   = D(endDate)   + (endHalf   == "下午" ? 1.0 : 0.5)
+duration   = endPoint - startPoint
+```
+
+### 4.2 示例
+
+| 开始 | 结束 | 时长(天) |
+|------|------|------------|
+| 同日 上午 → 上午 | 0.5 |
+| 同日 上午 → 下午 | 1.0 |
+| 同日 下午 → 下午 | 0.5 |
+| 同日 下午 → 上午 | **非法**(≤0) |
+| D1 上午 → D2 下午 | 2.0 |
+| D1 下午 → D2 上午 | 1.0 |
+
+### 4.3 手改与汇总
+
+| 状态 | 行为 |
+|------|------|
+| 行程 `durationManual = false` | 改起止日期/时段时重算该行 `duration` |
+| 行程 `durationManual = true` | 保留用户输入;提供「恢复自动计算」 |
+| 出差天数 `daysManual = false` | = Σ 各行程 `duration`(行程增删/时长变化时更新) |
+| 出差天数 `daysManual = true` | 不再自动汇总;可「恢复自动计算」 |
+
+「单程往返」**不**进入公式。
+
+### 4.4 前后端一致性
+
+- 前端发起页:即时预览(未手改时)。  
+- 后端提交:  
+  - 行程未手改:按同一算法覆盖该行 `duration`。  
+  - 行程已手改:信任提交值,校验 `number ≥ 0` 且为 0.5 倍数(允许实现为 `×2` 后为整数)。  
+  - 出差天数未手改:覆盖为 Σ(覆盖后的)行程时长。  
+  - 出差天数已手改:信任提交值(合法前提下)。  
+- 算法放在独立纯函数:`oa-trip-days.ts` ↔ `oa_trip_days.py`(命名以实现计划为准)。  
+
+---
+
+## 5. 设计器与运行时交互
+
+### 5.1 套件 Tab
+
+- 注册拖拽规则「出差」;卡片样式与其它套件一致。  
+- 容器加入 `LAYOUT_TYPES` / 打印 unwrap(与 `leaveKit` 等相同)。  
+
+### 5.2 拖入与编辑
+
+| 操作 | 行为 |
+|------|------|
+| 拖入出差 | 插入 `tripKit` + §3.2 子字段;行程列表默认 **1** 条空行程模板(设计器预览可显示占位) |
+| 选中子字段 | 常规属性;交通/单程往返 options 可维护 |
+| 删除顶层子字段 | 允许;套件不完整时发布 **警告可发**(同请假) |
+| 再拖一套 | **允许**;field 自动唯一 |
+
+### 5.3 发起 / 详情 / 打印
+
+- 「新增行程」:追加一条空行程(或带默认上下午)。  
+- 「删除」:当前 >1 条时可删;剩 1 条时禁用删除或提示。  
+- 详情/打印:行程按卡片或表格展开各字段;套件容器无独立 form 值。  
+- 历史单不因算法变更重算。  
+
+### 5.4 条件分支
+
+- 顶层:事由、出差天数、备注、同行人、外部人员——与普通字段相同,可勾选参与条件。  
+- 行程数组内字段:**首期不要求**进入条件分支(避免数组下标语义);若引擎已支持「数组聚合」可后续扩展。  
+
+---
+
+## 6. 校验清单
+
+### 6.1 设计器 / 发布
+
+- `tripKit` ∈ 布局类白名单。  
+- `tripItinerary`(或最终 type 名)∈ 允许控件白名单。  
+- `person` / `inputNumber` / textarea 等价 type 合法。  
+
+### 6.2 提交
+
+- 行程条数 ≥ 1。  
+- 每条必填项非空;`transport` / `tripType` / `startHalf` / `endHalf` ∈ 当前 options。  
+- `endPoint > startPoint`(严格大于;等于 0 亦拒绝)。  
+- 时长 / 出差天数规则见 §4.4。  
+- 同行人格式走现有 `person` 校验。  
+
+---
+
+## 7. 实现落点(指引,非任务拆解)
+
+| 层 | 落点 |
+|----|------|
+| 设计器宿主 | `web-admin/src/oa-fc-host/`:拖拽规则、画布刷新 |
+| 主应用注册 | `setupOaTripKit` + `main.ts` / `oa-fc-host/main.ts` |
+| 行程列表控件 | `OaTripItinerary.vue`(或挂在套件 runtime 内) |
+| 算法前端 | `web-admin/src/utils/oa-trip-days.ts` |
+| 算法后端 | `backend/app/services/oa_trip_days.py`;发起提交路径调用 |
+| 发布白名单 | `oa_publish.py` → `LAYOUT_TYPES` + 控件 type |
+| 打印 | `oa-approval-print.ts` unwrap `tripKit`;行程数组格式化 |
+
+---
+
+## 8. 验收标准
+
+1. 套件 Tab 可插入出差套件;画布出现事由、行程、天数、备注、同行人、外部人员。  
+2. 发起页可新增/删除行程,且至少保留 1 条。  
+3. 交通工具默认四项含火车(非货车);单程往返两项;均可在设计器改 options。  
+4. 未手改时,改起止日期/上下午后行程时长按半日制更新;出差天数等于各行程时长之和。  
+5. 手改行程时长或出差天数后断开联动;「恢复自动计算」后恢复。  
+6. 同日下午→上午提交失败;跨天示例 D1 下午→D2 上午 = 1.0。  
+7. 审批通过不回写用户表。  
+8. 详情/打印可读展示多行程。  
+
+---
+
+## 9. 开放问题(实现计划可定)
+
+- 手改标记存行内字段 vs props/隐藏字段:择一并写测试。  
+- 设计器画布上行程列表是「示意一张卡片」还是完整可编辑预览:建议示意 + 说明「发起时可增删」。  
+- `tripItinerary` 最终 type 字符串命名(保持 kebab/camel 与现网 FormCreate 登记一致)。  
+
+---
+
+## 10. 决策记录(对话摘要)
+
+1. 时长算法:钉钉半日制。  
+2. 出差天数:Σ 行程时长 + 手改断开。  
+3. 交通工具:飞机 / 火车 / 汽车 / 其他。  
+4. 行程条数:≥1。  
+5. 不回写业务表。  
+6. 单行程结束 ≥ 开始;不校验行程重叠。  
+7. 实现方案:套件容器 + 行程数组自定义控件。