# 协议单审核(细胞存储)列表模式 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: 写失败单测** ```ts // 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: ```bash cd web-admin && pnpm exec tsx --test src/utils/protocol-audit-flatten.test.ts ``` Expected: FAIL(模块不存在或导出走义) - [ ] **Step 3: 实现工具** ```ts // 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 | null { return v && typeof v === 'object' ? (v as Record) : 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[] { 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() 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: 跑测确认通过** ```bash cd web-admin && pnpm exec tsx --test src/utils/protocol-audit-flatten.test.ts ``` Expected: PASS - [ ] **Step 5: Commit** ```bash 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: 写失败单测** ```python # 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: 跑测确认失败** ```bash 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` 返回值中增加: ```python "transferTime": _fmt_dt(getattr(d, "transfer_time", None)), ``` 若文件内无 `_fmt_dt`,复用同文件已有日期格式化,或: ```python 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` 增加: ```ts transferTime?: string ``` - [ ] **Step 5: 跑测确认通过** ```bash cd backend && source venv/bin/activate && pytest tests/test_protocol_payment_summary_transfer_time.py -v ``` Expected: PASS - [ ] **Step 6: Commit** ```bash 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` 与分页): ```vue
``` 注意:`firstPayTimeLines(row.paymentSummary)` 在模板中会调用两次——应用 computed 缓存或模板内局部变量;实现时改为: ```vue ``` 其中 `const payLines = (row: ProtocolAuditFlatRow) => firstPayTimeLines(row.paymentSummary)`。 - [ ] **Step 2: script 接线** 1. 增加 import: ```ts 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,若操作列不再使用。) 2. 增加: ```ts const flatRows = computed(() => flattenProtocolAuditRows(tableData.value)) const selectedFlatRows = ref([]) const selectedRows = computed({ get: () => uniqueProtocolsFromFlatRows(selectedFlatRows.value), set: () => { /* selection 仅由 handleSelectionChange 驱动 */ } }) function handleSelectionChange(rows: ProtocolAuditFlatRow[]) { selectedFlatRows.value = rows || [] } ``` 因现有代码大量使用 `selectedRows` 为 `ref`,更稳妥的最小改法: ```ts const selectedFlatRows = ref([]) 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*`),保留少量: ```scss .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** ```bash 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** ```bash cd /Users/wuzilong/work/00code/zkxw && ./scripts/update-graph.sh all ``` - [ ] **Step 2: 再跑前端 flatten 单测 + 后端 transferTime 单测** ```bash 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 图谱(若有变更)** ```bash 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」断言即可。