Просмотр исходного кода

docs: add storage-cert QC info implementation plan.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 1 месяц назад
Родитель
Сommit
5d0e3d904a
1 измененных файлов с 1115 добавлено и 0 удалено
  1. 1115 0
      docs/superpowers/plans/2026-10-09-storage-cert-qc-info.md

+ 1115 - 0
docs/superpowers/plans/2026-10-09-storage-cert-qc-info.md

@@ -0,0 +1,1115 @@
+# 储存证质检信息 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:** 质检在细胞存储质检工单卡片填写「储存证质检信息」,市场在样本信息管理只读查看;数据按样本级 JSON 落库,字段对齐 Excel 两套模板。
+
+**Architecture:** 新表 `biz_sample_storage_cert_qc`(`tenant_no + sample_code` upsert,`payload` Text JSON)。服务层解析样本类型 → `perinatal_hsc` / `uc_msc`;API `GET/PUT /web/admin/sample-storage-cert-qc/{sample_code}`。前端共用字段元数据 + 弹窗组件;工单卡片可编辑,样本信息行操作只读。
+
+**Tech Stack:** MySQL 5.7 / FastAPI + SQLAlchemy / pytest unittest / Vue 3 + Element Plus / pnpm
+
+**Design Doc:** `docs/superpowers/specs/2026-10-09-storage-cert-qc-info-design.md`
+
+**Demo:** `docs/demos/2026-10-09-storage-cert-qc-info-demo.html`
+
+**Reuse:**
+- 样本类型名常量:`backend/app/crud/sample_production_reception.py` 的 `SAMPLE_TYPE_PERINATAL_HSC` / `SAMPLE_TYPE_UC_MSC`(可抽到 service 避免循环 import,或在新 service 内重复同名字符串常量)
+- 前端类型名:`web-admin/src/utils/sample-production-reception-fields.ts`
+- 鉴权:`require_any_menu_code`(质检 PUT:`biz:qcWorkOrder:list` | `biz:myQcTask:list`;GET 另含 `cell-storage:sample-info:list`)
+- ID:`app.core.utils.generate_uuid`
+- 工单卡片:`QcWorkOrderSingleCard.vue`;样本列表:`UnifiedSampleInfoPage.vue`
+
+---
+
+## File Structure Map
+
+### New files
+
+| File | Responsibility |
+|------|----------------|
+| `docs/sql/migrations/2026-10-09-sample-storage-cert-qc.sql` | 建表 |
+| `backend/app/services/sample_storage_cert_qc.py` | 类型键映射、payload 规范化、empty GET 形态 |
+| `backend/app/crud/sample_storage_cert_qc.py` | get / upsert / row_to_dict |
+| `backend/app/api/sample_storage_cert_qc.py` | GET + PUT |
+| `backend/tests/test_sample_storage_cert_qc.py` | 纯函数 + upsert 辅助单测 |
+| `web-admin/src/api/sample-storage-cert-qc.ts` | FE API |
+| `web-admin/src/utils/storage-cert-qc-fields.ts` | 两套模板字段元数据 + 类型判定 |
+| `web-admin/src/components/business/storage-cert-qc-dialog/index.vue` | 填写 / 查看共用弹窗 |
+
+### Modified files
+
+| File | Change |
+|------|--------|
+| `backend/app/models/models.py` | 新增 `BizSampleStorageCertQc` |
+| `backend/app/api/__init__.py` | export router |
+| `backend/app/main.py` | `include_router` |
+| `web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderSingleCard.vue` | 按钮 + 打开弹窗 |
+| `web-admin/src/views/cell-storage/sample-info/UnifiedSampleInfoPage.vue` | 行操作查看按钮 + 弹窗 |
+
+---
+
+### Task 1: SQL 迁移 + Model
+
+**Files:**
+- Create: `docs/sql/migrations/2026-10-09-sample-storage-cert-qc.sql`
+- Modify: `backend/app/models/models.py`(建议插在 `BizProtocolStorageCert` 附近)
+
+- [ ] **Step 1: 写迁移 SQL**
+
+```sql
+-- 样本级储存证质检信息(2026-10-09)
+-- 对齐 docs/superpowers/specs/2026-10-09-storage-cert-qc-info-design.md
+
+CREATE TABLE IF NOT EXISTS biz_sample_storage_cert_qc (
+  id VARCHAR(64) NOT NULL COMMENT '主键ID',
+  sample_code VARCHAR(32) NOT NULL COMMENT '样本编码',
+  collection_protocol_id VARCHAR(64) DEFAULT NULL COMMENT '采集协议单ID冗余',
+  qc_work_order_id VARCHAR(64) DEFAULT NULL COMMENT '最近填写来源工单ID',
+  sample_type_key VARCHAR(32) NOT NULL COMMENT 'perinatal_hsc|uc_msc',
+  payload TEXT NOT NULL COMMENT '业务字段 JSON',
+  filled_flag CHAR(1) NOT NULL DEFAULT '0' COMMENT '是否已保存过 0否1是',
+  tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
+  order_num INT DEFAULT NULL COMMENT '排序号',
+  remark VARCHAR(512) DEFAULT NULL COMMENT '备注',
+  create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人id',
+  create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
+  create_time DATETIME NOT NULL COMMENT '创建时间',
+  update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人id',
+  update_time DATETIME NOT NULL COMMENT '更新时间',
+  del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_storage_cert_qc_tenant_sample (tenant_no, sample_code, del_flag),
+  KEY idx_storage_cert_qc_protocol (tenant_no, collection_protocol_id, del_flag)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='样本储存证质检信息';
+```
+
+注意:MySQL 5.7 下 `UNIQUE (tenant_no, sample_code, del_flag)` 在多次软删时可能冲突(`del_flag=1` 多行)。若担心,可改为仅 `(tenant_no, sample_code)` 唯一、且业务**禁止**物理多行软删副本(upsert 只更新同一行,删除走 `del_flag=1` 且不再新建同码行);或应用层保证唯一、库上只建普通索引 `KEY idx_storage_cert_qc_sample (tenant_no, sample_code, del_flag)`。
+
+**采用(与约定一致、实现简单):** 唯一索引改为:
+
+```sql
+  UNIQUE KEY uk_storage_cert_qc_tenant_sample (tenant_no, sample_code),
+```
+
+软删后若需再建同码行,先清理或复用该行(本功能 upsert 始终复用,不删)。
+
+- [ ] **Step 2: 增加 Model**
+
+在 `models.py` 增加:
+
+```python
+class BizSampleStorageCertQc(Base):
+    """样本储存证质检信息 biz_sample_storage_cert_qc"""
+
+    __tablename__ = "biz_sample_storage_cert_qc"
+
+    id = Column(String(64), primary_key=True, comment="主键ID")
+    sample_code = Column(String(32), nullable=False, comment="样本编码")
+    collection_protocol_id = Column(String(64), nullable=True, comment="采集协议单ID冗余")
+    qc_work_order_id = Column(String(64), nullable=True, comment="最近填写来源工单ID")
+    sample_type_key = Column(String(32), nullable=False, comment="perinatal_hsc|uc_msc")
+    payload = Column(Text, nullable=False, comment="业务字段 JSON")
+    filled_flag = Column(CHAR(1), nullable=False, default="0", comment="是否已保存过 0否1是")
+    tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
+    order_num = Column(Integer, nullable=True, comment="排序号")
+    remark = Column(String(512), nullable=True, comment="备注")
+    create_by = Column(String(64), nullable=True, comment="创建人id")
+    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
+    create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
+    update_by = Column(String(64), nullable=True, comment="更新人id")
+    update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
+    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
+```
+
+确保文件顶部已 `from sqlalchemy import ... CHAR`(若无则加入)。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docs/sql/migrations/2026-10-09-sample-storage-cert-qc.sql backend/app/models/models.py
+git commit -m "$(cat <<'EOF'
+feat(db): add biz_sample_storage_cert_qc for storage-cert QC payload.
+
+EOF
+)"
+```
+
+---
+
+### Task 2: Service 纯函数(类型键 + payload)+ 单测
+
+**Files:**
+- Create: `backend/app/services/sample_storage_cert_qc.py`
+- Create: `backend/tests/test_sample_storage_cert_qc.py`
+
+- [ ] **Step 1: 写失败单测**
+
+```python
+"""储存证质检信息:类型键与 payload 规范化。"""
+import unittest
+
+from app.services.sample_storage_cert_qc import (
+    SAMPLE_TYPE_KEY_PERINATAL_HSC,
+    SAMPLE_TYPE_KEY_UC_MSC,
+    empty_detail,
+    normalize_payload,
+    resolve_sample_type_key,
+    supports_storage_cert_qc,
+)
+
+
+class TestResolveSampleTypeKey(unittest.TestCase):
+    def test_hsc(self):
+        self.assertEqual(resolve_sample_type_key("围产期造血干细胞"), SAMPLE_TYPE_KEY_PERINATAL_HSC)
+
+    def test_msc(self):
+        self.assertEqual(resolve_sample_type_key("脐带间充质干细胞"), SAMPLE_TYPE_KEY_UC_MSC)
+
+    def test_other(self):
+        self.assertIsNone(resolve_sample_type_key("胎盘亚全能干细胞"))
+        self.assertIsNone(resolve_sample_type_key(""))
+
+    def test_supports(self):
+        self.assertTrue(supports_storage_cert_qc("围产期造血干细胞"))
+        self.assertFalse(supports_storage_cert_qc("其他"))
+
+
+class TestNormalizePayload(unittest.TestCase):
+    def test_rejects_non_object(self):
+        with self.assertRaises(ValueError):
+            normalize_payload([])
+        with self.assertRaises(ValueError):
+            normalize_payload("x")
+
+    def test_keeps_dict_stringifies_keys(self):
+        out = normalize_payload({"viability_pct": 90.4, 1: "bad"})
+        self.assertEqual(out["viability_pct"], 90.4)
+        self.assertNotIn(1, out)
+
+
+class TestEmptyDetail(unittest.TestCase):
+    def test_shape(self):
+        d = empty_detail(
+            sample_code="B1",
+            protocol_code="P1",
+            mother_name="张三",
+            sample_type_name="围产期造血干细胞",
+            sample_type_key="perinatal_hsc",
+        )
+        self.assertFalse(d["filledFlag"])
+        self.assertEqual(d["payload"], {})
+        self.assertEqual(d["sampleCode"], "B1")
+        self.assertEqual(d["labFlowCode"], "B1")
+
+
+if __name__ == "__main__":
+    unittest.main()
+```
+
+- [ ] **Step 2: 跑测确认失败**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_sample_storage_cert_qc.py -v
+```
+
+Expected: FAIL import / not found
+
+- [ ] **Step 3: 实现 service**
+
+```python
+"""样本储存证质检信息 — 类型键与 payload 工具。"""
+from __future__ import annotations
+
+import json
+from typing import Any, Dict, Optional
+
+SAMPLE_TYPE_NAME_PERINATAL_HSC = "围产期造血干细胞"
+SAMPLE_TYPE_NAME_UC_MSC = "脐带间充质干细胞"
+SAMPLE_TYPE_KEY_PERINATAL_HSC = "perinatal_hsc"
+SAMPLE_TYPE_KEY_UC_MSC = "uc_msc"
+
+_NAME_TO_KEY = {
+    SAMPLE_TYPE_NAME_PERINATAL_HSC: SAMPLE_TYPE_KEY_PERINATAL_HSC,
+    SAMPLE_TYPE_NAME_UC_MSC: SAMPLE_TYPE_KEY_UC_MSC,
+}
+
+
+def resolve_sample_type_key(sample_type_name: Optional[str]) -> Optional[str]:
+    name = (sample_type_name or "").strip()
+    return _NAME_TO_KEY.get(name)
+
+
+def supports_storage_cert_qc(sample_type_name: Optional[str]) -> bool:
+    return resolve_sample_type_key(sample_type_name) is not None
+
+
+def normalize_payload(payload: Any) -> Dict[str, Any]:
+    if not isinstance(payload, dict):
+        raise ValueError("payload 必须为对象")
+    out: Dict[str, Any] = {}
+    for k, v in payload.items():
+        if not isinstance(k, str):
+            continue
+        key = k.strip()
+        if not key:
+            continue
+        out[key] = v
+    return out
+
+
+def dumps_payload(payload: Dict[str, Any]) -> str:
+    return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
+
+
+def loads_payload(raw: Optional[str]) -> Dict[str, Any]:
+    if not raw:
+        return {}
+    try:
+        data = json.loads(raw)
+    except (TypeError, ValueError):
+        return {}
+    if not isinstance(data, dict):
+        return {}
+    return data
+
+
+def empty_detail(
+    *,
+    sample_code: str,
+    protocol_code: str = "",
+    mother_name: str = "",
+    sample_type_name: str = "",
+    sample_type_key: Optional[str] = None,
+    collection_protocol_id: str = "",
+    qc_work_order_id: str = "",
+) -> Dict[str, Any]:
+    return {
+        "sampleCode": sample_code,
+        "labFlowCode": sample_code,
+        "protocolCode": protocol_code or "",
+        "motherName": mother_name or "",
+        "sampleTypeName": sample_type_name or "",
+        "sampleTypeKey": sample_type_key or resolve_sample_type_key(sample_type_name),
+        "collectionProtocolId": collection_protocol_id or "",
+        "qcWorkOrderId": qc_work_order_id or "",
+        "filledFlag": False,
+        "payload": {},
+    }
+```
+
+- [ ] **Step 4: 跑测通过**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_sample_storage_cert_qc.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/services/sample_storage_cert_qc.py backend/tests/test_sample_storage_cert_qc.py
+git commit -m "$(cat <<'EOF'
+feat(backend): add storage-cert QC type-key and payload helpers.
+
+EOF
+)"
+```
+
+---
+
+### Task 3: CRUD get / upsert
+
+**Files:**
+- Create: `backend/app/crud/sample_storage_cert_qc.py`
+- Modify: `backend/tests/test_sample_storage_cert_qc.py`(可增 mock/纯组装测;若无 DB fixture,优先测 `_row_to_dict` 用 SimpleNamespace)
+
+- [ ] **Step 1: 实现 CRUD**
+
+```python
+"""样本储存证质检信息 CRUD。"""
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Dict, Optional
+
+from sqlalchemy.orm import Session
+
+from app.core.utils import generate_uuid
+from app.models.models import (
+    BizCellSampleType,
+    BizCollectionProtocol,
+    BizProtocolPartyCustomer,
+    BizProtocolSample,
+    BizSampleStorageCertQc,
+)
+from app.services.sample_storage_cert_qc import (
+    dumps_payload,
+    empty_detail,
+    loads_payload,
+    normalize_payload,
+    resolve_sample_type_key,
+)
+
+
+def _row_to_dict(
+    row: BizSampleStorageCertQc,
+    *,
+    protocol_code: str = "",
+    mother_name: str = "",
+    sample_type_name: str = "",
+) -> Dict[str, Any]:
+    return {
+        "id": row.id,
+        "sampleCode": row.sample_code or "",
+        "labFlowCode": row.sample_code or "",
+        "protocolCode": protocol_code or "",
+        "motherName": mother_name or "",
+        "sampleTypeName": sample_type_name or "",
+        "sampleTypeKey": row.sample_type_key,
+        "collectionProtocolId": row.collection_protocol_id or "",
+        "qcWorkOrderId": row.qc_work_order_id or "",
+        "filledFlag": str(row.filled_flag or "0") == "1",
+        "payload": loads_payload(row.payload),
+        "createTime": row.create_time.isoformat(sep=" ", timespec="seconds") if row.create_time else "",
+        "updateTime": row.update_time.isoformat(sep=" ", timespec="seconds") if row.update_time else "",
+    }
+
+
+def _load_sample_context(db: Session, tenant_no: str, sample_code: str):
+    code = (sample_code or "").strip()
+    if not code:
+        return None
+    sample = (
+        db.query(BizProtocolSample)
+        .filter(
+            BizProtocolSample.tenant_no == tenant_no,
+            BizProtocolSample.sample_code == code,
+            BizProtocolSample.del_flag == 0,
+        )
+        .first()
+    )
+    if not sample:
+        return None
+    type_name = ""
+    if sample.sample_type_id:
+        st = (
+            db.query(BizCellSampleType)
+            .filter(
+                BizCellSampleType.id == sample.sample_type_id,
+                BizCellSampleType.del_flag == 0,
+            )
+            .first()
+        )
+        if st:
+            type_name = st.type_name or ""
+    protocol_code = ""
+    mother_name = ""
+    if sample.collection_protocol_id:
+        proto = (
+            db.query(BizCollectionProtocol)
+            .filter(
+                BizCollectionProtocol.id == sample.collection_protocol_id,
+                BizCollectionProtocol.tenant_no == tenant_no,
+                BizCollectionProtocol.del_flag == 0,
+            )
+            .first()
+        )
+        if proto:
+            protocol_code = proto.protocol_no or ""
+        pc = (
+            db.query(BizProtocolPartyCustomer)
+            .filter(
+                BizProtocolPartyCustomer.collection_protocol_id == sample.collection_protocol_id,
+                BizProtocolPartyCustomer.tenant_no == tenant_no,
+                BizProtocolPartyCustomer.del_flag == 0,
+            )
+            .first()
+        )
+        if pc:
+            mother_name = pc.mother_name or ""
+    return sample, type_name, protocol_code, mother_name
+
+
+def get_detail(db: Session, tenant_no: str, sample_code: str) -> Optional[Dict[str, Any]]:
+    """样本不存在返回 None;存在但未填返回 empty_detail。"""
+    ctx = _load_sample_context(db, tenant_no, sample_code)
+    if not ctx:
+        return None
+    sample, type_name, protocol_code, mother_name = ctx
+    key = resolve_sample_type_key(type_name)
+    row = (
+        db.query(BizSampleStorageCertQc)
+        .filter(
+            BizSampleStorageCertQc.tenant_no == tenant_no,
+            BizSampleStorageCertQc.sample_code == sample.sample_code,
+            BizSampleStorageCertQc.del_flag == 0,
+        )
+        .first()
+    )
+    if not row:
+        return empty_detail(
+            sample_code=sample.sample_code or "",
+            protocol_code=protocol_code,
+            mother_name=mother_name,
+            sample_type_name=type_name,
+            sample_type_key=key,
+            collection_protocol_id=sample.collection_protocol_id or "",
+        )
+    return _row_to_dict(
+        row,
+        protocol_code=protocol_code,
+        mother_name=mother_name,
+        sample_type_name=type_name,
+    )
+
+
+def upsert(
+    db: Session,
+    *,
+    tenant_no: str,
+    user_id: str,
+    sample_code: str,
+    payload: Any,
+    qc_work_order_id: Optional[str] = None,
+) -> Dict[str, Any]:
+    ctx = _load_sample_context(db, tenant_no, sample_code)
+    if not ctx:
+        raise ValueError("样本不存在")
+    sample, type_name, protocol_code, mother_name = ctx
+    key = resolve_sample_type_key(type_name)
+    if not key:
+        raise ValueError("该样本类型不支持填写储存证质检信息")
+    clean = normalize_payload(payload)
+    now = datetime.now()
+    row = (
+        db.query(BizSampleStorageCertQc)
+        .filter(
+            BizSampleStorageCertQc.tenant_no == tenant_no,
+            BizSampleStorageCertQc.sample_code == sample.sample_code,
+            BizSampleStorageCertQc.del_flag == 0,
+        )
+        .first()
+    )
+    if not row:
+        row = BizSampleStorageCertQc(
+            id=generate_uuid(),
+            sample_code=sample.sample_code,
+            collection_protocol_id=sample.collection_protocol_id,
+            qc_work_order_id=(qc_work_order_id or None),
+            sample_type_key=key,
+            payload=dumps_payload(clean),
+            filled_flag="1",
+            tenant_no=tenant_no,
+            create_by=user_id,
+            create_dept_id="",
+            create_time=now,
+            update_by=user_id,
+            update_time=now,
+            del_flag=0,
+        )
+        db.add(row)
+    else:
+        row.collection_protocol_id = sample.collection_protocol_id
+        row.sample_type_key = key
+        row.payload = dumps_payload(clean)
+        row.filled_flag = "1"
+        if qc_work_order_id:
+            row.qc_work_order_id = qc_work_order_id
+        row.update_by = user_id
+        row.update_time = now
+    db.commit()
+    db.refresh(row)
+    return _row_to_dict(
+        row,
+        protocol_code=protocol_code,
+        mother_name=mother_name,
+        sample_type_name=type_name,
+    )
+```
+
+协议编码取 `BizCollectionProtocol.protocol_no`(与现网列表 `protocolNo` 一致)。
+
+- [ ] **Step 2: 补充 `_row_to_dict` 单测(SimpleNamespace)**
+
+在 `test_sample_storage_cert_qc.py` 增加:
+
+```python
+from types import SimpleNamespace
+from app.crud.sample_storage_cert_qc import _row_to_dict
+
+class TestRowToDict(unittest.TestCase):
+    def test_filled(self):
+        row = SimpleNamespace(
+            id="1",
+            sample_code="B1",
+            sample_type_key="perinatal_hsc",
+            collection_protocol_id="P",
+            qc_work_order_id="W",
+            filled_flag="1",
+            payload='{"viability_pct":"90"}',
+            create_time=None,
+            update_time=None,
+        )
+        d = _row_to_dict(row, protocol_code="C1", mother_name="李", sample_type_name="围产期造血干细胞")
+        self.assertTrue(d["filledFlag"])
+        self.assertEqual(d["payload"]["viability_pct"], "90")
+```
+
+- [ ] **Step 3: 跑测**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_sample_storage_cert_qc.py -v
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add backend/app/crud/sample_storage_cert_qc.py backend/tests/test_sample_storage_cert_qc.py
+git commit -m "$(cat <<'EOF'
+feat(backend): add sample storage-cert QC get and upsert CRUD.
+
+EOF
+)"
+```
+
+---
+
+### Task 4: API 注册 GET / PUT
+
+**Files:**
+- Create: `backend/app/api/sample_storage_cert_qc.py`
+- Modify: `backend/app/api/__init__.py`
+- Modify: `backend/app/main.py`
+
+- [ ] **Step 1: 写 API 模块**
+
+```python
+"""样本储存证质检信息 API"""
+from typing import Any, Dict, Optional
+
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel, ConfigDict, Field
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.core.menu_guard import require_any_menu_code
+from app.core.response import error_response, success_response
+from app.crud import sample_storage_cert_qc as svc
+from app.models.models import SysUser
+
+router = APIRouter(prefix="/web/admin", tags=["储存证质检信息"])
+
+READ_MENUS = (
+    "biz:qcWorkOrder:list",
+    "biz:myQcTask:list",
+    "cell-storage:sample-info:list",
+)
+WRITE_MENUS = (
+    "biz:qcWorkOrder:list",
+    "biz:myQcTask:list",
+)
+
+
+def _get_tenant_no(db: Session, user: dict) -> str:
+    user_id = user.get("user_id")
+    if not user_id:
+        raise PermissionError("用户身份信息缺失,无法确定租户")
+    sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
+    if not sys_user or not sys_user.tenant_no:
+        raise PermissionError("用户租户信息缺失")
+    return sys_user.tenant_no
+
+
+def _get_current_user_id(user: dict) -> str:
+    user_id = user.get("user_id")
+    if not user_id:
+        raise PermissionError("用户身份信息缺失")
+    return str(user_id)
+
+
+class UpsertBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    payload: Dict[str, Any] = Field(default_factory=dict)
+    qc_work_order_id: Optional[str] = Field(None, alias="qcWorkOrderId")
+
+
+@router.get("/sample-storage-cert-qc/{sample_code}")
+async def get_sample_storage_cert_qc(
+    sample_code: str,
+    user: dict = Depends(require_any_menu_code(*READ_MENUS)),
+    db: Session = Depends(get_db),
+):
+    tenant_no = _get_tenant_no(db, user)
+    detail = svc.get_detail(db, tenant_no, sample_code)
+    if detail is None:
+        return error_response(404, "样本不存在")
+    return success_response(detail)
+
+
+@router.put("/sample-storage-cert-qc/{sample_code}")
+async def put_sample_storage_cert_qc(
+    sample_code: str,
+    body: UpsertBody,
+    user: dict = Depends(require_any_menu_code(*WRITE_MENUS)),
+    db: Session = Depends(get_db),
+):
+    tenant_no = _get_tenant_no(db, user)
+    user_id = _get_current_user_id(user)
+    try:
+        detail = svc.upsert(
+            db,
+            tenant_no=tenant_no,
+            user_id=user_id,
+            sample_code=sample_code,
+            payload=body.payload,
+            qc_work_order_id=body.qc_work_order_id,
+        )
+        return success_response(detail, message="保存成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+```
+
+- [ ] **Step 2: 注册路由**
+
+`backend/app/api/__init__.py`:增加
+
+```python
+from app.api.sample_storage_cert_qc import router as sample_storage_cert_qc_router
+```
+
+并加入 `__all__`。
+
+`backend/app/main.py`:在其它 admin router 旁增加
+
+```python
+from app.api import sample_storage_cert_qc_router  # 或与现有 import 风格一致
+app.include_router(sample_storage_cert_qc_router, prefix=settings.API_V1_PREFIX)
+```
+
+(按 `main.py` 现有从 `app.api` 批量 import 的写法对齐。)
+
+- [ ] **Step 3: 本地冒烟(有库时)**
+
+对支持类型样本:`GET` 未填 → `filledFlag=false`;`PUT` 部分 payload → 再 `GET` 可见;非支持类型 `PUT` → 400。
+
+无环境则依赖单测 + 代码审阅。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add backend/app/api/sample_storage_cert_qc.py backend/app/api/__init__.py backend/app/main.py
+git commit -m "$(cat <<'EOF'
+feat(api): expose GET/PUT sample-storage-cert-qc by sample code.
+
+EOF
+)"
+```
+
+---
+
+### Task 5: 前端字段元数据 + API
+
+**Files:**
+- Create: `web-admin/src/utils/storage-cert-qc-fields.ts`
+- Create: `web-admin/src/api/sample-storage-cert-qc.ts`
+
+- [ ] **Step 1: 字段元数据**
+
+对齐 design §5。结构示意:
+
+```typescript
+import {
+  SAMPLE_TYPE_PERINATAL_HSC,
+  SAMPLE_TYPE_UC_MSC
+} from '@/utils/sample-production-reception-fields'
+
+export type StorageCertQcTypeKey = 'perinatal_hsc' | 'uc_msc'
+
+export type StorageCertQcControl = 'text' | 'textarea' | 'select_yny'
+
+export interface StorageCertQcField {
+  key: string
+  label: string
+  control?: StorageCertQcControl
+  span?: 1 | 2 | 3
+}
+
+export interface StorageCertQcGroup {
+  title: string
+  variant?: 'primary' | 'alt' | 'meta'
+  fields: StorageCertQcField[]
+}
+
+export function resolveStorageCertQcTypeKey(
+  sampleTypeName?: string | null
+): StorageCertQcTypeKey | null {
+  const name = String(sampleTypeName || '').trim()
+  if (name === SAMPLE_TYPE_PERINATAL_HSC) return 'perinatal_hsc'
+  if (name === SAMPLE_TYPE_UC_MSC) return 'uc_msc'
+  return null
+}
+
+export function supportsStorageCertQc(sampleTypeName?: string | null): boolean {
+  return resolveStorageCertQcTypeKey(sampleTypeName) != null
+}
+
+const MOTHER_BLOOD: StorageCertQcField[] = [
+  { key: 'mother_hbsag', label: '乙型肝炎病毒表面抗原(HBsAg)', control: 'select_yny' },
+  { key: 'mother_hbv_dna', label: '乙型肝炎病毒核酸(HBV DNA)', control: 'select_yny' },
+  { key: 'mother_anti_hcv', label: '丙型肝炎病毒抗体(抗HCV)', control: 'select_yny' },
+  { key: 'mother_hcv_rna', label: '丙型肝炎病毒核酸(HCV RNA)', control: 'select_yny' },
+  { key: 'mother_anti_tp', label: '梅毒螺旋体特异性抗体(抗TP)', control: 'select_yny' },
+  { key: 'mother_cmv_igm', label: '巨细胞病毒 IgM 抗体 (CMV IgM)', control: 'select_yny' },
+  { key: 'mother_anti_hiv', label: '人类免疫缺陷病毒抗体(抗HIV-1/2)', control: 'select_yny' },
+  { key: 'mother_hiv1_rna', label: '人类免疫缺陷病毒核酸(1型) (HIV-1RNA)', control: 'select_yny' }
+]
+
+export const STORAGE_CERT_QC_GROUPS: Record<StorageCertQcTypeKey, StorageCertQcGroup[]> = {
+  perinatal_hsc: [
+    {
+      title: '采集与运输',
+      variant: 'alt',
+      fields: [
+        { key: 'collection_time', label: '造血干细胞采集时间' },
+        { key: 'transport_temp_c', label: '运输温度 /℃' }
+      ]
+    },
+    {
+      title: '造血干细胞检测报告',
+      variant: 'primary',
+      fields: [
+        { key: 'receive_time', label: '造血干细胞接收时间' },
+        { key: 'process_time', label: '处理时间' },
+        { key: 'cryo_time', label: '冻存时间' },
+        { key: 'weight_g', label: '造血干细胞重量/g' },
+        { key: 'cryo_volume_ml', label: '造血干细胞冻存体积/mL' },
+        { key: 'tnc_1e8', label: '有核细胞数 TNC / *10E8' },
+        { key: 'cd34_count_1e6', label: 'CD34+ 细胞计数 / *10E6' },
+        { key: 'cd34_cd45dim_1e5', label: 'CD34+ CD45dim 细胞计数 / *10E5' },
+        { key: 'viability_pct', label: '细胞活性/%' },
+        { key: 'bacteria_test', label: '细菌检测', control: 'select_yny' },
+        { key: 'hbsag', label: '乙型肝炎病毒表面抗原(HBsAg)', control: 'select_yny' },
+        { key: 'anti_hcv', label: '丙型肝炎病毒抗体(抗HCV)', control: 'select_yny' },
+        { key: 'anti_tp', label: '梅毒螺旋体特异性抗体(抗TP)', control: 'select_yny' },
+        { key: 'anti_hiv', label: '人类免疫缺陷病毒抗体(抗HIV-1/2)', control: 'select_yny' },
+        { key: 'cmv_igm', label: '巨细胞病毒 IgM 抗体 (CMV-IgM)', control: 'select_yny' }
+      ]
+    },
+    { title: '母亲血液检测报告', variant: 'primary', fields: MOTHER_BLOOD },
+    {
+      title: '储存证收尾信息',
+      variant: 'meta',
+      fields: [
+        { key: 'hla_typing', label: 'HLA 分型', control: 'textarea', span: 3 },
+        { key: 'storage_date', label: '储存日期' },
+        { key: 'storage_years', label: '储存年限' },
+        { key: 'result_comment', label: '检测结果评论' },
+        { key: 'report_issue_date', label: '报告发出日期' }
+      ]
+    }
+  ],
+  uc_msc: [
+    {
+      title: '采集与运输 / 邮寄',
+      variant: 'alt',
+      fields: [
+        { key: 'mail_flag', label: '是否邮寄' },
+        { key: 'collection_time', label: '脐带采集时间' },
+        { key: 'transport_temp_c', label: '运输温度 /℃' }
+      ]
+    },
+    {
+      title: '脐带间充质干细胞检测报告',
+      variant: 'primary',
+      fields: [
+        { key: 'receive_time', label: '脐带接收时间' },
+        { key: 'process_time', label: '处理时间' },
+        { key: 'cryo_time', label: '冻存时间' },
+        { key: 'storage_tube_count', label: '储存总量(管)' },
+        { key: 'available_cell_type', label: '可提供细胞类型' },
+        { key: 'available_cell_count_1e7', label: '可提供细胞数量 / *10E7' },
+        { key: 'available_viability_pct', label: '可提供细胞存活率 %' },
+        { key: 'endotoxin_test', label: '内毒素检测', control: 'select_yny' },
+        { key: 'bacteria_test', label: '细菌检测', control: 'select_yny' },
+        { key: 'fungus_test', label: '真菌检测', control: 'select_yny' },
+        { key: 'mycoplasma_test', label: '支原体检测', control: 'select_yny' },
+        { key: 'available_cell_morphology', label: '可提供细胞形态' },
+        {
+          key: 'available_immunophenotype',
+          label: '可提供细胞免疫表型特征',
+          span: 2
+        }
+      ]
+    },
+    { title: '母亲血液检测报告', variant: 'primary', fields: MOTHER_BLOOD },
+    {
+      title: '储存证收尾信息',
+      variant: 'meta',
+      fields: [
+        { key: 'result_judgment', label: '检测结果评判' },
+        { key: 'report_issue_date', label: '报告发出日期' },
+        { key: 'storage_date_p2', label: '储存日期 (P2冻存日期)' },
+        { key: 'freeze_method', label: '冷冻方式' },
+        { key: 'storage_temp', label: '储存温度' },
+        { key: 'storage_years', label: '储存年限' }
+      ]
+    }
+  ]
+}
+
+export const YNY_OPTIONS = [
+  { label: '阴', value: '阴' },
+  { label: '阳', value: '阳' },
+  { label: '/', value: '/' }
+]
+```
+
+- [ ] **Step 2: API**
+
+```typescript
+import request from '@/utils/http'
+
+export type SampleStorageCertQcDetail = {
+  sampleCode: string
+  labFlowCode: string
+  protocolCode: string
+  motherName: string
+  sampleTypeName: string
+  sampleTypeKey: string | null
+  collectionProtocolId?: string
+  qcWorkOrderId?: string
+  filledFlag: boolean
+  payload: Record<string, unknown>
+}
+
+export function fetchSampleStorageCertQc(sampleCode: string) {
+  return request.get<SampleStorageCertQcDetail>({
+    url: `/web/admin/sample-storage-cert-qc/${encodeURIComponent(sampleCode)}`
+  })
+}
+
+export function saveSampleStorageCertQc(
+  sampleCode: string,
+  data: { payload: Record<string, unknown>; qcWorkOrderId?: string }
+) {
+  return request.put<SampleStorageCertQcDetail>({
+    url: `/web/admin/sample-storage-cert-qc/${encodeURIComponent(sampleCode)}`,
+    data
+  })
+}
+```
+
+(若项目 `request.get` 泛型写法不同,对齐 `sample-info-handle.ts` / `qc-work-order.ts`。)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/utils/storage-cert-qc-fields.ts web-admin/src/api/sample-storage-cert-qc.ts
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add storage-cert QC field metadata and API client.
+
+EOF
+)"
+```
+
+---
+
+### Task 6: 共用弹窗组件
+
+**Files:**
+- Create: `web-admin/src/components/business/storage-cert-qc-dialog/index.vue`
+
+- [ ] **Step 1: 实现弹窗**
+
+Props:
+- `visible` / `v-model:visible`
+- `mode: 'edit' | 'view'`
+- `sampleCode: string`
+- `sampleTypeName?: string`(打开前可用于提前判断;仍以 GET 返回为准)
+- `qcWorkOrderId?: string`(edit 时可选传入)
+
+行为:
+- `visible=true` 且有 `sampleCode` → `fetchSampleStorageCertQc`
+- `mode=view` 且 `!filledFlag` → 身份区 + 空态「质检尚未填写」
+- `mode=edit` → 按 `sampleTypeKey` 渲染 `STORAGE_CERT_QC_GROUPS`;`el-form` `label-position="top"`;三列 `form-grid-3`(若全局无则用 inline grid / `form-grid-2` + span)
+- `select_yny` → `el-select` + `YNY_OPTIONS`
+- 保存:`saveSampleStorageCertQc`,成功 `ElMessage.success`、`emit('saved')`、关弹窗
+- `dialog-form-layout`、`append-to-body`、`modal-class="protocol-dialog-mask"`、`align-center`
+- 底栏:view 仅关闭;edit 取消 Close + 确定 Check
+
+视觉分组可参考 Demo;样式用 scoped + Fresh Mint token,勿引入小程序色。
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web-admin/src/components/business/storage-cert-qc-dialog/index.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add shared storage-cert QC fill/view dialog.
+
+EOF
+)"
+```
+
+---
+
+### Task 7: 质检工单卡片入口
+
+**Files:**
+- Modify: `web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderSingleCard.vue`
+
+- [ ] **Step 1: 显示条件与按钮**
+
+在 `showWorkOrderOps` / `showWorkOrderViewOnly` 两个操作区模板中,于「特殊样本」附近增加:
+
+```vue
+<el-button
+  v-if="showStorageCertQcButton"
+  class="btn-op-item"
+  @click="openStorageCertQcDialog"
+>
+  <el-icon><Document /></el-icon>填写储存证质检信息
+</el-button>
+```
+
+(`Document` 从 `@element-plus/icons-vue` 引入;manage/mine 均显示,不额外限制执行人。)
+
+```ts
+import { supportsStorageCertQc } from '@/utils/storage-cert-qc-fields'
+import StorageCertQcDialog from '@/components/business/storage-cert-qc-dialog/index.vue'
+
+const showStorageCertQcButton = computed(() =>
+  supportsStorageCertQc(String(props.row?.sampleTypeName || ''))
+)
+
+const storageCertQcVisible = ref(false)
+function openStorageCertQcDialog() {
+  storageCertQcVisible.value = true
+}
+```
+
+模板底部挂载:
+
+```vue
+<StorageCertQcDialog
+  v-model:visible="storageCertQcVisible"
+  mode="edit"
+  :sample-code="String(row.sampleCode || '')"
+  :sample-type-name="String(row.sampleTypeName || '')"
+  :qc-work-order-id="String(row.id || '')"
+/>
+```
+
+若 `row.sampleTypeName` 在某些布局为空:从父级 SampleGroup / protocol gate 已有字段补传 props(优先修卡片数据源,保持与列表 `sampleTypeName` 一致)。
+
+- [ ] **Step 2: 手动点验**
+
+细胞存储质检 Tab:造血 / 间充质卡片有按钮;其它类型无。保存后再次打开有数据。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderSingleCard.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add storage-cert QC fill entry on QC work-order cards.
+
+EOF
+)"
+```
+
+---
+
+### Task 8: 样本信息管理查看入口
+
+**Files:**
+- Modify: `web-admin/src/views/cell-storage/sample-info/UnifiedSampleInfoPage.vue`
+
+- [ ] **Step 1: 操作列**
+
+将操作列 `min-width` 提到 ≥ 260(规范)。细胞存储 Tab 行内:
+
+```vue
+<el-button
+  v-if="isCellStorage && supportsStorageCertQc(row.sampleTypeName)"
+  class="btn-op-item"
+  @click="openStorageCertQcView(row)"
+>
+  <el-icon><Document /></el-icon>查看储存证的质检信息
+</el-button>
+```
+
+始终可点(不按 filled 禁用)。挂载 view 模式弹窗;`openStorageCertQcView` 设 `sampleCode` 并打开。
+
+科研捐赠 Tab 不显示该按钮。
+
+- [ ] **Step 2: 点验**
+
+未填 →「质检尚未填写」;质检已填 → 只读字段一致。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/views/cell-storage/sample-info/UnifiedSampleInfoPage.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add storage-cert QC view on cell-storage sample-info.
+
+EOF
+)"
+```
+
+---
+
+### Task 9: 图谱增量 + 收尾核对
+
+- [ ] **Step 1: 更新图谱**
+
+```bash
+./scripts/update-graph.sh all
+```
+
+(若仅后端或仅前端有改,可分别 `backend` / `frontend`。)
+
+- [ ] **Step 2: Spec 对照清单**
+
+| Spec 要求 | Task |
+|-----------|------|
+| 表 + JSON payload | 1, 3 |
+| 类型键映射 | 2 |
+| GET 空记录不 404 | 3, 4 |
+| PUT upsert / 部分保存 / 可覆盖 | 3, 4 |
+| 仅两类型可写 | 2, 3, 4 |
+| 工单卡片填写 | 7 |
+| 样本信息查看 + 空态文案 | 6, 8 |
+| 手填、身份只读 | 6 |
+| 非目标未做 | — |
+
+- [ ] **Step 3: Commit 图谱(若有 diff)**
+
+```bash
+git add backend/graphify-out web-admin/graphify-out
+git commit -m "$(cat <<'EOF'
+chore: refresh graphify after storage-cert QC info.
+
+EOF
+)"
+```
+
+---
+
+## Self-Review(写计划后)
+
+1. **Spec coverage:** §4–§9 均有对应 Task;非目标未列入实现任务。  
+2. **Placeholders:** 无 TBD;协议编码已锁定为 `protocol_no`。  
+3. **Type consistency:** `sampleTypeKey` / `filledFlag` / `payload` / `labFlowCode` 前后端一致;菜单码与现网一致。
+
+---
+
+## Execution Handoff
+
+Plan complete and saved to `docs/superpowers/plans/2026-10-09-storage-cert-qc-info.md`. Two execution options:
+
+1. **Subagent-Driven (recommended)** — 每个 Task 派生子代理,Task 间复查  
+2. **Inline Execution** — 本会话按 executing-plans 批量执行并设检查点  
+
+Which approach?