|
|
@@ -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?
|