2026-10-09-protocol-audit-list-mode.md 24 KB

协议单审核(细胞存储)列表模式 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 从卡片改为纯扁平行 el-table(一行一样本,协议字段重复),列对齐客户 Excel。

Architecture: 接口仍按协议分页并返回 sampleItems / paymentSummary;前端用 flattenProtocolAuditRows 展开为样本行;勾选与批量操作按 protocolId 去重。可选在 payment summary 明细中透出 transferTime 供「首期缴费时间」优先展示。

Tech Stack: Vue 3 + TypeScript + Element Plus(web-admin);pnpm exec tsx --test(node:test);后端可选 pytest(qc_work_order_fee_summary)。

Spec: docs/superpowers/specs/2026-10-09-protocol-audit-list-mode-design.md
Demo: docs/demos/2026-10-09-protocol-audit-list-mode-demo.html


File Structure

文件 职责
Create: web-admin/src/utils/protocol-audit-flatten.ts 协议列表 → 样本扁平行;采集日期;是否同存;首期缴费时间文案;协议去重
Create: web-admin/src/utils/protocol-audit-flatten.test.ts flatten / 同存 / 去重 / 首期时间单测
Modify: web-admin/src/views/cell-storage/protocol-audit/ProtocolAuditTab.vue 卡片 → el-table;接线 selection / 操作
Modify: backend/app/services/qc_work_order_fee_summary.py _payment_detail_row 增加 transferTime
Create: backend/tests/test_protocol_payment_summary_transfer_time.py 断言 summary 明细含转账时间
Modify: web-admin/src/api/qc-work-order.ts QcProtocolPaymentDetailSummary.transferTime?

Task 1: flatten 工具(TDD)

Files:

  • Create: web-admin/src/utils/protocol-audit-flatten.test.ts
  • Create: web-admin/src/utils/protocol-audit-flatten.ts

  • [ ] Step 1: 写失败单测

// web-admin/src/utils/protocol-audit-flatten.test.ts
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
  flattenProtocolAuditRows,
  extractCollectDate,
  uniqueProtocolsFromFlatRows,
  firstPayTimeLines
} from './protocol-audit-flatten'

describe('extractCollectDate', () => {
  it('takes YYYY-MM-DD from datetime', () => {
    assert.equal(extractCollectDate('2025-09-15 08:51:00'), '2025-09-15')
    assert.equal(extractCollectDate('2025-09-15T08:51:00'), '2025-09-15')
  })
  it('empty → empty', () => {
    assert.equal(extractCollectDate(''), '')
    assert.equal(extractCollectDate(undefined), '')
  })
})

describe('flattenProtocolAuditRows', () => {
  it('one protocol two samples → two rows, both co-stored', () => {
    const rows = flattenProtocolAuditRows([
      {
        id: 'p1',
        protocolNo: 'ZKZX1',
        motherName: '郭梦朵',
        sampleCollectTime: '2025-09-15 08:51:00',
        sampleItems: [
          { sampleTypeName: '脐带血', sampleCode: 'A1', labStorageCode: 'B0000004' },
          { sampleTypeName: '胎盘', sampleCode: 'A2', labStorageCode: 'B0000004A' }
        ]
      }
    ])
    assert.equal(rows.length, 2)
    assert.equal(rows[0].isCoStored, true)
    assert.equal(rows[1].isCoStored, true)
    assert.equal(rows[0].collectDate, '2025-09-15')
    assert.equal(rows[0].labStorageCode, 'B0000004')
    assert.equal(rows[1].labStorageCode, 'B0000004A')
    assert.equal(rows[0].protocolId, 'p1')
  })

  it('zero samples → one placeholder row, not co-stored', () => {
    const rows = flattenProtocolAuditRows([{ id: 'p2', protocolNo: 'ZKZX2', sampleItems: [] }])
    assert.equal(rows.length, 1)
    assert.equal(rows[0].isCoStored, false)
    assert.equal(rows[0].labStorageCode, '')
  })

  it('one sample → not co-stored', () => {
    const rows = flattenProtocolAuditRows([
      {
        id: 'p3',
        sampleItems: [{ sampleTypeName: '脐带血', sampleCode: 'C1', labStorageCode: 'B7' }]
      }
    ])
    assert.equal(rows.length, 1)
    assert.equal(rows[0].isCoStored, false)
  })
})

describe('uniqueProtocolsFromFlatRows', () => {
  it('dedupes by protocolId keeping first protocol ref', () => {
    const flat = flattenProtocolAuditRows([
      {
        id: 'p1',
        motherName: 'A',
        sampleItems: [
          { sampleCode: '1', labStorageCode: 'B1' },
          { sampleCode: '2', labStorageCode: 'B2' }
        ]
      }
    ])
    const uniq = uniqueProtocolsFromFlatRows(flat)
    assert.equal(uniq.length, 1)
    assert.equal(String(uniq[0].id), 'p1')
  })
})

describe('firstPayTimeLines', () => {
  it('prefers transferTime then dueDate; stacks multiple', () => {
    const lines = firstPayTimeLines({
      paymentDetails: [
        { installmentNo: 1, transferTime: '2025-09-15 09:20:00', installmentDueDate: '2025-09-01' },
        { installmentNo: 2, transferTime: '2025-10-15 10:05:00' }
      ]
    })
    assert.deepEqual(lines, ['2025-09-15 09:20:00', '2025-10-15 10:05:00'])
  })
  it('falls back to dueDate when no transferTime', () => {
    const lines = firstPayTimeLines({
      paymentDetails: [{ installmentNo: 1, installmentDueDate: '2025-09-01' }]
    })
    assert.deepEqual(lines, ['2025-09-01'])
  })
})
  • Step 2: 跑测确认失败

Run:

cd web-admin && pnpm exec tsx --test src/utils/protocol-audit-flatten.test.ts

Expected: FAIL(模块不存在或导出走义)

  • Step 3: 实现工具
// web-admin/src/utils/protocol-audit-flatten.ts
import type { CollectionProtocolRow } from '@/api/collection-protocol'
import type { QcProtocolPaymentSummary } from '@/api/qc-work-order'
import { transportTypeLabel } from '@/utils/status-tag-registry'

export type ProtocolAuditSampleTransport = {
  transportType?: string
  destSiteName?: string
  expressCompanyName?: string
  waybillNo?: string
  carrierName?: string
  carrierPhone?: string
  etaTime?: string
}

export type ProtocolAuditFlatRow = {
  rowKey: string
  protocolId: string
  protocol: CollectionProtocolRow
  sampleCount: number
  sampleIndex: number
  isCoStored: boolean
  collectDate: string
  sampleCollectTime: string
  motherName: string
  protocolNo: string
  regionName: string
  hospitalName: string
  salesUserName: string
  regionManagerUserName: string
  sampleTypeName: string
  sampleCode: string
  labStorageCode: string
  transportType: string
  transportLabel: string
  paymentSummary: QcProtocolPaymentSummary | null
}

function asRecord(v: unknown): Record<string, unknown> | null {
  return v && typeof v === 'object' ? (v as Record<string, unknown>) : null
}

function str(v: unknown): string {
  return v == null ? '' : String(v).trim()
}

export function extractCollectDate(raw?: string | null): string {
  const s = str(raw)
  if (!s) return ''
  const m = s.match(/^(\d{4}-\d{2}-\d{2})/)
  return m ? m[1] : ''
}

function readSampleItems(protocol: CollectionProtocolRow): Record<string, unknown>[] {
  const raw = protocol.sampleItems
  if (Array.isArray(raw) && raw.length) {
    return raw.map((x) => asRecord(x) || {})
  }
  return []
}

export function flattenProtocolAuditRows(
  list: CollectionProtocolRow[]
): ProtocolAuditFlatRow[] {
  const out: ProtocolAuditFlatRow[] = []
  for (const protocol of list || []) {
    const protocolId = str(protocol.id)
    const items = readSampleItems(protocol)
    const sampleCount = items.length
    const isCoStored = sampleCount >= 2
    const slots = sampleCount > 0 ? items : [null]
    slots.forEach((item, sampleIndex) => {
      const rec = item || {}
      const transport = asRecord(rec.transport)
      const transportType = str(transport?.transportType)
      out.push({
        rowKey: `${protocolId}|${str(rec.sampleCode) || sampleIndex}`,
        protocolId,
        protocol,
        sampleCount,
        sampleIndex,
        isCoStored,
        collectDate: extractCollectDate(str(protocol.sampleCollectTime)),
        sampleCollectTime: str(protocol.sampleCollectTime),
        motherName: str(protocol.motherName),
        protocolNo: str(protocol.protocolNo),
        regionName: str(protocol.regionName),
        hospitalName: str(protocol.hospitalName),
        salesUserName: str(protocol.salesUserName),
        regionManagerUserName: str(protocol.regionManagerUserName),
        sampleTypeName: str(rec.sampleTypeName),
        sampleCode: str(rec.sampleCode),
        labStorageCode: str(rec.labStorageCode),
        transportType,
        transportLabel: transportType ? transportTypeLabel(transportType) : '',
        paymentSummary:
          protocol.paymentSummary && typeof protocol.paymentSummary === 'object'
            ? (protocol.paymentSummary as QcProtocolPaymentSummary)
            : null
      })
    })
  }
  return out
}

export function uniqueProtocolsFromFlatRows(
  rows: ProtocolAuditFlatRow[]
): CollectionProtocolRow[] {
  const seen = new Set<string>()
  const out: CollectionProtocolRow[] = []
  for (const r of rows) {
    if (!r.protocolId || seen.has(r.protocolId)) continue
    seen.add(r.protocolId)
    out.push(r.protocol)
  }
  return out
}

export function firstPayTimeLines(
  summary: QcProtocolPaymentSummary | null | undefined
): string[] {
  const details = summary?.paymentDetails
  if (!Array.isArray(details) || !details.length) return []
  const sorted = [...details].sort((a, b) => {
    const na = a.installmentNo == null ? 10_000 : Number(a.installmentNo)
    const nb = b.installmentNo == null ? 10_000 : Number(b.installmentNo)
    return na - nb
  })
  const lines: string[] = []
  for (const d of sorted) {
    const t = str((d as { transferTime?: string }).transferTime)
    const due = str(d.installmentDueDate)
    const pick = t || due
    if (pick) lines.push(pick)
  }
  return lines
}

(transportTypeLabel 可选用于列展示;若不想在 util 依赖 registry,可只存 transportType,标签在 Vue 里调。)

  • Step 4: 跑测确认通过
cd web-admin && pnpm exec tsx --test src/utils/protocol-audit-flatten.test.ts

Expected: PASS

  • Step 5: Commit
git add web-admin/src/utils/protocol-audit-flatten.ts web-admin/src/utils/protocol-audit-flatten.test.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add protocol-audit sample-row flatten helpers.

Support Excel-aligned list mode for cell-storage protocol audit.
EOF
)"

Task 2: payment summary 透出 transferTime

Files:

  • Create: backend/tests/test_protocol_payment_summary_transfer_time.py
  • Modify: backend/app/services/qc_work_order_fee_summary.py(_payment_detail_row)
  • Modify: web-admin/src/api/qc-work-order.ts(类型)

  • [ ] Step 1: 写失败单测

# backend/tests/test_protocol_payment_summary_transfer_time.py
"""payment summary detail includes transferTime"""
from datetime import datetime
from types import SimpleNamespace
import unittest

from app.services.qc_work_order_fee_summary import build_protocol_payment_summary


class TestPaymentSummaryTransferTime(unittest.TestCase):
    def test_detail_includes_transfer_time(self):
        pay = SimpleNamespace(pay_mode="fq", contract_amount=10000)
        detail = SimpleNamespace(
            installment_no=1,
            order_num=1,
            create_time=datetime(2025, 9, 1, 10, 0, 0),
            installment_due_date="20250901",
            due_amount=5000,
            transfer_amount=5000,
            pay_status="yjf",
            transfer_time=datetime(2025, 9, 15, 9, 20, 0),
        )
        summary = build_protocol_payment_summary(pay, [detail])
        self.assertIsNotNone(summary)
        self.assertEqual(summary["paymentDetails"][0]["transferTime"], "2025-09-15 09:20:00")


if __name__ == "__main__":
    unittest.main()

(若项目中 datetime 格式函数与上不同,实现时与 _fmt_dt / 邻近服务一致,并改断言。)

  • Step 2: 跑测确认失败
cd backend && source venv/bin/activate && pytest tests/test_protocol_payment_summary_transfer_time.py -v

Expected: FAIL(无 transferTime key)

  • Step 3: 实现 _payment_detail_row 增加字段

在 backend/app/services/qc_work_order_fee_summary.py 的 _payment_detail_row 返回值中增加:

"transferTime": _fmt_dt(getattr(d, "transfer_time", None)),

若文件内无 _fmt_dt,复用同文件已有日期格式化,或:

def _fmt_dt(v):
    if v is None:
        return ""
    if isinstance(v, datetime):
        return v.strftime("%Y-%m-%d %H:%M:%S")
    s = str(v).strip()
    return s

并确保文件顶部 from datetime import datetime(若尚未导入)。

  • Step 4: 前端类型

在 web-admin/src/api/qc-work-order.ts 的 QcProtocolPaymentDetailSummary 增加:

transferTime?: string
  • Step 5: 跑测确认通过
cd backend && source venv/bin/activate && pytest tests/test_protocol_payment_summary_transfer_time.py -v

Expected: PASS

  • Step 6: Commit
git add backend/app/services/qc_work_order_fee_summary.py \
  backend/tests/test_protocol_payment_summary_transfer_time.py \
  web-admin/src/api/qc-work-order.ts
git commit -m "$(cat <<'EOF'
feat(backend): expose payment detail transferTime in protocol summary.

Let protocol-audit list show first-pay times from transfer records.
EOF
)"

Task 3: ProtocolAuditTab 改为 el-table

Files:

  • Modify: web-admin/src/views/cell-storage/protocol-audit/ProtocolAuditTab.vue

  • [ ] Step 1: 替换 list-body 内卡片为表格

删除 .protocol-card-section 整块卡片模板,改为(保留外层 list-body list-wrap 与分页):

<div class="list-body list-wrap">
  <el-table
    ref="tableRef"
    class="list-table"
    v-loading="loading"
    :data="flatRows"
    row-key="rowKey"
    stripe
    @selection-change="handleSelectionChange"
    @row-dblclick="(row) => handleView(row.protocol)"
  >
    <el-table-column type="selection" min-width="48" align="center" header-align="center" />
    <el-table-column label="序号" min-width="64" align="center" header-align="center">
      <template #default="{ $index }">{{ $index + 1 }}</template>
    </el-table-column>
    <el-table-column prop="collectDate" label="采集日期" min-width="110" align="center" header-align="center">
      <template #default="{ row }">{{ row.collectDate || '—' }}</template>
    </el-table-column>
    <el-table-column prop="labStorageCode" label="实验室流转编号" min-width="130" align="center" header-align="center" show-overflow-tooltip>
      <template #default="{ row }">
        <span class="mono">{{ row.labStorageCode || '—' }}</span>
      </template>
    </el-table-column>
    <el-table-column prop="motherName" label="母亲姓名" min-width="100" align="center" header-align="center" show-overflow-tooltip>
      <template #default="{ row }">{{ row.motherName || '—' }}</template>
    </el-table-column>
    <el-table-column prop="protocolNo" label="协议编号" min-width="140" align="center" header-align="center" show-overflow-tooltip>
      <template #default="{ row }">
        <span class="mono">{{ row.protocolNo || '—' }}</span>
      </template>
    </el-table-column>
    <el-table-column prop="sampleTypeName" label="样本类型" min-width="140" align="center" header-align="center" show-overflow-tooltip>
      <template #default="{ row }">{{ row.sampleTypeName || '—' }}</template>
    </el-table-column>
    <el-table-column label="是否同存" min-width="90" align="center" header-align="center">
      <template #default="{ row }">
        <el-tag :type="row.isCoStored ? 'warning' : 'info'" size="small">
          {{ row.isCoStored ? '是' : '否' }}
        </el-tag>
      </template>
    </el-table-column>
    <el-table-column prop="regionName" label="采集地区" min-width="100" align="center" header-align="center" show-overflow-tooltip>
      <template #default="{ row }">{{ row.regionName || '—' }}</template>
    </el-table-column>
    <el-table-column prop="hospitalName" label="采集医院" min-width="140" align="center" header-align="center" show-overflow-tooltip>
      <template #default="{ row }">{{ row.hospitalName || '—' }}</template>
    </el-table-column>
    <el-table-column prop="sampleCollectTime" label="采集时间" min-width="160" align="center" header-align="center">
      <template #default="{ row }">{{ formatDisplayOrDash(row.sampleCollectTime) }}</template>
    </el-table-column>
    <el-table-column label="缴费方式" min-width="90" align="center" header-align="center">
      <template #default="{ row }">
        <el-tag
          v-if="row.paymentSummary?.payMode"
          :type="payModeTagType(String(row.paymentSummary.payMode))"
          size="small"
        >
          {{
            row.paymentSummary.payModeLabel ||
            payModeLabel(String(row.paymentSummary.payMode)) ||
            '—'
          }}
        </el-tag>
        <span v-else class="muted">—</span>
      </template>
    </el-table-column>
    <el-table-column label="首期缴费时间" min-width="160" align="center" header-align="center">
      <template #default="{ row }">
        <div v-if="firstPayTimeLines(row.paymentSummary).length" class="pay-time-stack">
          <div v-for="(t, i) in firstPayTimeLines(row.paymentSummary)" :key="i">
            {{ formatDisplayOrDash(t) }}
          </div>
        </div>
        <span v-else class="muted">—</span>
      </template>
    </el-table-column>
    <el-table-column prop="salesUserName" label="市场专员" min-width="100" align="center" header-align="center" show-overflow-tooltip>
      <template #default="{ row }">{{ row.salesUserName || '—' }}</template>
    </el-table-column>
    <el-table-column prop="regionManagerUserName" label="地区经理" min-width="100" align="center" header-align="center" show-overflow-tooltip>
      <template #default="{ row }">{{ row.regionManagerUserName || '—' }}</template>
    </el-table-column>
    <el-table-column prop="transportLabel" label="运输方式" min-width="100" align="center" header-align="center">
      <template #default="{ row }">{{ row.transportLabel || '—' }}</template>
    </el-table-column>
    <el-table-column label="操作" min-width="280" fixed="right" align="center" header-align="center">
      <template #default="{ row }">
        <div class="op-row">
          <el-button class="btn-op-item" @click.stop="openAuditDialog(row.protocol)">
            <el-icon><CircleCheck /></el-icon>审核
          </el-button>
          <el-button class="btn-op-item" @click.stop="handleView(row.protocol)">
            <el-icon><View /></el-icon>查看
          </el-button>
          <el-button class="btn-op-item" @click.stop="handleEdit(row.protocol)">
            <el-icon><Edit /></el-icon>编辑
          </el-button>
          <el-button class="btn-op-item" @click.stop="handleDelete(row.protocol)">
            <el-icon><Delete /></el-icon>删除
          </el-button>
        </div>
      </template>
    </el-table-column>
  </el-table>
  <!-- 保留原 pagination 块 -->
</div>

注意:firstPayTimeLines(row.paymentSummary) 在模板中会调用两次——应用 computed 缓存或模板内局部变量;实现时改为:

<template #default="{ row }">
  <template v-if="payLines(row).length">
    <div class="pay-time-stack">
      <div v-for="(t, i) in payLines(row)" :key="i">{{ formatDisplayOrDash(t) }}</div>
    </div>
  </template>
  <span v-else class="muted">—</span>
</template>

其中 const payLines = (row: ProtocolAuditFlatRow) => firstPayTimeLines(row.paymentSummary)。

  • Step 2: script 接线
  1. 增加 import:
import {
  flattenProtocolAuditRows,
  uniqueProtocolsFromFlatRows,
  firstPayTimeLines,
  type ProtocolAuditFlatRow
} from '@/utils/protocol-audit-flatten'
import { payModeLabel, payModeTagType } from '@/utils/status-tag-registry'

(合并进现有 status-tag-registry import,去掉卡片专用的 sample/reception/qc 标签 import,若操作列不再使用。)

  1. 增加:
const flatRows = computed(() => flattenProtocolAuditRows(tableData.value))
const selectedFlatRows = ref<ProtocolAuditFlatRow[]>([])

const selectedRows = computed({
  get: () => uniqueProtocolsFromFlatRows(selectedFlatRows.value),
  set: () => {
    /* selection 仅由 handleSelectionChange 驱动 */
  }
})

function handleSelectionChange(rows: ProtocolAuditFlatRow[]) {
  selectedFlatRows.value = rows || []
}

因现有代码大量使用 selectedRows 为 ref<CollectionProtocolRow[]>,更稳妥的最小改法:

const selectedFlatRows = ref<ProtocolAuditFlatRow[]>([])
const selectedProtocolCount = computed(
  () => uniqueProtocolsFromFlatRows(selectedFlatRows.value).length
)

function selectedProtocols(): CollectionProtocolRow[] {
  return uniqueProtocolsFromFlatRows(selectedFlatRows.value)
}

function handleSelectionChange(rows: ProtocolAuditFlatRow[]) {
  selectedFlatRows.value = rows || []
}

然后:

  • 工具栏「已选 N 项」→ selectedProtocolCount
  • openRefundDialog / openAuditDialog() / openStatusChangeDialog / handleBatchDelete 中凡读 selectedRows.value 改为 selectedProtocols()
  • loadData 成功后:selectedFlatRows.value = [](或按仍在当前页的 protocolId 过滤 flat 选中)
  • 删除卡片用的 isRowSelected / toggleRowSelection / getSampleItems / getPaymentSummary / formatTransportSummary(若表格不再需要)
  • 删除卡片相关大段 scoped SCSS(.protocol-card*),保留少量:
.mono {
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  font-size: 12px;
}
.muted { color: var(--el-text-color-secondary); }
.pay-time-stack {
  display: flex;
  flex-direction: column;
  gap: 2px;
  line-height: 1.35;
}
.op-row {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 4px;
  justify-content: center;
}
  • Step 3: 本地目视验收清单

打开 http://localhost:3006/#/market/cell-storage/protocol-audit?tab=cell_storage:

  1. 无卡片,为表格;列含采集日期 / 流转编号 / 是否同存等。
  2. 多样本协议占多行,是否同存均为「是」。
  3. 勾选同协议两行 → 已选 1 项;批量审核目标为 1 个协议。
  4. 双击行 / 查看 / 编辑 / 审核弹窗正常。
  5. 分页 total 仍为协议总数。
  • Step 4: Commit
git add web-admin/src/views/cell-storage/protocol-audit/ProtocolAuditTab.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): switch cell-storage protocol audit to flat sample table.

Replace card list with Excel-aligned columns and protocol-deduped selection.
EOF
)"

Task 4: 图谱与收尾

Files:

  • 运行:./scripts/update-graph.sh frontend(若改了 backend 则 all)

  • [ ] Step 1: 更新 graphify

cd /Users/wuzilong/work/00code/zkxw && ./scripts/update-graph.sh all
  • Step 2: 再跑前端 flatten 单测 + 后端 transferTime 单测
cd web-admin && pnpm exec tsx --test src/utils/protocol-audit-flatten.test.ts
cd backend && source venv/bin/activate && pytest tests/test_protocol_payment_summary_transfer_time.py -v

Expected: 全部 PASS

  • Step 3: Commit 图谱(若有变更)
git add backend/graphify-out web-admin/graphify-out
git commit -m "$(cat <<'EOF'
chore: refresh graphify after protocol-audit list mode.
EOF
)"

Spec coverage(自检)

规格要求 任务
纯扁平行、无 rowspan Task 1 + 3
采集日期合并年/月/日 Task 1 extractCollectDate + Task 3 列
是否同存 ≥2 Task 1
无样本仍 1 行 Task 1
分页按协议 Task 3(pagination.total 不变)
勾选按协议去重 Task 1 uniqueProtocolsFromFlatRows + Task 3
列对齐 Excel + 操作列 Task 3
首期缴费时间 Task 2 + Task 1 firstPayTimeLines + Task 3
仅细胞存储 Tab Task 3 仅改 ProtocolAuditTab.vue
筛选/弹窗不改业务 Task 3 复用现有 dialog 函数

执行说明

实现时勿改 DonationProtocolTab.vue。若 Task 2 因 SimpleNamespace 与真实模型字段不一致导致单测难写,可改为直接单测 _payment_detail_row(若导出)或构造最小 ORM mock;保持「返回值含 transferTime」断言即可。