Files
zkxw/docs/superpowers/plans/2026-10-14-presign-save-auto-submit-finance.md
T
2026-10-14 12:32:03 +08:00

19 KiB
Raw Blame History

预签约保存时自动提交财务确认 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: 保存预签约订单后,对表单内所有仍为「已付款」dfj 的意向金转账笔自动提交财务确认,Web 与移动端行为一致。

Architecture: 前端编排:先落库订单/转账并写回 id,再逐笔调用现有提交 API;单笔失败只汇总提示,不回滚保存。纯函数负责收集待提交 id、汇总失败文案与串行提交。

Tech Stack: Vue 3;web-admin(Element Plus + node:test);mobile-oa-h5(Vant + node:test)

Spec: docs/superpowers/specs/2026-10-14-presign-save-auto-submit-finance-design.md


File map

File Responsibility
mobile-oa-h5/src/market/presign/presign-form-state.js 纯函数:收集 dfj id、失败文案、串行提交
mobile-oa-h5/src/market/presign/presign-form-state.test.js 上述纯函数单测
mobile-oa-h5/src/views/market/presign/PresignOrderForm.vue save 落库后调用自动提交
mobile-oa-h5/src/market/presign/presign-form-view.test.js 契约:save 路径引用自动提交 helper
web-admin/src/utils/presign-save-auto-submit.ts 与移动端同语义的纯函数(TS)
web-admin/src/utils/presign-save-auto-submit.test.ts Web 纯函数单测
web-admin/src/api/presign-order.ts 提交财务确认支持关闭成功 toast
web-admin/src/views/cell-storage/presign-order/PresignOrderFormDialog.vue handleSave 写回 id + 自动提交

后端 / mobile-h5 不改。


Task 1: 移动端纯函数 + 单测(TDD)

Files:

  • Modify: mobile-oa-h5/src/market/presign/presign-form-state.js

  • Modify: mobile-oa-h5/src/market/presign/presign-form-state.test.js

  • Step 1: 先写失败用例

在 presign-form-state.test.js 末尾追加:

import {
  // …保留既有 import,并追加:
  collectDfjTransferIds,
  formatAutoSubmitFinanceFailureMessage,
  submitDfjTransfersOnSave
} from './presign-form-state.js'

test('collectDfjTransferIds only returns paid transfers with server ids', () => {
  assert.deepEqual(
    collectDfjTransferIds([
      { id: 't1', payStatus: 'dfj' },
      { id: 't2', payStatus: 'jfqrz' },
      { id: '', payStatus: 'dfj' },
      { payStatus: 'dfj' },
      { id: 't3', payStatus: 'dfj' },
      { id: '  t4  ', payStatus: 'dfj' }
    ]),
    ['t1', 't3', 't4']
  )
  assert.deepEqual(collectDfjTransferIds(null), [])
})

test('formatAutoSubmitFinanceFailureMessage summarizes count and reasons', () => {
  assert.equal(formatAutoSubmitFinanceFailureMessage([]), '')
  assert.equal(
    formatAutoSubmitFinanceFailureMessage([
      { id: 't1', message: '请选择转账方式' },
      { id: 't2', message: '请填写转账金额' }
    ]),
    '订单已保存,但有 2 笔提交财务确认失败:请选择转账方式;请填写转账金额'
  )
})

test('submitDfjTransfersOnSave submits only dfj ids and continues after failures', async () => {
  const calls = []
  const failures = await submitDfjTransfersOnSave(
    [
      { id: 'ok', payStatus: 'dfj' },
      { id: 'skip', payStatus: 'yjf' },
      { id: 'bad', payStatus: 'dfj' }
    ],
    async (id) => {
      calls.push(id)
      if (id === 'bad') throw new Error('请填写转账金额')
    }
  )
  assert.deepEqual(calls, ['ok', 'bad'])
  assert.deepEqual(failures, [{ id: 'bad', message: '请填写转账金额' }])
})
  • Step 2: 跑测确认失败

Run:

cd /Users/wuzilong/work/00code/zkxw/mobile-oa-h5 && node --test src/market/presign/presign-form-state.test.js

Expected: FAIL — collectDfjTransferIds / 相关导出走义不存在。

  • Step 3: 实现纯函数

在 presign-form-state.js 末尾追加:

export function collectDfjTransferIds(transfers) {
  const rows = Array.isArray(transfers) ? transfers : []
  return rows
    .filter((row) => {
      const id = String(row?.id || '').trim()
      const status = String(row?.payStatus || 'dfj').trim() || 'dfj'
      return Boolean(id) && status === 'dfj'
    })
    .map((row) => String(row.id).trim())
}

export function formatAutoSubmitFinanceFailureMessage(failures) {
  const list = Array.isArray(failures) ? failures.filter(Boolean) : []
  if (!list.length) return ''
  const reasons = list
    .map((item) => String(item?.message || item || '提交失败').trim())
    .filter(Boolean)
  return `订单已保存,但有 ${list.length} 笔提交财务确认失败:${reasons.join(';')}`
}

export async function submitDfjTransfersOnSave(transfers, submitOne) {
  const ids = collectDfjTransferIds(transfers)
  const failures = []
  for (const id of ids) {
    try {
      await submitOne(id)
    } catch (error) {
      failures.push({
        id,
        message: String(error?.message || error || '提交失败').trim() || '提交失败'
      })
    }
  }
  return failures
}
  • Step 4: 跑测确认通过

Run:

cd /Users/wuzilong/work/00code/zkxw/mobile-oa-h5 && node --test src/market/presign/presign-form-state.test.js

Expected: PASS

  • Step 5: Commit
git add mobile-oa-h5/src/market/presign/presign-form-state.js mobile-oa-h5/src/market/presign/presign-form-state.test.js
git commit -m "$(cat <<'EOF'
feat(mobile-oa-h5): add helpers to auto-submit dfj presign transfers on save

EOF
)"

Task 2: 移动端 save 接入自动提交

Files:

  • Modify: mobile-oa-h5/src/views/market/presign/PresignOrderForm.vue

  • Modify: mobile-oa-h5/src/market/presign/presign-form-view.test.js

  • Step 1: 更新契约测试

在 presign-form-view.test.js 的 transfer workflow 用例中追加断言:

  assert.match(source, /submitDfjTransfersOnSave/)
  assert.match(source, /formatAutoSubmitFinanceFailureMessage/)
  • Step 2: 跑契约确认失败

Run:

cd /Users/wuzilong/work/00code/zkxw/mobile-oa-h5 && node --test src/market/presign/presign-form-view.test.js

Expected: FAIL — 源码尚无 submitDfjTransfersOnSave。

  • Step 3: 改 PresignOrderForm.vue
  1. import 追加:
import {
  // 既有…
  mergePersistedTransfer,
  submitDfjTransfersOnSave,
  formatAutoSubmitFinanceFailureMessage
} from '../../../market/presign/presign-form-state.js'
  1. 将 save() 中落库后的成功提示段替换为:
    const orderId = String(result?.id || id.value)
    if (mode.value === 'edit') await persistTransfers(orderId)
    const transfersForSubmit =
      mode.value === 'edit'
        ? form.value.transfers
        : Array.isArray(result?.transfers)
          ? result.transfers
          : []
    const failures = await submitDfjTransfersOnSave(
      transfersForSubmit,
      (transferId) => submitPresignTransfer(context.value, transferId, { signal: saveController.signal })
    )
    dirty.value = false
    allowLeave = true
    if (failures.length) {
      showToast(formatAutoSubmitFinanceFailureMessage(failures))
    } else {
      showSuccessToast('保存成功')
    }
    await goList()

说明:编辑路径依赖现有 persistTransfers + mergePersistedTransfer 写回新建转账 id;新增路径用创建订单响应里的 transfers。

  • Step 4: 跑契约确认通过

Run:

cd /Users/wuzilong/work/00code/zkxw/mobile-oa-h5 && node --test src/market/presign/presign-form-view.test.js src/market/presign/presign-form-state.test.js

Expected: PASS

  • Step 5: Commit
git add mobile-oa-h5/src/views/market/presign/PresignOrderForm.vue mobile-oa-h5/src/market/presign/presign-form-view.test.js
git commit -m "$(cat <<'EOF'
feat(mobile-oa-h5): auto-submit paid earnest transfers after presign save

EOF
)"

Task 3: Web 纯函数 + API 静默提交选项(TDD)

Files:

  • Create: web-admin/src/utils/presign-save-auto-submit.ts

  • Create: web-admin/src/utils/presign-save-auto-submit.test.ts

  • Modify: web-admin/src/api/presign-order.ts

  • Step 1: 写失败单测

创建 presign-save-auto-submit.test.ts:

import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
  collectDfjTransferIds,
  formatAutoSubmitFinanceFailureMessage,
  submitDfjTransfersOnSave
} from './presign-save-auto-submit'

describe('presign-save-auto-submit', () => {
  it('collects only dfj transfers with ids', () => {
    assert.deepEqual(
      collectDfjTransferIds([
        { id: 't1', payStatus: 'dfj' },
        { id: 't2', payStatus: 'jfqrz' },
        { id: '', payStatus: 'dfj' },
        { payStatus: 'dfj' },
        { id: 't3', payStatus: 'dfj' }
      ]),
      ['t1', 't3']
    )
  })

  it('formats partial failure message', () => {
    assert.equal(
      formatAutoSubmitFinanceFailureMessage([
        { id: 't1', message: '请选择转账方式' },
        { id: 't2', message: '请填写转账金额' }
      ]),
      '订单已保存,但有 2 笔提交财务确认失败:请选择转账方式;请填写转账金额'
    )
  })

  it('continues submitting after one failure', async () => {
    const calls: string[] = []
    const failures = await submitDfjTransfersOnSave(
      [
        { id: 'ok', payStatus: 'dfj' },
        { id: 'bad', payStatus: 'dfj' }
      ],
      async (id) => {
        calls.push(id)
        if (id === 'bad') throw new Error('请填写转账金额')
      }
    )
    assert.deepEqual(calls, ['ok', 'bad'])
    assert.deepEqual(failures, [{ id: 'bad', message: '请填写转账金额' }])
  })
})
  • Step 2: 跑测确认失败

Run:

cd /Users/wuzilong/work/00code/zkxw/web-admin && node --test src/utils/presign-save-auto-submit.test.ts

Expected: FAIL — 模块不存在(若需 tsx/ts-node,改用仓库现有跑法:与其它 *.test.ts 相同,例如 pnpm exec tsx --test src/utils/presign-save-auto-submit.test.ts;以 protocol-payment-payload.test.ts 的实际运行命令为准)。

先确认既有测试如何跑:

cd /Users/wuzilong/work/00code/zkxw/web-admin && node --import tsx --test src/utils/protocol-payment-payload.test.ts

若该命令通过,本任务一律使用:

cd /Users/wuzilong/work/00code/zkxw/web-admin && node --import tsx --test src/utils/presign-save-auto-submit.test.ts
  • Step 3: 实现 util

创建 presign-save-auto-submit.ts:

export type PresignTransferLike = {
  id?: string | null
  payStatus?: string | null
}

export type AutoSubmitFailure = {
  id: string
  message: string
}

export function collectDfjTransferIds(
  transfers: PresignTransferLike[] | null | undefined
): string[] {
  const rows = Array.isArray(transfers) ? transfers : []
  return rows
    .filter((row) => {
      const id = String(row?.id || '').trim()
      const status = String(row?.payStatus || 'dfj').trim() || 'dfj'
      return Boolean(id) && status === 'dfj'
    })
    .map((row) => String(row.id).trim())
}

export function formatAutoSubmitFinanceFailureMessage(
  failures: Array<AutoSubmitFailure | string> | null | undefined
): string {
  const list = Array.isArray(failures) ? failures.filter(Boolean) : []
  if (!list.length) return ''
  const reasons = list
    .map((item) =>
      typeof item === 'string'
        ? item.trim()
        : String(item?.message || '提交失败').trim()
    )
    .filter(Boolean)
  return `订单已保存,但有 ${list.length} 笔提交财务确认失败:${reasons.join(';')}`
}

export async function submitDfjTransfersOnSave(
  transfers: PresignTransferLike[] | null | undefined,
  submitOne: (transferId: string) => Promise<unknown>
): Promise<AutoSubmitFailure[]> {
  const ids = collectDfjTransferIds(transfers)
  const failures: AutoSubmitFailure[] = []
  for (const id of ids) {
    try {
      await submitOne(id)
    } catch (error: any) {
      failures.push({
        id,
        message: String(error?.message || error || '提交失败').trim() || '提交失败'
      })
    }
  }
  return failures
}
  • Step 4: API 支持关闭成功 toast

将 fetchSubmitPresignTransferFinance 改为:

export function fetchSubmitPresignTransferFinance(
  transferId: string,
  options?: { showSuccessMessage?: boolean }
) {
  return request.post({
    url: `/web/admin/presign-transfers/${transferId}/submit-receipt-finance`,
    showSuccessMessage: options?.showSuccessMessage !== false
  })
}

(手动提交不传 options,仍为 true;自动提交传 { showSuccessMessage: false }。)

可选:若保存循环里 fetchCreatePresignTransfer 的成功 toast 过多造成干扰,可同样为 create 增加 showSuccessMessage 选项并在 handleSave 内关闭;非本需求硬性要求,优先只关提交 toast。

  • Step 5: 跑测确认通过

Run:

cd /Users/wuzilong/work/00code/zkxw/web-admin && node --import tsx --test src/utils/presign-save-auto-submit.test.ts

Expected: PASS

  • Step 6: Commit
git add web-admin/src/utils/presign-save-auto-submit.ts web-admin/src/utils/presign-save-auto-submit.test.ts web-admin/src/api/presign-order.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add presign save auto-submit helpers and silent finance submit option

EOF
)"

Task 4: Web 弹窗 handleSave 写回 id 并自动提交

Files:

  • Modify: web-admin/src/views/cell-storage/presign-order/PresignOrderFormDialog.vue

  • Step 1: 增加 import

  import {
    formatAutoSubmitFinanceFailureMessage,
    submitDfjTransfersOnSave
  } from '@/utils/presign-save-auto-submit'
  • Step 2: 重写 handleSave 落库与自动提交段

用下列逻辑替换现有 try 内保存主体(保留母亲姓名 / 样本类型前置校验与 saving):

      const payload: Record<string, unknown> = {
        orderDate: form.orderDate || undefined,
        motherName: form.motherName,
        expectedDeliveryDate: form.expectedDeliveryDate || undefined,
        regionId: form.regionId || undefined,
        hospitalId: form.hospitalId || undefined,
        sampleCollectTime: form.sampleCollectTime || undefined,
        collectorUserId: form.collectorUserId || undefined,
        regionManagerUserId: form.regionManagerUserId || undefined,
        payMode: form.payMode || undefined,
        contractAmount: form.contractAmount ?? undefined,
        orderStatus: form.orderStatus || 'active',
        sampleTypeIds: form.sampleTypeIds,
        remark: form.remark || undefined
      }

      let transfersForSubmit: Array<Record<string, any>> = []

      if (props.orderId) {
        await fetchUpdatePresignOrder(props.orderId, payload)
        for (const tr of form.transfers) {
          if (!tr.id && tr.payStatus === 'dfj') {
            const created: any = await fetchCreatePresignTransfer(props.orderId, {
              transferType: tr.transferType,
              transferAmount: tr.transferAmount,
              transferTime: tr.transferTime || undefined,
              transferAttachments: attachmentsToUrls(tr.attachments) || undefined,
              remark: tr.remark || undefined
            })
            if (created?.id) tr.id = String(created.id)
          } else if (tr.id && tr.payStatus === 'dfj') {
            await fetchUpdatePresignTransfer(String(tr.id), {
              transferType: tr.transferType,
              transferAmount: tr.transferAmount,
              transferTime: tr.transferTime || undefined,
              transferAttachments: attachmentsToUrls(tr.attachments) || undefined,
              remark: tr.remark || undefined
            })
          }
        }
        transfersForSubmit = form.transfers
      } else {
        payload.transfers = form.transfers
          .filter((t) => t.payStatus === 'dfj' || !t.payStatus)
          .map((t) => ({
            transferType: t.transferType,
            transferAmount: t.transferAmount,
            transferTime: t.transferTime || undefined,
            transferAttachments: attachmentsToUrls(t.attachments) || undefined,
            remark: t.remark || undefined
          }))
        const createdOrder: any = await fetchCreatePresignOrder(payload)
        transfersForSubmit = Array.isArray(createdOrder?.transfers)
          ? createdOrder.transfers
          : []
      }

      const failures = await submitDfjTransfersOnSave(transfersForSubmit, (transferId) =>
        fetchSubmitPresignTransferFinance(transferId, { showSuccessMessage: false })
      )
      if (failures.length) {
        ElMessage.warning(formatAutoSubmitFinanceFailureMessage(failures))
      }

      emit('update:modelValue', false)
      emit('saved')

要点:

  • 编辑新建转账必须写回 created.id

  • 自动提交静默成功 toast;失败用 ElMessage.warning,仍关弹窗

  • 手动 submitTransfer 不改

  • Step 3: 静态核对

确认文件内存在:

  • submitDfjTransfersOnSave
  • formatAutoSubmitFinanceFailureMessage
  • fetchSubmitPresignTransferFinance(transferId, { showSuccessMessage: false })
  • if (created?.id) tr.id = String(created.id)

可用:

rg -n "submitDfjTransfersOnSave|showSuccessMessage: false|created\?\.id" web-admin/src/views/cell-storage/presign-order/PresignOrderFormDialog.vue

Expected: 均有命中。

  • Step 4: Commit
git add web-admin/src/views/cell-storage/presign-order/PresignOrderFormDialog.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): auto-submit paid earnest transfers after saving presign order

EOF
)"

Task 5: 回归验证

Files: 无新文件

  • Step 1: 跑移动端相关测试
cd /Users/wuzilong/work/00code/zkxw/mobile-oa-h5 && node --test \
  src/market/presign/presign-form-state.test.js \
  src/market/presign/presign-form-view.test.js \
  src/market/presign/presign-components-contract.test.js

Expected: PASS

  • Step 2: 跑 Web util 测试
cd /Users/wuzilong/work/00code/zkxw/web-admin && node --import tsx --test src/utils/presign-save-auto-submit.test.ts

Expected: PASS

  • Step 3: 手工清单(实现者勾选)

  • Web 新增:带 1 笔完整 dfj 转账保存 → 财务确认列表出现「付款确认中」

  • Web 编辑:新增 1 笔 + 已有 1 笔未提交 dfj → 保存后两笔均为确认中

  • Web:故意空转账方式保存 → 订单保存成功弹窗关闭,并警告部分提交失败

  • 移动端同上三条

  • 手动「提交财务确认」/「撤回」仍可用

  • Step 4: 若手工未做,在 PR/交接说明中注明待验项;有失败则修后重跑 Step 1–2


Spec coverage self-check

Spec 要求 Task
全部 dfj 自动提交 Task 1–4
前端编排、不改后端保存语义 Task 2/4 仅调现有 submit API
Web + 移动 Task 2 + Task 4
部分失败仍关弹窗/回列表 + 文案 Task 1 文案函数 + Task 2/4
新建写回 id Task 2(mergePersisted)+ Task 4(created.id)
静默自动提交 toast Task 3 API 选项 + Task 4
保留手动按钮 Task 4 不改 submitTransfer;移动既有按钮保留
单测 / 契约 Task 1、2、3、5