2026-07-30-oa-trip-kit.md 21 KB

OA Trip 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: 在审批「套件」Tab 增加出差套件:事由 + 可增删多行程(钉钉半日制算时长)+ 出差天数汇总 / 备注 / 同行人 / 外部人员;提交时后端权威重算与校验;不回写业务表。

Architecture: FormCreate tripKit 布局容器(input: false,默认 6 子字段)+ 自定义可填控件 tripItinerary(v-model 为行程对象数组)。半日时长纯函数前后端各一份;发起页 watch 行程/天数;start_instance 调用 apply_trip_kit_days。手改标记:行内 durationManual;出差天数用 formData['__manual_' + daysField] = true(对齐请假套件)。

Tech Stack: Vue 3 · @form-create/designer · Element Plus · FastAPI · pytest · Node node:test

Spec: docs/superpowers/specs/2026-07-30-oa-trip-kit-design.md


File map

Path Responsibility
Create: web-admin/src/utils/oa-trip-days.ts 半日制时长 / 汇总纯函数
Create: web-admin/src/utils/oa-trip-days.test.ts 算法单测
Create: backend/app/services/oa_trip_days.py 同算法 + apply/validate
Create: backend/tests/test_oa_trip_days.py pytest
Create: web-admin/src/components/oa-approval/OaTripItinerary.vue 行程列表 UI(增删、半日联动)
Create: web-admin/src/utils/oa-trip-itinerary-rule.ts tripItinerary DragRule(一般不单独出现在组件库;由套件内嵌)
Create: web-admin/src/utils/oa-form-create-trip-itinerary.ts 注册 tripItinerary 控件
Create: web-admin/src/components/oa-approval/OaTripKit.vue 套件容器
Create: web-admin/src/utils/oa-trip-kit-rule.ts 套件 DragRule + 默认子字段
Create: web-admin/src/utils/oa-form-create-trip-kit.ts setupOaTripKit
Create: web-admin/src/utils/oa-trip-kit-runtime.ts 解析 schema、apply 自动时长/天数
Modify: web-admin/src/main.ts 注册 itinerary + tripKit
Modify: web-admin/src/oa-fc-host/main.ts 同上
Modify: web-admin/src/oa-fc-host/App.vue 拖入 tripKit 画布 refresh 白名单
Modify: web-admin/src/views/oa-approval/start/index.vue 发起页联动
Modify: web-admin/src/utils/oa-approval-print.ts unwrap tripKit;格式化行程数组
Modify: backend/app/services/oa_publish.py LAYOUT_TYPES + FILLABLE_TYPES
Modify: backend/app/services/oa_engine.py start_instance 调用 apply/validate
Modify: docs/superpowers/specs/2026-07-30-oa-trip-kit-design.md 状态 → 已实现

Task 1: Half-day algorithm (TDD, frontend)

Files:

  • Create: web-admin/src/utils/oa-trip-days.ts
  • Create: web-admin/src/utils/oa-trip-days.test.ts

  • [ ] Step 1: Write failing tests

// web-admin/src/utils/oa-trip-days.test.ts
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
  calcTripLegDays,
  sumTripLegDays,
  isHalfDayMultiple
} from './oa-trip-days'

describe('calcTripLegDays', () => {
  it('same day morning->morning => 0.5', () => {
    assert.equal(calcTripLegDays('2026-08-01', '上午', '2026-08-01', '上午'), 0.5)
  })
  it('same day morning->afternoon => 1', () => {
    assert.equal(calcTripLegDays('2026-08-01', '上午', '2026-08-01', '下午'), 1)
  })
  it('same day afternoon->afternoon => 0.5', () => {
    assert.equal(calcTripLegDays('2026-08-01', '下午', '2026-08-01', '下午'), 0.5)
  })
  it('same day afternoon->morning => null', () => {
    assert.equal(calcTripLegDays('2026-08-01', '下午', '2026-08-01', '上午'), null)
  })
  it('D1 morning -> D2 afternoon => 2', () => {
    assert.equal(calcTripLegDays('2026-08-01', '上午', '2026-08-02', '下午'), 2)
  })
  it('D1 afternoon -> D2 morning => 1', () => {
    assert.equal(calcTripLegDays('2026-08-01', '下午', '2026-08-02', '上午'), 1)
  })
  it('invalid date => null', () => {
    assert.equal(calcTripLegDays('', '上午', '2026-08-01', '下午'), null)
  })
})

describe('sumTripLegDays', () => {
  it('sums durations', () => {
    assert.equal(sumTripLegDays([{ duration: 0.5 }, { duration: 1 }, { duration: 2 }]), 3.5)
  })
  it('ignores non-number', () => {
    assert.equal(sumTripLegDays([{ duration: 1 }, { duration: null }]), 1)
  })
})

describe('isHalfDayMultiple', () => {
  it('accepts 0.5 steps', () => {
    assert.equal(isHalfDayMultiple(1.5), true)
    assert.equal(isHalfDayMultiple(1.25), false)
  })
})
  • Step 2: Run — expect FAIL
cd web-admin && pnpm exec tsx --test src/utils/oa-trip-days.test.ts

Expected: FAIL(模块不存在)

  • Step 3: Implement oa-trip-days.ts
// web-admin/src/utils/oa-trip-days.ts
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/

export type TripHalf = '上午' | '下午'

function dayIndex(dateStr: string): number | null {
  const m = DATE_RE.exec(String(dateStr || '').trim())
  if (!m) return null
  const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3])
  const t = Date.UTC(y, mo - 1, d)
  if (Number.isNaN(t)) return null
  return t / 86400000
}

function startOffset(half: string): number | null {
  if (half === '上午') return 0
  if (half === '下午') return 0.5
  return null
}

function endOffset(half: string): number | null {
  if (half === '上午') return 0.5
  if (half === '下午') return 1
  return null
}

/** 非法或 endPoint <= startPoint 返回 null */
export function calcTripLegDays(
  startDate: string,
  startHalf: string,
  endDate: string,
  endHalf: string
): number | null {
  const ds = dayIndex(startDate)
  const de = dayIndex(endDate)
  const so = startOffset(String(startHalf || '').trim())
  const eo = endOffset(String(endHalf || '').trim())
  if (ds == null || de == null || so == null || eo == null) return null
  const duration = de + eo - (ds + so)
  if (!(duration > 0)) return null
  return Math.round(duration * 2) / 2
}

export function sumTripLegDays(legs: Array<{ duration?: unknown }>): number {
  let sum = 0
  for (const leg of legs || []) {
    const n = Number(leg?.duration)
    if (Number.isFinite(n) && n >= 0) sum += n
  }
  return Math.round(sum * 2) / 2
}

export function isHalfDayMultiple(n: unknown): boolean {
  const v = Number(n)
  if (!Number.isFinite(v) || v < 0) return false
  return Math.abs(v * 2 - Math.round(v * 2)) < 1e-9
}

export function emptyTripLeg(): Record<string, unknown> {
  return {
    transport: '',
    tripType: '',
    fromCity: '',
    toCity: '',
    startDate: '',
    startHalf: '上午',
    endDate: '',
    endHalf: '下午',
    duration: null,
    durationManual: false
  }
}
  • Step 4: Run — expect PASS
cd web-admin && pnpm exec tsx --test src/utils/oa-trip-days.test.ts
  • Step 5: Commit
git add web-admin/src/utils/oa-trip-days.ts web-admin/src/utils/oa-trip-days.test.ts
git commit -m "feat(oa): add trip kit half-day duration helpers"

Task 2: Backend algorithm + apply/validate (TDD)

Files:

  • Create: backend/app/services/oa_trip_days.py
  • Create: backend/tests/test_oa_trip_days.py
  • Modify: backend/app/services/oa_publish.py
  • Modify: backend/app/services/oa_engine.py(在 leave apply 之后插入 trip apply)

  • [ ] Step 1: Write failing pytest

# backend/tests/test_oa_trip_days.py
from app.services.oa_trip_days import calc_trip_leg_days, sum_trip_leg_days, apply_trip_kit_days, validate_trip_kit_form_values

def test_half_day_samples():
    assert calc_trip_leg_days("2026-08-01", "上午", "2026-08-01", "上午") == 0.5
    assert calc_trip_leg_days("2026-08-01", "上午", "2026-08-01", "下午") == 1.0
    assert calc_trip_leg_days("2026-08-01", "下午", "2026-08-01", "上午") is None
    assert calc_trip_leg_days("2026-08-01", "下午", "2026-08-02", "上午") == 1.0

def test_sum():
    assert sum_trip_leg_days([{"duration": 0.5}, {"duration": 2}]) == 2.5

def test_apply_overwrites_when_not_manual():
    schema = [{
        "type": "tripKit",
        "props": {"tripListField": "L1", "tripDaysField": "D1"},
        "children": [
            {"type": "tripItinerary", "field": "L1", "props": {"_fc_kit_field": "tripList"}},
            {"type": "inputNumber", "field": "D1", "props": {"_fc_kit_field": "tripDays"}},
        ],
    }]
    data = {
        "L1": [{
            "transport": "飞机", "tripType": "单程",
            "fromCity": "合肥", "toCity": "上海",
            "startDate": "2026-08-01", "startHalf": "上午",
            "endDate": "2026-08-01", "endHalf": "下午",
            "duration": 9, "durationManual": False,
        }],
        "D1": 9,
    }
    out = apply_trip_kit_days(schema, data)
    assert out["L1"][0]["duration"] == 1.0
    assert out["D1"] == 1.0

def test_respect_manual_flags():
    schema = [{
        "type": "tripKit",
        "props": {"tripListField": "L1", "tripDaysField": "D1"},
        "children": [
            {"type": "tripItinerary", "field": "L1", "props": {"_fc_kit_field": "tripList"}},
            {"type": "inputNumber", "field": "D1", "props": {"_fc_kit_field": "tripDays"}},
        ],
    }]
    data = {
        "L1": [{
            "startDate": "2026-08-01", "startHalf": "上午",
            "endDate": "2026-08-01", "endHalf": "下午",
            "duration": 3, "durationManual": True,
            "transport": "火车", "tripType": "往返",
            "fromCity": "a", "toCity": "b",
        }],
        "D1": 7,
        "__manual_D1": True,
    }
    out = apply_trip_kit_days(schema, data)
    assert out["L1"][0]["duration"] == 3
    assert out["D1"] == 7

def test_validate_end_before_start():
    schema = [{
        "type": "tripKit",
        "props": {"tripListField": "L1", "tripDaysField": "D1"},
        "children": [
            {"type": "tripItinerary", "field": "L1", "props": {"_fc_kit_field": "tripList"}},
            {"type": "inputNumber", "field": "D1", "props": {"_fc_kit_field": "tripDays"}},
        ],
    }]
    data = {
        "L1": [{
            "transport": "飞机", "tripType": "单程",
            "fromCity": "a", "toCity": "b",
            "startDate": "2026-08-01", "startHalf": "下午",
            "endDate": "2026-08-01", "endHalf": "上午",
            "duration": 0.5, "durationManual": True,
        }],
        "D1": 0.5,
    }
    errs = validate_trip_kit_form_values(schema, data)
    assert errs
  • Step 2: Run — expect FAIL
cd backend && PYTHONPATH=. python -m pytest tests/test_oa_trip_days.py -v
  • Step 3: Implement oa_trip_days.py

要点(与前端公式一致):

  • calc_trip_leg_days / sum_trip_leg_days / is_half_day_multiple
  • _walk_trip_kits(schema):找 type == tripKit,解析 tripListField / tripDaysField(props 优先,否则 _fc_kit_field)
  • apply_trip_kit_days(schema, data):
    • 对每行:若非 durationManual,用算法覆盖 duration;算不出则保持原值
    • 若 data.get("__manual_" + daysField) 非真,则 data[daysField] = sum(leg durations)
  • validate_trip_kit_form_values:
    • 列表为 list 且 len >= 1
    • 每行必填:transport/tripType/fromCity/toCity/startDate/startHalf/endDate/endHalf
    • calc_trip_leg_days(...) 必须非 null(即结束晚于开始)
    • duration / days:>= 0 且半日倍数

手改约定:

MANUAL_PREFIX = "__manual_"  # 出差天数
# 行程行:leg["durationManual"] is True
  • Step 4: Wire publish + engine

oa_publish.py:

# FILLABLE_TYPES 增加
"tripItinerary",

# LAYOUT_TYPES 增加
"tripKit",

oa_engine.py(紧接 leave 校验之后):

from app.services.oa_trip_days import apply_trip_kit_days, validate_trip_kit_form_values

data = apply_trip_kit_days(schema, data)
trip_errs = validate_trip_kit_form_values(schema, data)
if trip_errs:
    raise OaEngineError(trip_errs[0])
  • Step 5: pytest PASS + commit
cd backend && PYTHONPATH=. python -m pytest tests/test_oa_trip_days.py -v
git add backend/app/services/oa_trip_days.py backend/tests/test_oa_trip_days.py \
  backend/app/services/oa_publish.py backend/app/services/oa_engine.py
git commit -m "feat(oa): add trip kit day calc and submit validation"

Task 3: tripItinerary FormCreate control

Files:

  • Create: web-admin/src/components/oa-approval/OaTripItinerary.vue
  • Create: web-admin/src/utils/oa-trip-itinerary-rule.ts
  • Create: web-admin/src/utils/oa-form-create-trip-itinerary.ts
  • Modify: web-admin/src/main.ts(先注册 itinerary,再注册 kit)
  • Modify: web-admin/src/oa-fc-host/main.ts

  • [ ] Step 1: DragRule(可不进组件菜单,仅登记 type;menu 可省略或 menu: 'aide' 不展示——推荐不 addDragRule 到可见菜单,仅 component 注册;套件 children 引用 type)

oa-form-create-trip-itinerary.ts:

import FcDesigner from '@form-create/designer'
import OaTripItinerary from '@/components/oa-approval/OaTripItinerary.vue'

const FLAG = '__oaTripItineraryConfigured'

export function setupOaTripItinerary(formCreate?: { component?: (name: string, c: unknown) => void }) {
  if ((FcDesigner as any)[FLAG]) return
  FcDesigner.component('tripItinerary', OaTripItinerary)
  if (typeof formCreate?.component === 'function') {
    formCreate.component('tripItinerary', OaTripItinerary)
  }
  ;(FcDesigner as any)[FLAG] = true
}
  • Step 2: Implement OaTripItinerary.vue

行为:

  • modelValue: TripLeg[];空或非数组时初始化为 [emptyTripLeg()]
  • 每条卡片字段:交通 select、单程往返 select、出发/目的 input、开始 date+half、结束 date+half、时长 inputNumber(step 0.5)
  • options 来自 props.transportOptions / props.tripTypeOptions(缺省用设计默认四项/两项)
  • 上下午固定:上午/下午
  • 未 durationManual 时,watch 起止变化 → calcTripLegDays 写回 duration
  • 用户改时长 → durationManual = true;旁提供文字按钮「恢复自动计算」清标记并重算
  • 「新增行程」push emptyTripLeg();「删除」在 length > 1 时 splice
  • 设计器预览:同样可渲染(FormCreate 设计器内编辑 value 可不持久,有 slot/attrs 即可)

组件骨架:

<template>
  <div class="oa-trip-itinerary">
    <div v-for="(leg, idx) in inner" :key="idx" class="oa-trip-leg">
      <div class="oa-trip-leg__head">
        <span>行程 {{ idx + 1 }}</span>
        <el-button link type="danger" :disabled="inner.length <= 1" @click="remove(idx)">删除</el-button>
      </div>
      <!-- el-select / el-input / el-date-picker / el-input-number 绑定 leg 字段 -->
      <!-- 时长旁:恢复自动计算 -->
    </div>
    <el-button @click="add">新增行程</el-button>
  </div>
</template>

样式:Fresh Mint;卡片圆角与间距对齐现有 OA 表单(参考 oa-fc-runtime.scss 间距,避免另起色板)。

  • Step 3: Register in both mains
import { setupOaTripItinerary } from '@/utils/oa-form-create-trip-itinerary'
setupOaTripItinerary(FcDesigner.formCreate)
  • Step 4: Commit
git add web-admin/src/components/oa-approval/OaTripItinerary.vue \
  web-admin/src/utils/oa-form-create-trip-itinerary.ts \
  web-admin/src/utils/oa-trip-itinerary-rule.ts \
  web-admin/src/main.ts web-admin/src/oa-fc-host/main.ts
git commit -m "feat(oa): add tripItinerary FormCreate control"

(若无需单独 rule 文件,可省略 oa-trip-itinerary-rule.ts,本 Task 以 component 注册为准。)


Task 4: tripKit container + designer registration

Files:

  • Create: web-admin/src/components/oa-approval/OaTripKit.vue
  • Create: web-admin/src/utils/oa-trip-kit-rule.ts
  • Create: web-admin/src/utils/oa-form-create-trip-kit.ts
  • Create: web-admin/src/utils/oa-trip-kit-runtime.ts
  • Modify: web-admin/src/main.ts / oa-fc-host/main.ts
  • Modify: web-admin/src/oa-fc-host/App.vue(refresh 排除名加 tripKit)
  • Modify: web-admin/src/utils/oa-approval-print.ts

  • [ ] Step 1: OaTripKit.vue — 对齐 OaOvertimeKit.vue(仅 slot 容器)

  • [ ] Step 2: oa-trip-kit-rule.ts

默认 options 常量:

export const DEFAULT_TRANSPORT_OPTIONS = [
  { label: '飞机', value: '飞机' },
  { label: '火车', value: '火车' },
  { label: '汽车', value: '汽车' },
  { label: '其他', value: '其他' }
]
export const DEFAULT_TRIP_TYPE_OPTIONS = [
  { label: '单程', value: '单程' },
  { label: '往返', value: '往返' }
]

buildTripKitChildren() 生成 field ids,children 顺序:

  1. textarea 事由(type: 'input', props: { type: 'textarea', rows: 3, _fc_kit_field: 'tripReason' };若项目用 textarea type 则改用现网等价)
  2. tripItinerary 行程(props 带 transport/tripType options + _fc_kit_field: 'tripList')
  3. inputNumber 出差天数(step 0.5, precision 1, _fc_kit_field: 'tripDays')
  4. textarea 备注
  5. person multiple 同行人
  6. input 外部人员

容器 props 挂 tripReasonField 等六个 id。

DragRule:

export const tripKitDragRule = {
  menu: 'kit' as const,
  icon: 'icon-date',
  label: '出差',
  name: 'tripKit',
  input: false,
  drag: true,
  dragBtn: true,
  mask: false,
  inside: true,
  rule() { /* return type tripKit + props + children */ },
  props() { return [] }
}
  • [ ] Step 3: setupOaTripKit — 同 overtime:addMenu('kit') + component + addDragRule

  • [ ] Step 4: oa-trip-kit-runtime.ts

export type TripKitRef = {
  listField: string
  daysField: string
}

export function listTripKits(schema: unknown): TripKitRef[]
export function applyTripKitDaysToFormData(
  schema: unknown,
  formData: Record<string, unknown>
): { data: Record<string, unknown>; changed: boolean }

逻辑:对每个 kit,读 listField 数组;对非 manual 行重算 duration;若无 __manual_${daysField} 则写 sum。

  • [ ] Step 5: App.vue — 在 kit refresh 过滤条件中加入 name !== 'tripKit'(与 leave/overtime/resignation 并列)

  • [ ] Step 6: Print — LAYOUT_UNWRAP(或现有 kit 列表)加入 'tripKit';行程数组打印时格式化为多行文本(交通/城市/日期时段/时长)

  • [ ] Step 7: Commit

git commit -m "feat(oa): register tripKit in FormCreate designer"

Task 5: Start page runtime linkage

Files:

  • Modify: web-admin/src/views/oa-approval/start/index.vue

  • [ ] Step 1: Import listTripKits / applyTripKitDaysToFormData

  • [ ] Step 2: Watch formData(可与 leave 并列独立 syncing 标志)

伪逻辑:

let tripKitSyncing = false
let tripKitPrev: Record<string, unknown> = {}

function syncTripKitDurations() {
  if (tripKitSyncing) return
  const schema = /* current form rules */
  const curr = { ...formData }
  for (const kit of listTripKits(schema)) {
    const prevDays = tripKitPrev[kit.daysField]
    const daysChanged =
      prevDays !== undefined && prevDays !== curr[kit.daysField]
    if (daysChanged) {
      curr['__manual_' + kit.daysField] = true
    }
    // 行程行内 durationManual 由 OaTripItinerary 维护
  }
  const { data, changed } = applyTripKitDaysToFormData(schema, curr)
  tripKitPrev = { ...data }
  if (!changed) return
  tripKitSyncing = true
  Object.assign(formData, data)
  nextTick(() => { tripKitSyncing = false })
}

在 form-create onChange / 现有 leave sync 钩子旁调用 syncTripKitDurations。

  • Step 3: 手工冒烟
  1. 设计器拖「出差」→ 发布
  2. 发起:填事由;改行程起止看时长与出差天数
  3. 手改天数后改行程天数不变;清 __manual_ 或提供恢复后汇总恢复
  4. 同日下午→上午提交应被后端拒绝
  • Step 4: Commit
git commit -m "feat(oa): wire trip kit duration sync on approval start"

Task 6: Spec status + graphify

  • 将设计文档状态改为 已实现
  • ./scripts/update-graph.sh frontend 与 backend(或依赖 commit hook)
  • Commit docs
git commit -m "docs(oa): mark trip kit design as implemented"

Spec coverage

Spec 项 Task
tripKit 容器 + 三部分字段 T4
tripItinerary 多行程增删 ≥1 T3
钉钉半日制 0.5 步进 T1/T2
出差天数 Σ + 手改断开 T1/T2/T5
交通工具含火车 T4 defaults
不回写业务表 (无 sync 服务,默认满足)
单行程 end>start 校验 T2 validate
不校验行程重叠 (无代码,满足)
发布白名单 T2
打印 unwrap T4
条件分支仅顶层 T4(行程数组不注册条件)

Placeholder / consistency self-check

  • 手改:行 durationManual + 天数 __manual_<field> — 全任务统一。
  • Type 名:tripKit / tripItinerary — 与 publish / print / mains 一致。
  • 算法:endPoint > startPoint — 与 spec §4/§6 一致。
  • 无 TBD/「类似 Task N」未展开项。

Execution handoff

Plan complete and saved to docs/superpowers/plans/2026-07-30-oa-trip-kit.md.

Two execution options:

  1. Subagent-Driven(推荐) — 每 Task 派生子代理,Task 间复审
  2. Inline Execution — 本会话按 executing-plans 连续执行并设检查点

Which approach?