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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-30 18:16:27 +08:00
1 parent 4ebc2dbe82
commit feec1ca0bf
1 file changed
+373
@@ -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. 实现方案:套件容器 + 行程数组自定义控件。