From 5d0e3d904a9f2452db147f5ac0ae13c4666f13ee Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 9 Oct 2026 12:40:22 +0800 Subject: [PATCH] docs: add storage-cert QC info implementation plan. Co-authored-by: Cursor --- .../plans/2026-10-09-storage-cert-qc-info.md | 1115 +++++++++++++++++ 1 file changed, 1115 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-09-storage-cert-qc-info.md diff --git a/docs/superpowers/plans/2026-10-09-storage-cert-qc-info.md b/docs/superpowers/plans/2026-10-09-storage-cert-qc-info.md new file mode 100644 index 00000000..267da511 --- /dev/null +++ b/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 = { + 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 +} + +export function fetchSampleStorageCertQc(sampleCode: string) { + return request.get({ + url: `/web/admin/sample-storage-cert-qc/${encodeURIComponent(sampleCode)}` + }) +} + +export function saveSampleStorageCertQc( + sampleCode: string, + data: { payload: Record; qcWorkOrderId?: string } +) { + return request.put({ + 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 + + 填写储存证质检信息 + +``` + +(`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 + +``` + +若 `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 + + 查看储存证的质检信息 + +``` + +始终可点(不按 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?