Sfoglia il codice sorgente

docs: add research donation list Excel parity implementation plan

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 settimane fa
parent
commit
56f379dd0c

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