2026-10-09-storage-cert-qc-info.md 36 KB

储存证质检信息 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

-- 样本级储存证质检信息(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)。

采用(与约定一致、实现简单): 唯一索引改为:

  UNIQUE KEY uk_storage_cert_qc_tenant_sample (tenant_no, sample_code),

软删后若需再建同码行,先清理或复用该行(本功能 upsert 始终复用,不删)。

  • Step 2: 增加 Model

在 models.py 增加:

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
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: 写失败单测

"""储存证质检信息:类型键与 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: 跑测确认失败
cd backend && source venv/bin/activate && pytest tests/test_sample_storage_cert_qc.py -v

Expected: FAIL import / not found

  • Step 3: 实现 service
"""样本储存证质检信息 — 类型键与 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: 跑测通过
cd backend && source venv/bin/activate && pytest tests/test_sample_storage_cert_qc.py -v

Expected: PASS

  • Step 5: Commit
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

"""样本储存证质检信息 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 增加:

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: 跑测
cd backend && source venv/bin/activate && pytest tests/test_sample_storage_cert_qc.py -v
  • Step 4: Commit
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 模块

"""样本储存证质检信息 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:增加

from app.api.sample_storage_cert_qc import router as sample_storage_cert_qc_router

并加入 __all__。

backend/app/main.py:在其它 admin router 旁增加

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
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。结构示意:

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
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
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
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 两个操作区模板中,于「特殊样本」附近增加:

<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 均显示,不额外限制执行人。)

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
}

模板底部挂载:

<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
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 行内:

<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
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: 更新图谱
./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)
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?