From 4904508d9ffc5a08816e73960999270c9cca3a95 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 9 Oct 2026 09:49:47 +0800 Subject: [PATCH] docs: add protocol-audit list-mode implementation plan. Break work into flatten TDD, payment transferTime, and ProtocolAuditTab table swap. Co-authored-by: Cursor --- .../2026-10-09-protocol-audit-list-mode.md | 706 ++++++++++++++++++ 1 file changed, 706 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-09-protocol-audit-list-mode.md diff --git a/docs/superpowers/plans/2026-10-09-protocol-audit-list-mode.md b/docs/superpowers/plans/2026-10-09-protocol-audit-list-mode.md new file mode 100644 index 00000000..990eb533 --- /dev/null +++ b/docs/superpowers/plans/2026-10-09-protocol-audit-list-mode.md @@ -0,0 +1,706 @@ +# 协议单审核(细胞存储)列表模式 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」断言即可。