docs: add protocol-audit list-mode implementation plan.

Break work into flatten TDD, payment transferTime, and ProtocolAuditTab table swap.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-09 09:49:47 +08:00
1 parent 8f00db9400
commit 4904508d9f
1 file changed
+706
@@ -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<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: 跑测确认通过**
```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
<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 缓存或模板内局部变量;实现时改为:
```vue
<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:
```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<ProtocolAuditFlatRow[]>([])
const selectedRows = computed({
get: () => uniqueProtocolsFromFlatRows(selectedFlatRows.value),
set: () => {
/* selection 仅由 handleSelectionChange 驱动 */
}
})
function handleSelectionChange(rows: ProtocolAuditFlatRow[]) {
selectedFlatRows.value = rows || []
}
```
因现有代码大量使用 `selectedRows` 为 `ref<CollectionProtocolRow[]>`,更稳妥的最小改法:
```ts
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*`),保留少量:
```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」断言即可。