docs: add research donation list Excel parity implementation plan

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-13 12:05:10 +08:00
1 parent c22c9467f5
commit 56f379dd0c
1 file changed
+646
@@ -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`(其他附件)。