diff --git a/docs/superpowers/plans/2026-10-13-research-donation-list-excel-parity.md b/docs/superpowers/plans/2026-10-13-research-donation-list-excel-parity.md new file mode 100644 index 00000000..6466a82d --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-research-donation-list-excel-parity.md @@ -0,0 +1,646 @@ +# 科研捐赠列表对齐 Excel「科研表」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:** Web / 小程序「科研捐赠」列表字段对齐 Excel「科研表」,并在创建时生成当月核量号(`YYYYMM###`);历史不回填。 + +**Architecture:** `biz_research_donation.monthly_verify_no` 落库;创建与「转科研」建单路径统一取号;Web 列表经 flatten 展列;小程序 `protocolCard` 对 `research_donation` 使用独立字段集。附件有无由前端对 URL 字段判空。 + +**Tech Stack:** FastAPI / SQLAlchemy / pytest、Vue 3 + Element Plus(web-admin)、mobile-oa-h5(node:test) + +**Spec:** `docs/superpowers/specs/2026-10-13-research-donation-list-excel-parity-design.md` + +--- + +## File map + +| 文件 | 职责 | +|------|------| +| `docs/sql/migrations/2026-10-13-research-donation-monthly-verify-no.sql` | 加列 + 唯一索引 | +| `backend/app/models/research_donation.py` | ORM 字段 | +| `backend/app/crud/research_donation.py` | 取号、create / 转科研写号、list 补字段、`_donation_to_dict` | +| `backend/tests/test_research_donation_monthly_verify_no.py` | 取号与创建单测(可 mock Session) | +| `backend/app/services/market_protocol.py` | 小程序列表补齐科研字段 | +| `web-admin/src/utils/protocol-audit-flatten.ts` | flatten 增加科研列表列字段 | +| `web-admin/src/utils/protocol-audit-flatten.test.ts` | flatten 单测 | +| `web-admin/src/views/cell-storage/protocol-audit/DonationProtocolTab.vue` | 表格列替换 | +| `mobile-oa-h5/src/market/list-presenter.js` | 科研捐赠卡片字段 | +| `mobile-oa-h5/src/market/list-presenter.test.js` | 卡片契约测试 | + +--- + +### Task 1: DB 迁移 + ORM 字段 + +**Files:** +- Create: `docs/sql/migrations/2026-10-13-research-donation-monthly-verify-no.sql` +- Modify: `backend/app/models/research_donation.py` + +- [ ] **Step 1: 写迁移 SQL** + +```sql +-- 科研捐赠:当月核量号(创建时按 create_time 年月生成 YYYYMM###;历史可空) +-- Spec: docs/superpowers/specs/2026-10-13-research-donation-list-excel-parity-design.md + +SET NAMES utf8mb4; + +ALTER TABLE `biz_research_donation` + ADD COLUMN `monthly_verify_no` varchar(16) DEFAULT NULL COMMENT '当月核量号 YYYYMM+3位序号' AFTER `donation_no`; + +-- 租户内号唯一;多行 NULL 在 MySQL 下不冲突 +ALTER TABLE `biz_research_donation` + ADD UNIQUE KEY `uk_rd_tenant_monthly_verify_no` (`tenant_no`, `monthly_verify_no`); +``` + +- [ ] **Step 2: ORM 增加列** + +在 `BizResearchDonation` 中 `donation_no` 后增加: + +```python +monthly_verify_no = Column(String(16), nullable=True, comment="当月核量号 YYYYMM+3位序号") +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/sql/migrations/2026-10-13-research-donation-monthly-verify-no.sql \ + backend/app/models/research_donation.py +git commit -m "$(cat <<'EOF' +feat(db): add research donation monthly_verify_no column + +EOF +)" +``` + +--- + +### Task 2: 取号函数(TDD) + +**Files:** +- Create: `backend/tests/test_research_donation_monthly_verify_no.py` +- Modify: `backend/app/crud/research_donation.py` + +- [ ] **Step 1: 写失败测试** + +```python +"""当月核量号:按租户+创建年月自增,含软删占用。""" +from datetime import datetime +from unittest.mock import MagicMock + +import pytest + +from app.crud import research_donation as crud + + +def test_allocate_monthly_verify_no_first_of_month(): + db = MagicMock() + db.query.return_value.filter.return_value.order_by.return_value.first.return_value = (None,) + no = crud.allocate_monthly_verify_no( + db, tenant_no="zkxw", create_time=datetime(2025, 8, 15, 10, 0, 0) + ) + assert no == "202508001" + + +def test_allocate_monthly_verify_no_increments(): + db = MagicMock() + db.query.return_value.filter.return_value.order_by.return_value.first.return_value = ( + "202508007", + ) + no = crud.allocate_monthly_verify_no( + db, tenant_no="zkxw", create_time=datetime(2025, 8, 20, 12, 0, 0) + ) + assert no == "202508008" + + +def test_allocate_monthly_verify_no_new_month_resets(): + db = MagicMock() + db.query.return_value.filter.return_value.order_by.return_value.first.return_value = (None,) + no = crud.allocate_monthly_verify_no( + db, tenant_no="zkxw", create_time=datetime(2025, 9, 1, 9, 0, 0) + ) + assert no == "202509001" +``` + +> 实现若用 `db.execute(text(...))` 查 MAX,则测试改为 mock `execute` / `scalar`,断言仍保持上述期望值。 + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_research_donation_monthly_verify_no.py -v +``` + +Expected: FAIL(`allocate_monthly_verify_no` 不存在) + +- [ ] **Step 3: 实现取号** + +在 `research_donation.py` 增加(查询须**不排除** `del_flag=1`): + +```python +def allocate_monthly_verify_no( + db: Session, + *, + tenant_no: str, + create_time: datetime, +) -> str: + """按租户 + create_time 年月分配 YYYYMM###;含软删占用,不回收。""" + ym = create_time.strftime("%Y%m") + prefix_len = len(ym) + row = ( + db.query(BizResearchDonation.monthly_verify_no) + .filter( + BizResearchDonation.tenant_no == tenant_no, + BizResearchDonation.monthly_verify_no.isnot(None), + BizResearchDonation.monthly_verify_no.like(f"{ym}%"), + ) + .order_by(BizResearchDonation.monthly_verify_no.desc()) + .first() + ) + max_no = (row[0] if row else None) or "" + seq = 0 + if max_no.startswith(ym) and len(max_no) >= prefix_len + 3: + try: + seq = int(max_no[prefix_len : prefix_len + 3]) + except ValueError: + seq = 0 + nxt = seq + 1 + if nxt > 999: + raise ValueError(f"当月核量号已用尽({ym})") + return f"{ym}{nxt:03d}" +``` + +- [ ] **Step 4: 跑测试确认通过** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_research_donation_monthly_verify_no.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/crud/research_donation.py backend/tests/test_research_donation_monthly_verify_no.py +git commit -m "$(cat <<'EOF' +feat(backend): allocate research donation monthly verify numbers + +EOF +)" +``` + +--- + +### Task 3: 创建与转科研写号 + 序列化 + +**Files:** +- Modify: `backend/app/crud/research_donation.py`(`create_donation`、转科研建主表处、`_donation_to_dict`、`list_donations` 展平 party/collection) + +- [ ] **Step 1: `_donation_to_dict` 增加字段** + +```python +"monthlyVerifyNo": d.monthly_verify_no or "", +``` + +放在 `donationNo` 附近。 + +- [ ] **Step 2: `create_donation` 写号** + +在构造 `BizResearchDonation(...)` 前: + +```python +monthly_verify_no = allocate_monthly_verify_no( + db, tenant_no=tenant_no, create_time=now +) +``` + +并传入 `monthly_verify_no=monthly_verify_no`。唯一约束冲突时可在循环内重试最多 3 次(可选,最小实现可先不加)。 + +- [ ] **Step 3: 转科研建单写号** + +`convert_protocol_to_donation`(或同文件内直接 `BizResearchDonation(...)` 的 `source_type="from_storage"` 路径,约 1607 行)同样在 `create_time=now` 后调用 `allocate_monthly_verify_no` 并写入。 + +- [ ] **Step 4: `list_donations` 补齐列表缺字段** + +在 party 展平处增加: + +```python +d["birthOrder"] = pc.get("birthOrder") +``` + +无 party 时 `d["birthOrder"] = None`。 + +在 collection 展平处增加: + +```python +d["maternalBloodCollectTime"] = ci.get("maternalBloodCollectTime", "") +``` + +无 collection 时置 `""`。 + +(`voidDesc` / 三类附件 URL 已在 `_donation_to_dict`;确认 list 走该序列化即可。) + +- [ ] **Step 5: 补充集成向测试(可选轻量)** + +在同测试文件增加对「create 路径会调用 allocate」的 mock 断言,或文档化手动跑迁移后冒烟;至少保证 Task 2 单测仍绿。 + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/crud/research_donation.py backend/tests/test_research_donation_monthly_verify_no.py +git commit -m "$(cat <<'EOF' +feat(backend): persist monthly verify no on donation create and convert + +EOF +)" +``` + +--- + +### Task 4: 小程序列表 API 字段补齐 + +**Files:** +- Modify: `backend/app/services/market_protocol.py`(`_enrich_protocol_list_items` 或同类循环 `item.update(...)` 处) +- Test: `backend/tests/test_market_protocol_api.py`(若有科研列表断言则扩展;否则新增断言字段存在) + +- [ ] **Step 1: 在 list enrich 中为每条 item 增加** + +对科研捐赠主表字段(从 query 到的 `BizResearchDonation` row 或已有 item): + +```python +monthlyVerifyNo=getattr(row, 'monthly_verify_no', '') or item.get('monthlyVerifyNo', ''), +voidDesc=getattr(row, 'void_desc', '') or '', +consentFormUrls=getattr(row, 'consent_form_urls', '') or '', +infectiousDiseaseReportUrls=getattr(row, 'infectious_disease_report_urls', '') or '', +attachmentUrls=getattr(row, 'attachment_urls', '') or '', +remark=getattr(row, 'remark', '') or item.get('remark', ''), +birthOrder=getattr(party, 'birth_order', None), +maternalBloodCollectTime=_fmt_dt(getattr(collection, 'maternal_blood_collect_time', None)), +newbornDeliveryTime=_fmt_dt(getattr(party, 'newborn_delivery_time', None)), +regionManagerUserName=user_names.get( + getattr(collection, 'region_manager_user_id', ''), '' +), +``` + +说明:现网已有部分字段(`motherName`、`labStorageCode`、`transportMode` 等);本步只补缺口。`managerName` 已存在时可同时保证 `regionManagerUserName` 同值,方便前端统一读。 + +- [ ] **Step 2: 跑市场协议相关测试** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_market_protocol_api.py -v --tb=short +``` + +Expected: PASS(或按新增断言修复) + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/services/market_protocol.py backend/tests/test_market_protocol_api.py +git commit -m "$(cat <<'EOF' +feat(backend): expose research donation list fields for market mobile + +EOF +)" +``` + +--- + +### Task 5: Web flatten 扩展(TDD) + +**Files:** +- Modify: `web-admin/src/utils/protocol-audit-flatten.ts` +- Modify: `web-admin/src/utils/protocol-audit-flatten.test.ts` + +- [ ] **Step 1: 扩展 `ProtocolAuditFlatRow` 类型与 flatten 输出** + +新增字段(从 `protocol` 读取): + +```typescript +monthlyVerifyNo: string +donationStatus: string +voidDesc: string +newbornDeliveryTime: string +newbornSex: string +newbornWeightGram: string +deliveryMode: string +birthOrder: string +maternalBloodCollectTime: string +consentFormUrls: string +infectiousDiseaseReportUrls: string +attachmentUrls: string +remark: string +``` + +在 `out.push({...})` 中赋值,例如: + +```typescript +monthlyVerifyNo: str(protocol.monthlyVerifyNo), +donationStatus: str(protocol.donationStatus), +voidDesc: str(protocol.voidDesc), +newbornDeliveryTime: str(protocol.newbornDeliveryTime), +newbornSex: str(protocol.newbornSex), +newbornWeightGram: + protocol.newbornWeightGram == null || protocol.newbornWeightGram === '' + ? '' + : String(protocol.newbornWeightGram), +deliveryMode: str(protocol.deliveryMode), +birthOrder: + protocol.birthOrder == null || protocol.birthOrder === '' + ? '' + : String(protocol.birthOrder), +maternalBloodCollectTime: str(protocol.maternalBloodCollectTime), +consentFormUrls: str(protocol.consentFormUrls), +infectiousDiseaseReportUrls: str(protocol.infectiousDiseaseReportUrls), +attachmentUrls: str(protocol.attachmentUrls), +remark: str(protocol.remark), +``` + +- [ ] **Step 2: 单测断言新字段从 protocol 透传** + +在 `protocol-audit-flatten.test.ts` 增加用例:输入含 `monthlyVerifyNo`、`consentFormUrls` 等的捐赠行,断言 flat 行对应字段。 + +- [ ] **Step 3: 跑测试** + +```bash +cd web-admin && pnpm exec vitest run src/utils/protocol-audit-flatten.test.ts +``` + +(若项目用其他 runner,以该文件现有跑法为准,例如 `node --import tsx` / `pnpm test`。) + +Expected: PASS + +- [ ] **Step 4: 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): flatten research donation excel-parity list fields + +EOF +)" +``` + +--- + +### Task 6: Web `DonationProtocolTab` 列对齐 + +**Files:** +- Modify: `web-admin/src/views/cell-storage/protocol-audit/DonationProtocolTab.vue` + +- [ ] **Step 1: 调整表格列** + +按 spec §4.1 顺序替换列(去掉「是否同存」): + +1. 选择、序号、采集日期(已有) +2. **当月核量号** → `row.monthlyVerifyNo`,monospace,空「—」 +3. 实验室流转编号(已有) +4. **协议状态** → `donationStatusDisplayLabel` + `el-tag`(从 `asDonation(row)` 或 `row.donationStatus`;复用 `research-donation/list` 或现有 status 工具) +5. **废弃原因** → `row.voidDesc || '—'` +6. 母亲姓名、样本类型(已有;去掉是否同存列) +7. 采集地区、采集医院(已有) +8. **分娩时间** → `formatDisplayOrDash(row.newbornDeliveryTime)` +9. 采集时间、市场专员、地区经理、运输方式(已有) +10. **新生儿性别** → `newbornSexLabel(row.newbornSex)`(从 `@/utils/...` 或与 `research-donation/list/index.vue` 相同 import) +11. **新生儿体重** → 有值显示数字,空「—」 +12. **分娩方式** → `deliveryModeLabel` +13. **胎次** → `row.birthOrder || '—'` +14. **母血采集时间** → `formatDisplayOrDash(row.maternalBloodCollectTime)` +15. **知情同意书 / 传染病检测报告 / 协议书** → 非空 URL 显示「有」,否则「—」 + +辅助函数: + +```typescript +function hasAttachmentUrls(raw: string | undefined | null): boolean { + return Boolean(String(raw || '').trim()) +} +``` + +模板示例: + +```vue +{{ hasAttachmentUrls(row.consentFormUrls) ? '有' : '—' }} +``` + +16. **备注** → `row.remark || '—'`,可 `show-overflow-tooltip` +17. 操作列(已有,`min-width` ≥ 260) + +- [ ] **Step 2: 本地目视** + +打开 `/#/market/cell-storage/protocol-audit?tab=research_donation`,确认列顺序、无运费/是否同存、横向可滚。 + +- [ ] **Step 3: Commit** + +```bash +git add web-admin/src/views/cell-storage/protocol-audit/DonationProtocolTab.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): align research donation audit list with excel columns + +EOF +)" +``` + +--- + +### Task 7: 小程序科研捐赠列表卡片 + +**Files:** +- Modify: `mobile-oa-h5/src/market/list-presenter.js` +- Modify: `mobile-oa-h5/src/market/list-presenter.test.js` + +- [ ] **Step 1: 写失败测试** + +在 `list-presenter.test.js`: + +```javascript +test('research donation protocol card matches excel-parity field set', () => { + const card = buildMarketCard('protocol', 'specialist', { + source: 'research_donation', + motherName: '曹陆双', + sampleCode: 'S-D1', + donationStatus: 'sdfq', + sampleCollectTime: '2025-08-29 12:27:00', + monthlyVerifyNo: '202508001', + labStorageCode: 'A0000001', + voidDesc: '母血乙肝阳性', + sampleTypeName: '围产期造血干细胞', + regionName: '亳州', + hospitalName: '涡阳县人民医院', + newbornDeliveryTime: '2025-08-29 12:26:00', + salesUserName: '杨莉莉', + regionManagerUserName: '张颖', + managerName: '张颖', + transportMode: 'kdys', + transportModeLabel: '快递运输', + newbornSex: '0', + newbornWeightGram: 3000, + deliveryMode: 'sc', + birthOrder: 1, + maternalBloodCollectTime: '2025-08-29 13:30:00', + consentFormUrls: 'https://x/a.pdf', + infectiousDiseaseReportUrls: '', + attachmentUrls: 'https://x/b.pdf', + remark: '备注一', + allowedActions: ['edit'] + }) + const labels = card.fields.map((f) => f.label) + for (const label of [ + '当月核量号', '实验室流转编号', '协议状态', '废弃原因', '样本类型', + '采集地区', '采集医院', '分娩时间', '采集时间', '市场专员', '地区经理', + '运输方式', '新生儿性别', '新生儿体重', '分娩方式', '胎次', '母血采集时间', + '知情同意书', '传染病检测报告', '协议书', '备注' + ]) { + assert.ok(labels.includes(label), `missing ${label}`) + } + assert.equal(card.fields.find((f) => f.label === '当月核量号').value, '202508001') + assert.equal(card.fields.find((f) => f.label === '知情同意书').value, '有') + assert.equal(card.fields.find((f) => f.label === '传染病检测报告').value, '—') + assert.equal(card.fields.find((f) => f.label === '协议书').value, '有') + assert.ok(!labels.includes('是否同存')) +}) +``` + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd mobile-oa-h5 && node --test src/market/list-presenter.test.js +``` + +Expected: FAIL(缺字段) + +- [ ] **Step 3: 实现 `protocolCard` 分支** + +```javascript +function attachmentPresence(urls) { + return String(urls || '').trim() ? '有' : '—' +} + +function protocolCard(item) { + const actions = allowed(item, ['edit', 'delete']) + const isDonation = item.source === 'research_donation' + const status = label(PROTOCOL_STATUS_LABELS, item.protocolStatus || item.donationStatus || item.status) + if (isDonation) { + const collectDate = String(item.sampleCollectTime || '').slice(0, 10) + return { + title: text(item.motherName || item.customerName), + subtitle: `${label(SOURCE_LABELS, item.source)} · ${text(item.labStorageCode || item.sampleCode)}`, + status, + fields: [ + field('采集日期', collectDate || item.donationDate), + field('当月核量号', item.monthlyVerifyNo), + field('实验室流转编号', item.labStorageCode || item.sampleCode, { wide: true }), + field('协议状态', status, { tag: true }), + field('废弃原因', item.voidDesc), + field('母亲姓名', item.motherName || item.customerName), + field('样本类型', item.sampleTypeName), + field('采集地区', referenceName(item.regionName, item.regionId)), + field('采集医院', referenceName(item.hospitalName, item.hospitalId), { wide: true }), + field('分娩时间', item.newbornDeliveryTime, { wide: true }), + field('采集时间', item.sampleCollectTime, { wide: true }), + field('市场专员', personName(item.salesUserName, item.salesUserId)), + field('地区经理', personName(item.regionManagerUserName || item.managerName, item.regionManagerUserId)), + field('运输方式', item.transportModeLabel || item.transportMode), + field('新生儿性别', label(NEWBORN_SEX_LABELS, item.newbornSex)), + field('新生儿体重', item.newbornWeightGram), + field('分娩方式', label(DELIVERY_MODE_LABELS, item.deliveryMode)), + field('胎次', item.birthOrder), + field('母血采集时间', item.maternalBloodCollectTime, { wide: true }), + field('知情同意书', attachmentPresence(item.consentFormUrls)), + field('传染病检测报告', attachmentPresence(item.infectiousDiseaseReportUrls)), + field('协议书', attachmentPresence(item.attachmentUrls)), + field('备注', item.remark, { wide: true }) + ], + actions: ['view', ...actions] + } + } + // 原细胞存储卡片逻辑保持不变 + ... +} +``` + +保留现有 cell_storage 分支与原测试。 + +- [ ] **Step 4: 跑测试确认通过** + +```bash +cd mobile-oa-h5 && node --test src/market/list-presenter.test.js +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add mobile-oa-h5/src/market/list-presenter.js mobile-oa-h5/src/market/list-presenter.test.js +git commit -m "$(cat <<'EOF' +feat(mobile-oa-h5): align research donation protocol card with excel fields + +EOF +)" +``` + +--- + +### Task 8: 回归验证 + graphify + +- [ ] **Step 1: 后端测试** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_research_donation_monthly_verify_no.py tests/test_market_protocol_api.py -v --tb=short +``` + +Expected: PASS + +- [ ] **Step 2: 前端 / 小程序测试** + +```bash +cd web-admin && pnpm exec vitest run src/utils/protocol-audit-flatten.test.ts +cd mobile-oa-h5 && node --test src/market/list-presenter.test.js +``` + +Expected: PASS + +- [ ] **Step 3: 更新知识图谱(backend 有模型/CRUD 变更)** + +```bash +./scripts/update-graph.sh backend +``` + +- [ ] **Step 4: 手动验收清单** + +- [ ] 迁移已在目标库执行 +- [ ] 新建科研捐赠出现 `YYYYMM001` 形态核量号;同月第二笔递增 +- [ ] 历史单据核量号为空显示「—」 +- [ ] Web 科研捐赠列表列符合 spec;细胞存储 Tab 无变化 +- [ ] 小程序科研捐赠卡片字段齐全;细胞存储卡片无回归 +- [ ] 附件三列仅「有」/「—」;无运费、无是否同存 + +- [ ] **Step 5: 若 graphify 有变更则提交** + +```bash +git add backend/graphify-out +git commit -m "$(cat <<'EOF' +chore: update backend graphify after monthly verify no + +EOF +)" +``` + +--- + +## Spec coverage checklist + +| Spec 要求 | Task | +|-----------|------| +| `monthly_verify_no` 落库 + 唯一索引 | 1 | +| 格式 `YYYYMM###`、按创建时间年月、含软删 | 2 | +| 创建写号;历史不回填 | 3 | +| 转科研建单写号 | 3 | +| list 暴露核量号 + birthOrder + 母血时间等 | 3、4 | +| Web 列顺序 / 附件有无 / 去是否同存与运费 | 5、6 | +| 小程序科研卡片对齐 | 4、7 | +| 详情/表单不展示核量号 | 不做 UI(默认满足) | +| 细胞存储 Tab 不动 | 6、7 仅改科研路径 | + +## Placeholder / ambiguity notes(已拍板) + +- 附件:前端 URL 判空,不新增布尔字段。 +- 废弃原因:列表展示 `voidDesc`。 +- 协议书:`attachmentUrls`(其他附件)。