|
|
@@ -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`(其他附件)。
|