|
|
@@ -0,0 +1,927 @@
|
|
|
+# 出库细胞来源链质检查看 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:** 在「出库物料准备」出库细胞行提供「查看质检」,经聚合接口实时展示该产品整条来源链(祖先细胞产品 + 上游样本)上全部状态质检任务的检测参数、结论、备注与附件。
|
|
|
+
|
|
|
+**Architecture:** 后端新增 `GET /web/admin/cell-products/{id}/lineage-qc`:复用 `get_product_chain` 展平 ancestors+current,按节点批量查质检工单与任务(含附件与检测参数结果),一次返回。前端新建只读弹窗,操作列入口接入 `OutboundReleaseMaterialDialog`;任务卡片复用 `QcWorkOrderTaskResultPanel`。
|
|
|
+
|
|
|
+**Tech Stack:** FastAPI + SQLAlchemy(后端),Vue 3 + Element Plus + SCSS(前端),pytest(后端单测)
|
|
|
+
|
|
|
+**Design Spec:** `docs/superpowers/specs/2026-07-29-outbound-cell-lineage-qc-design.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File Structure
|
|
|
+
|
|
|
+| Action | File | Responsibility |
|
|
|
+|--------|------|----------------|
|
|
|
+| Modify | `backend/app/crud/cell_product.py` | `_flatten_lineage_nodes`、`get_product_lineage_qc` 编排 |
|
|
|
+| Modify | `backend/app/crud/qc_task_result.py` | 批量按 task_id 查已保存检测参数 |
|
|
|
+| Modify | `backend/app/api/production.py` | 注册 `lineage-qc` 路由 |
|
|
|
+| Create | `backend/tests/test_cell_product_lineage_qc.py` | 展平与编排单测 |
|
|
|
+| Modify | `web-admin/src/api/cell-product.ts` | `fetchCellProductLineageQc` |
|
|
|
+| Create | `web-admin/src/components/business/cell-product-lineage-qc-dialog/index.vue` | 来源链质检只读弹窗 |
|
|
|
+| Modify | `web-admin/src/views/pilot-service/outbound-release-review/OutboundReleaseMaterialDialog.vue` | 操作列「查看质检」入口 |
|
|
|
+
|
|
|
+**本期不改:** 出库保存 payload、`outbound-material-form-sections`(除非实现时发现物料弹窗完全委托该组件——则以该组件为准补入口,默认改 `OutboundReleaseMaterialDialog.vue`)。
|
|
|
+
|
|
|
+**权限(闭合设计开放项):** 与 `/cell-products/{id}/chain` 一致,使用 `require_admin_or_super_admin`(出库物料保存已用同一守卫;能进物料准备即可调本只读接口)。租户:仅 `tenant_no` 过滤,**不**套用质检 data scope,避免放行角色看不到工单。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: 后端 — 来源链节点展平(TDD)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/crud/cell_product.py`
|
|
|
+- Create: `backend/tests/test_cell_product_lineage_qc.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败单测(展平顺序与去重)**
|
|
|
+
|
|
|
+创建 `backend/tests/test_cell_product_lineage_qc.py`:
|
|
|
+
|
|
|
+```python
|
|
|
+"""细胞产品来源链质检 — 节点展平与编排"""
|
|
|
+import unittest
|
|
|
+from unittest.mock import MagicMock, patch
|
|
|
+
|
|
|
+from app.crud.cell_product import _flatten_lineage_nodes
|
|
|
+
|
|
|
+
|
|
|
+class TestFlattenLineageNodes(unittest.TestCase):
|
|
|
+ def test_current_only_when_no_ancestors(self):
|
|
|
+ chain = {
|
|
|
+ "current": {
|
|
|
+ "id": "cp-cur",
|
|
|
+ "productCode": "C-CUR",
|
|
|
+ "productName": "当前",
|
|
|
+ "nodeType": "cell_product",
|
|
|
+ "passage": "P2",
|
|
|
+ "isCurrent": True,
|
|
|
+ },
|
|
|
+ "ancestors": [],
|
|
|
+ }
|
|
|
+ nodes = _flatten_lineage_nodes(chain)
|
|
|
+ self.assertEqual(len(nodes), 1)
|
|
|
+ self.assertEqual(nodes[0]["id"], "cp-cur")
|
|
|
+ self.assertTrue(nodes[0]["isCurrent"])
|
|
|
+
|
|
|
+ def test_sample_then_parent_then_current_order(self):
|
|
|
+ sample = {
|
|
|
+ "id": "s1",
|
|
|
+ "nodeType": "sample",
|
|
|
+ "sampleCode": "YB001",
|
|
|
+ "receptionSource": "cell_storage",
|
|
|
+ "receptionSourceLabel": "细胞存储",
|
|
|
+ }
|
|
|
+ parent = {
|
|
|
+ "id": "cp-p1",
|
|
|
+ "productCode": "C-P1",
|
|
|
+ "productName": "父代",
|
|
|
+ "nodeType": "cell_product",
|
|
|
+ "passage": "P1",
|
|
|
+ "isCurrent": False,
|
|
|
+ }
|
|
|
+ current = {
|
|
|
+ "id": "cp-cur",
|
|
|
+ "productCode": "C-CUR",
|
|
|
+ "productName": "当前",
|
|
|
+ "nodeType": "cell_product",
|
|
|
+ "passage": "P2",
|
|
|
+ "isCurrent": True,
|
|
|
+ }
|
|
|
+ chain = {
|
|
|
+ "current": current,
|
|
|
+ "ancestors": [
|
|
|
+ {
|
|
|
+ "node": parent,
|
|
|
+ "edge": {},
|
|
|
+ "parent": {"node": sample, "edge": {}, "parent": None},
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ nodes = _flatten_lineage_nodes(chain)
|
|
|
+ self.assertEqual([n["id"] for n in nodes], ["s1", "cp-p1", "cp-cur"])
|
|
|
+ self.assertEqual(nodes[0]["nodeType"], "sample")
|
|
|
+ self.assertTrue(nodes[-1]["isCurrent"])
|
|
|
+
|
|
|
+ def test_dedupe_same_node_type_and_id(self):
|
|
|
+ parent = {
|
|
|
+ "id": "cp-p1",
|
|
|
+ "nodeType": "cell_product",
|
|
|
+ "productCode": "C-P1",
|
|
|
+ "productName": "父",
|
|
|
+ "passage": "P1",
|
|
|
+ "isCurrent": False,
|
|
|
+ }
|
|
|
+ current = {
|
|
|
+ "id": "cp-cur",
|
|
|
+ "nodeType": "cell_product",
|
|
|
+ "productCode": "C-CUR",
|
|
|
+ "productName": "当前",
|
|
|
+ "passage": "P2",
|
|
|
+ "isCurrent": True,
|
|
|
+ }
|
|
|
+ # 多进一:两条边指向同一父产品
|
|
|
+ chain = {
|
|
|
+ "current": current,
|
|
|
+ "ancestors": [
|
|
|
+ {"node": parent, "edge": {}, "parent": None},
|
|
|
+ {"node": dict(parent), "edge": {}, "parent": None},
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ nodes = _flatten_lineage_nodes(chain)
|
|
|
+ ids = [n["id"] for n in nodes]
|
|
|
+ self.assertEqual(ids.count("cp-p1"), 1)
|
|
|
+ self.assertEqual(ids[-1], "cp-cur")
|
|
|
+
|
|
|
+ def test_parent_array_multi_input(self):
|
|
|
+ s1 = {"id": "s1", "nodeType": "sample", "sampleCode": "A", "receptionSource": "cell_storage"}
|
|
|
+ s2 = {"id": "s2", "nodeType": "sample", "sampleCode": "B", "receptionSource": "cell_storage"}
|
|
|
+ parent = {
|
|
|
+ "id": "cp-p1",
|
|
|
+ "nodeType": "cell_product",
|
|
|
+ "productCode": "P1",
|
|
|
+ "productName": "父",
|
|
|
+ "passage": "P1",
|
|
|
+ "isCurrent": False,
|
|
|
+ }
|
|
|
+ current = {
|
|
|
+ "id": "cp-cur",
|
|
|
+ "nodeType": "cell_product",
|
|
|
+ "productCode": "CUR",
|
|
|
+ "productName": "当前",
|
|
|
+ "passage": "P2",
|
|
|
+ "isCurrent": True,
|
|
|
+ }
|
|
|
+ chain = {
|
|
|
+ "current": current,
|
|
|
+ "ancestors": [
|
|
|
+ {
|
|
|
+ "node": parent,
|
|
|
+ "edge": {},
|
|
|
+ "parent": [
|
|
|
+ {"node": s1, "edge": {}, "parent": None},
|
|
|
+ {"node": s2, "edge": {}, "parent": None},
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ nodes = _flatten_lineage_nodes(chain)
|
|
|
+ ids = [n["id"] for n in nodes]
|
|
|
+ self.assertIn("s1", ids)
|
|
|
+ self.assertIn("s2", ids)
|
|
|
+ self.assertEqual(ids[-1], "cp-cur")
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ unittest.main()
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 跑测确认失败**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_cell_product_lineage_qc.py::TestFlattenLineageNodes -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL(`_flatten_lineage_nodes` 未定义)
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现 `_flatten_lineage_nodes`**
|
|
|
+
|
|
|
+在 `backend/app/crud/cell_product.py` 靠近 `get_product_chain` 处新增:
|
|
|
+
|
|
|
+```python
|
|
|
+def _lineage_node_key(node: Dict[str, Any]) -> str:
|
|
|
+ nt = (node.get("nodeType") or "").strip()
|
|
|
+ nid = (node.get("id") or "").strip()
|
|
|
+ if nt == "sample":
|
|
|
+ code = (node.get("sampleCode") or "").strip()
|
|
|
+ return f"sample:{(nid or code)}"
|
|
|
+ return f"{nt}:{nid}"
|
|
|
+
|
|
|
+
|
|
|
+def _walk_ancestor_link(
|
|
|
+ link: Any,
|
|
|
+ out: List[Dict[str, Any]],
|
|
|
+ seen: set,
|
|
|
+) -> None:
|
|
|
+ if not link:
|
|
|
+ return
|
|
|
+ if isinstance(link, list):
|
|
|
+ for item in link:
|
|
|
+ _walk_ancestor_link(item, out, seen)
|
|
|
+ return
|
|
|
+ if not isinstance(link, dict):
|
|
|
+ return
|
|
|
+ parent = link.get("parent")
|
|
|
+ _walk_ancestor_link(parent, out, seen)
|
|
|
+ node = link.get("node")
|
|
|
+ if not isinstance(node, dict):
|
|
|
+ return
|
|
|
+ key = _lineage_node_key(node)
|
|
|
+ if not key or key in seen:
|
|
|
+ return
|
|
|
+ seen.add(key)
|
|
|
+ out.append(dict(node))
|
|
|
+
|
|
|
+
|
|
|
+def _flatten_lineage_nodes(chain: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
|
+ """将 get_product_chain 结果展平为上游→下游节点列表(含 current,不含 descendants)。"""
|
|
|
+ out: List[Dict[str, Any]] = []
|
|
|
+ seen: set = set()
|
|
|
+ for link in chain.get("ancestors") or []:
|
|
|
+ _walk_ancestor_link(link, out, seen)
|
|
|
+ current = chain.get("current")
|
|
|
+ if isinstance(current, dict):
|
|
|
+ key = _lineage_node_key(current)
|
|
|
+ if key and key not in seen:
|
|
|
+ seen.add(key)
|
|
|
+ node = dict(current)
|
|
|
+ node["isCurrent"] = True
|
|
|
+ out.append(node)
|
|
|
+ elif key:
|
|
|
+ for n in out:
|
|
|
+ if _lineage_node_key(n) == key:
|
|
|
+ n["isCurrent"] = True
|
|
|
+ break
|
|
|
+ return out
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 跑测确认通过**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_cell_product_lineage_qc.py::TestFlattenLineageNodes -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit(若本会话要求提交)**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/crud/cell_product.py backend/tests/test_cell_product_lineage_qc.py
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat: add lineage node flatten helper for cell product QC view
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: 后端 — 批量检测参数 + `get_product_lineage_qc`
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/crud/qc_task_result.py`
|
|
|
+- Modify: `backend/app/crud/cell_product.py`
|
|
|
+- Modify: `backend/tests/test_cell_product_lineage_qc.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败单测(编排挂载工单与任务字段)**
|
|
|
+
|
|
|
+在 `test_cell_product_lineage_qc.py` 追加:
|
|
|
+
|
|
|
+```python
|
|
|
+from app.crud.cell_product import get_product_lineage_qc
|
|
|
+
|
|
|
+
|
|
|
+class TestGetProductLineageQc(unittest.TestCase):
|
|
|
+ @patch("app.crud.cell_product.get_product_chain")
|
|
|
+ @patch("app.crud.cell_product._load_lineage_qc_for_nodes")
|
|
|
+ def test_missing_product_returns_none(self, _load, get_chain):
|
|
|
+ get_chain.return_value = None
|
|
|
+ db = MagicMock()
|
|
|
+ self.assertIsNone(get_product_lineage_qc(db, "missing", "zkxw"))
|
|
|
+ _load.assert_not_called()
|
|
|
+
|
|
|
+ @patch("app.crud.cell_product.get_product_chain")
|
|
|
+ @patch("app.crud.cell_product._load_lineage_qc_for_nodes")
|
|
|
+ def test_returns_product_header_and_nodes(self, load_nodes, get_chain):
|
|
|
+ get_chain.return_value = {
|
|
|
+ "current": {
|
|
|
+ "id": "cp1",
|
|
|
+ "productCode": "C1",
|
|
|
+ "productName": "产品1",
|
|
|
+ "nodeType": "cell_product",
|
|
|
+ "passage": "P1",
|
|
|
+ "isCurrent": True,
|
|
|
+ },
|
|
|
+ "ancestors": [],
|
|
|
+ }
|
|
|
+ load_nodes.return_value = [
|
|
|
+ {
|
|
|
+ "nodeType": "cell_product",
|
|
|
+ "id": "cp1",
|
|
|
+ "label": "C1",
|
|
|
+ "productName": "产品1",
|
|
|
+ "passage": "P1",
|
|
|
+ "receptionSource": None,
|
|
|
+ "isCurrent": True,
|
|
|
+ "workOrders": [
|
|
|
+ {
|
|
|
+ "id": "wo1",
|
|
|
+ "workOrderNo": "WO1",
|
|
|
+ "workOrderType": "temporary",
|
|
|
+ "workOrderStatus": "ywc",
|
|
|
+ "workOrderStatusLabel": "已完成",
|
|
|
+ "tasks": [
|
|
|
+ {
|
|
|
+ "id": "t1",
|
|
|
+ "taskNo": "T1",
|
|
|
+ "itemName": "无菌",
|
|
|
+ "results": [{"paramName": "结果", "paramValue": "阴性"}],
|
|
|
+ "attachmentFiles": [],
|
|
|
+ "remark": "",
|
|
|
+ "conclusion": "tg",
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ db = MagicMock()
|
|
|
+ out = get_product_lineage_qc(db, "cp1", "zkxw")
|
|
|
+ self.assertEqual(out["productId"], "cp1")
|
|
|
+ self.assertEqual(out["productCode"], "C1")
|
|
|
+ self.assertEqual(len(out["nodes"]), 1)
|
|
|
+ self.assertEqual(out["nodes"][0]["workOrders"][0]["tasks"][0]["results"][0]["paramValue"], "阴性")
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 跑测确认失败**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_cell_product_lineage_qc.py::TestGetProductLineageQc -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL(函数未定义)
|
|
|
+
|
|
|
+- [ ] **Step 3: 在 `qc_task_result.py` 增加批量已保存结果**
|
|
|
+
|
|
|
+```python
|
|
|
+def list_saved_results_by_task_ids(
|
|
|
+ db: Session, task_ids: List[str], tenant_no: str
|
|
|
+) -> Dict[str, List[Dict[str, Any]]]:
|
|
|
+ """按任务 ID 批量返回已保存检测参数(只读展示用,不合并模板空项)。"""
|
|
|
+ ids = list({(x or "").strip() for x in task_ids if (x or "").strip()})
|
|
|
+ if not ids or not tenant_no:
|
|
|
+ return {}
|
|
|
+ rows = (
|
|
|
+ db.query(BizQcTaskResult)
|
|
|
+ .filter(
|
|
|
+ BizQcTaskResult.task_id.in_(ids),
|
|
|
+ BizQcTaskResult.tenant_no == tenant_no,
|
|
|
+ BizQcTaskResult.del_flag == 0,
|
|
|
+ )
|
|
|
+ .order_by(BizQcTaskResult.create_time.asc())
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ grouped: Dict[str, List[Dict[str, Any]]] = {i: [] for i in ids}
|
|
|
+ for r in rows:
|
|
|
+ tid = (r.task_id or "").strip()
|
|
|
+ if tid in grouped:
|
|
|
+ grouped[tid].append(_row_to_dict(r))
|
|
|
+ return grouped
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 实现 `_load_lineage_qc_for_nodes` 与 `get_product_lineage_qc`**
|
|
|
+
|
|
|
+在 `cell_product.py` 实现(要点,实现时保持与仓库 import 风格一致):
|
|
|
+
|
|
|
+```python
|
|
|
+def _wo_status_label(status: str) -> str:
|
|
|
+ from app.crud.qc_work_order import _WORK_ORDER_STATUS_LABELS
|
|
|
+ return _WORK_ORDER_STATUS_LABELS.get(status or "", status or "")
|
|
|
+
|
|
|
+
|
|
|
+def _slim_task_for_lineage(task: Dict[str, Any], results: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
|
+ return {
|
|
|
+ "id": task.get("id") or "",
|
|
|
+ "taskNo": task.get("taskNo") or "",
|
|
|
+ "itemName": task.get("itemName") or "",
|
|
|
+ "itemCode": task.get("itemCode") or "",
|
|
|
+ "taskType": task.get("taskType") or "",
|
|
|
+ "taskStatus": task.get("taskStatus") or "",
|
|
|
+ "conclusion": task.get("conclusion") or "",
|
|
|
+ "conclusionLabel": task.get("conclusionLabel") or "",
|
|
|
+ "remark": task.get("remark") or "",
|
|
|
+ "completedTime": task.get("completedTime") or "",
|
|
|
+ "companyName": task.get("companyName") or "",
|
|
|
+ "results": [
|
|
|
+ {
|
|
|
+ "paramName": r.get("paramName") or "",
|
|
|
+ "paramValue": r.get("paramValue") or "",
|
|
|
+ "paramUnit": r.get("paramUnit") or "",
|
|
|
+ }
|
|
|
+ for r in (results or [])
|
|
|
+ ],
|
|
|
+ "attachmentFiles": task.get("attachmentFiles") or [],
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _load_lineage_qc_for_nodes(
|
|
|
+ db: Session, nodes: List[Dict[str, Any]], tenant_no: str
|
|
|
+) -> List[Dict[str, Any]]:
|
|
|
+ from app.models.models import BizQcWorkOrder
|
|
|
+ from app.crud import qc_task as task_svc
|
|
|
+ from app.crud import qc_task_result as result_svc
|
|
|
+ from app.crud.qc_work_order import list_work_orders_by_sample, _wo_to_dict
|
|
|
+
|
|
|
+ product_ids = [
|
|
|
+ (n.get("id") or "").strip()
|
|
|
+ for n in nodes
|
|
|
+ if (n.get("nodeType") or "") == "cell_product" and (n.get("id") or "").strip()
|
|
|
+ ]
|
|
|
+ wo_by_product: Dict[str, List[BizQcWorkOrder]] = {pid: [] for pid in product_ids}
|
|
|
+ if product_ids:
|
|
|
+ rows = (
|
|
|
+ db.query(BizQcWorkOrder)
|
|
|
+ .filter(
|
|
|
+ BizQcWorkOrder.cell_product_id.in_(product_ids),
|
|
|
+ BizQcWorkOrder.tenant_no == tenant_no,
|
|
|
+ BizQcWorkOrder.del_flag == 0,
|
|
|
+ )
|
|
|
+ .order_by(BizQcWorkOrder.create_time.desc())
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ for wo in rows:
|
|
|
+ pid = (wo.cell_product_id or "").strip()
|
|
|
+ if pid in wo_by_product:
|
|
|
+ wo_by_product[pid].append(wo)
|
|
|
+
|
|
|
+ sample_wo_cache: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
|
|
|
+
|
|
|
+ all_wo_ids: List[str] = []
|
|
|
+ node_wo_objs: List[List[Any]] = [] # parallel to nodes: list of wo dict or ORM
|
|
|
+
|
|
|
+ out_nodes: List[Dict[str, Any]] = []
|
|
|
+ pending_wo_ids: List[str] = []
|
|
|
+
|
|
|
+ # 先收集全部 wo id,再批量拉 tasks
|
|
|
+ node_work_orders_orm: List[List[BizQcWorkOrder]] = []
|
|
|
+ for n in nodes:
|
|
|
+ if (n.get("nodeType") or "") == "cell_product":
|
|
|
+ node_work_orders_orm.append(wo_by_product.get((n.get("id") or "").strip(), []))
|
|
|
+ else:
|
|
|
+ node_work_orders_orm.append([]) # sample filled below as dicts
|
|
|
+
|
|
|
+ # sample:对齐 list_work_orders_by_sample(返回 dict 列表)
|
|
|
+ sample_wo_dicts: Dict[int, List[Dict[str, Any]]] = {}
|
|
|
+ for idx, n in enumerate(nodes):
|
|
|
+ if (n.get("nodeType") or "") != "sample":
|
|
|
+ continue
|
|
|
+ code = (n.get("sampleCode") or "").strip()
|
|
|
+ rs = (n.get("receptionSource") or "").strip()
|
|
|
+ if not code or not rs:
|
|
|
+ sample_wo_dicts[idx] = []
|
|
|
+ continue
|
|
|
+ key = (code, rs)
|
|
|
+ if key not in sample_wo_cache:
|
|
|
+ sample_wo_cache[key] = list_work_orders_by_sample(
|
|
|
+ db,
|
|
|
+ sample_code=code,
|
|
|
+ reception_source=rs,
|
|
|
+ tenant_no=tenant_no,
|
|
|
+ user_info=None,
|
|
|
+ )
|
|
|
+ sample_wo_dicts[idx] = sample_wo_cache[key]
|
|
|
+
|
|
|
+ for wos in node_work_orders_orm:
|
|
|
+ for wo in wos:
|
|
|
+ pending_wo_ids.append(wo.id)
|
|
|
+ for wos in sample_wo_dicts.values():
|
|
|
+ for wo in wos:
|
|
|
+ wid = (wo.get("id") or "").strip()
|
|
|
+ if wid:
|
|
|
+ pending_wo_ids.append(wid)
|
|
|
+
|
|
|
+ tasks_by_wo = task_svc.list_tasks_by_work_order_ids(db, pending_wo_ids, tenant_no)
|
|
|
+ all_task_ids = [
|
|
|
+ (t.get("id") or "").strip()
|
|
|
+ for tasks in tasks_by_wo.values()
|
|
|
+ for t in tasks
|
|
|
+ if (t.get("id") or "").strip()
|
|
|
+ ]
|
|
|
+ results_by_task = result_svc.list_saved_results_by_task_ids(db, all_task_ids, tenant_no)
|
|
|
+
|
|
|
+ def pack_orm_wos(wos: List[BizQcWorkOrder]) -> List[Dict[str, Any]]:
|
|
|
+ packed = []
|
|
|
+ for wo in wos:
|
|
|
+ tasks = tasks_by_wo.get(wo.id, [])
|
|
|
+ packed.append(
|
|
|
+ {
|
|
|
+ "id": wo.id,
|
|
|
+ "workOrderNo": wo.work_order_no or "",
|
|
|
+ "workOrderType": wo.work_order_type or "",
|
|
|
+ "workOrderStatus": wo.work_order_status or "",
|
|
|
+ "workOrderStatusLabel": _wo_status_label(wo.work_order_status or ""),
|
|
|
+ "tasks": [
|
|
|
+ _slim_task_for_lineage(t, results_by_task.get(t.get("id") or "", []))
|
|
|
+ for t in tasks
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return packed
|
|
|
+
|
|
|
+ def pack_dict_wos(wos: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
|
+ packed = []
|
|
|
+ for wo in wos:
|
|
|
+ wid = (wo.get("id") or "").strip()
|
|
|
+ tasks = tasks_by_wo.get(wid, [])
|
|
|
+ packed.append(
|
|
|
+ {
|
|
|
+ "id": wid,
|
|
|
+ "workOrderNo": wo.get("workOrderNo") or "",
|
|
|
+ "workOrderType": wo.get("workOrderType") or "",
|
|
|
+ "workOrderStatus": wo.get("workOrderStatus") or "",
|
|
|
+ "workOrderStatusLabel": wo.get("workOrderStatusLabel")
|
|
|
+ or _wo_status_label(wo.get("workOrderStatus") or ""),
|
|
|
+ "tasks": [
|
|
|
+ _slim_task_for_lineage(t, results_by_task.get(t.get("id") or "", []))
|
|
|
+ for t in tasks
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return packed
|
|
|
+
|
|
|
+ for idx, n in enumerate(nodes):
|
|
|
+ nt = n.get("nodeType") or ""
|
|
|
+ if nt == "sample":
|
|
|
+ label = (n.get("sampleCode") or n.get("id") or "").strip()
|
|
|
+ work_orders = pack_dict_wos(sample_wo_dicts.get(idx, []))
|
|
|
+ else:
|
|
|
+ label = (n.get("productCode") or n.get("id") or "").strip()
|
|
|
+ work_orders = pack_orm_wos(node_work_orders_orm[idx])
|
|
|
+ out_nodes.append(
|
|
|
+ {
|
|
|
+ "nodeType": nt,
|
|
|
+ "id": n.get("id") or "",
|
|
|
+ "label": label,
|
|
|
+ "productName": n.get("productName") or "",
|
|
|
+ "passage": n.get("passage") or None,
|
|
|
+ "receptionSource": n.get("receptionSource") or None,
|
|
|
+ "receptionSourceLabel": n.get("receptionSourceLabel") or None,
|
|
|
+ "isCurrent": bool(n.get("isCurrent")),
|
|
|
+ "workOrders": work_orders,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return out_nodes
|
|
|
+
|
|
|
+
|
|
|
+def get_product_lineage_qc(
|
|
|
+ db: Session, product_id: str, tenant_no: str
|
|
|
+) -> Optional[Dict[str, Any]]:
|
|
|
+ chain = get_product_chain(db, product_id, tenant_no)
|
|
|
+ if not chain:
|
|
|
+ return None
|
|
|
+ current = chain.get("current") or {}
|
|
|
+ flat = _flatten_lineage_nodes(chain)
|
|
|
+ nodes = _load_lineage_qc_for_nodes(db, flat, tenant_no)
|
|
|
+ return {
|
|
|
+ "productId": current.get("id") or product_id,
|
|
|
+ "productCode": current.get("productCode") or "",
|
|
|
+ "productName": current.get("productName") or "",
|
|
|
+ "nodes": nodes,
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+实现时注意:
|
|
|
+
|
|
|
+1. 工单状态文案:`from app.crud.qc_work_order import _WORK_ORDER_STATUS_LABELS`,然后
|
|
|
+ `def _wo_status_label(status: str) -> str: return _WORK_ORDER_STATUS_LABELS.get(status or "", status or "")`
|
|
|
+2. `list_tasks_by_work_order_ids` 有 `ids[:100]` 上限;若 `pending_wo_ids` 超过 100,按 100 分块合并。
|
|
|
+3. 样本节点无 `sampleCode`/`receptionSource` 时 `workOrders: []`,不抛错。
|
|
|
+4. 删除未使用的 import。
|
|
|
+5. Task 字段若列表接口已有 `taskStatusLabel`,slim 时一并带上,供前端 Tag 使用(可选,面板会用 registry 回退)。
|
|
|
+
|
|
|
+- [ ] **Step 5: 跑全文件测试**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_cell_product_lineage_qc.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit(若要求提交)**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/crud/cell_product.py backend/app/crud/qc_task_result.py backend/tests/test_cell_product_lineage_qc.py
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat: add cell product lineage QC aggregate loader
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: 后端 — 注册路由
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/api/production.py`(紧挨 `/cell-products/{product_id}/chain`)
|
|
|
+
|
|
|
+- [ ] **Step 1: 增加路由**
|
|
|
+
|
|
|
+在 `get_cell_product_chain` 之后插入:
|
|
|
+
|
|
|
+```python
|
|
|
+@router.get("/cell-products/{product_id}/lineage-qc")
|
|
|
+async def get_cell_product_lineage_qc(
|
|
|
+ product_id: str,
|
|
|
+ user: dict = Depends(require_admin_or_super_admin),
|
|
|
+ db: Session = Depends(get_db),
|
|
|
+):
|
|
|
+ tenant_no = _get_tenant_no(db, user)
|
|
|
+ data = cell_product_svc.get_product_lineage_qc(db, product_id, tenant_no)
|
|
|
+ if not data:
|
|
|
+ raise HTTPException(status_code=404, detail="细胞产品不存在")
|
|
|
+ return success_response(data)
|
|
|
+```
|
|
|
+
|
|
|
+确认文件顶部已有 `HTTPException`、`require_admin_or_super_admin`、`cell_product_svc` 导入(与 chain 路由相同)。
|
|
|
+
|
|
|
+- [ ] **Step 2: 手工烟测(有本地服务时)**
|
|
|
+
|
|
|
+```bash
|
|
|
+# 替换 TOKEN 与 PRODUCT_ID
|
|
|
+curl -s -H "Authorization: Bearer $TOKEN" \
|
|
|
+ "http://127.0.0.1:8000/api/web/admin/cell-products/$PRODUCT_ID/lineage-qc" | head -c 500
|
|
|
+```
|
|
|
+
|
|
|
+Expected: JSON `code:200`,含 `nodes` 数组;错误 id 返回 404。
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit(若要求提交)**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/api/production.py
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat: expose cell product lineage-qc API endpoint
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: 前端 — API 封装
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/api/cell-product.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: 追加类型与方法**
|
|
|
+
|
|
|
+在 `fetchCellProductChain` 附近增加:
|
|
|
+
|
|
|
+```typescript
|
|
|
+export type CellProductLineageQcTask = {
|
|
|
+ id?: string
|
|
|
+ taskNo?: string
|
|
|
+ itemName?: string
|
|
|
+ itemCode?: string
|
|
|
+ taskType?: string
|
|
|
+ taskStatus?: string
|
|
|
+ conclusion?: string
|
|
|
+ conclusionLabel?: string
|
|
|
+ remark?: string
|
|
|
+ completedTime?: string
|
|
|
+ companyName?: string
|
|
|
+ results?: Array<{ paramName?: string; paramValue?: string; paramUnit?: string }>
|
|
|
+ attachmentFiles?: Array<{ fileName?: string; fileUrl?: string; fileType?: string }>
|
|
|
+}
|
|
|
+
|
|
|
+export type CellProductLineageQcWorkOrder = {
|
|
|
+ id?: string
|
|
|
+ workOrderNo?: string
|
|
|
+ workOrderType?: string
|
|
|
+ workOrderStatus?: string
|
|
|
+ workOrderStatusLabel?: string
|
|
|
+ tasks?: CellProductLineageQcTask[]
|
|
|
+}
|
|
|
+
|
|
|
+export type CellProductLineageQcNode = {
|
|
|
+ nodeType?: 'sample' | 'cell_product' | string
|
|
|
+ id?: string
|
|
|
+ label?: string
|
|
|
+ productName?: string
|
|
|
+ passage?: string | null
|
|
|
+ receptionSource?: string | null
|
|
|
+ receptionSourceLabel?: string | null
|
|
|
+ isCurrent?: boolean
|
|
|
+ workOrders?: CellProductLineageQcWorkOrder[]
|
|
|
+}
|
|
|
+
|
|
|
+export type CellProductLineageQc = {
|
|
|
+ productId?: string
|
|
|
+ productCode?: string
|
|
|
+ productName?: string
|
|
|
+ nodes?: CellProductLineageQcNode[]
|
|
|
+}
|
|
|
+
|
|
|
+export function fetchCellProductLineageQc(productId: string) {
|
|
|
+ return request.get<CellProductLineageQc>({
|
|
|
+ url: `/web/admin/cell-products/${productId}/lineage-qc`
|
|
|
+ })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Commit(若要求提交)**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/api/cell-product.ts
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat: add fetchCellProductLineageQc API client
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: 前端 — `CellProductLineageQcDialog`
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `web-admin/src/components/business/cell-product-lineage-qc-dialog/index.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建弹窗组件**
|
|
|
+
|
|
|
+实现要点:
|
|
|
+
|
|
|
+```vue
|
|
|
+<!-- 细胞产品 — 来源链质检只读 -->
|
|
|
+<template>
|
|
|
+ <el-dialog
|
|
|
+ :model-value="modelValue"
|
|
|
+ title="来源链质检"
|
|
|
+ width="1040px"
|
|
|
+ class="dialog-form-layout cell-lineage-qc-dialog"
|
|
|
+ align-center
|
|
|
+ append-to-body
|
|
|
+ modal-class="protocol-dialog-mask"
|
|
|
+ destroy-on-close
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ style="backdrop-filter: none"
|
|
|
+ @update:model-value="emit('update:modelValue', $event)"
|
|
|
+ @closed="resetState"
|
|
|
+ >
|
|
|
+ <div v-loading="loading" class="lineage-qc-body">
|
|
|
+ <div v-if="data" class="lineage-qc-summary">
|
|
|
+ <span class="lineage-qc-summary__code">{{ data.productCode || '—' }}</span>
|
|
|
+ <span class="lineage-qc-summary__name">{{ data.productName || '—' }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-empty v-if="!loading && (!data?.nodes || !data.nodes.length)" description="暂无来源链数据" />
|
|
|
+
|
|
|
+ <el-collapse v-else v-model="activeNames" class="lineage-qc-collapse">
|
|
|
+ <el-collapse-item
|
|
|
+ v-for="(node, idx) in data?.nodes || []"
|
|
|
+ :key="`${node.nodeType}-${node.id}-${idx}`"
|
|
|
+ :name="String(idx)"
|
|
|
+ >
|
|
|
+ <template #title>
|
|
|
+ <div class="lineage-qc-node-title">
|
|
|
+ <el-tag v-if="node.isCurrent" type="success" size="small">当前</el-tag>
|
|
|
+ <el-tag v-if="node.nodeType === 'sample'" type="info" size="small">样本</el-tag>
|
|
|
+ <el-tag v-else type="primary" size="small">细胞产品</el-tag>
|
|
|
+ <span class="lineage-qc-node-title__label">{{ node.label || '—' }}</span>
|
|
|
+ <span v-if="node.productName" class="lineage-qc-node-title__sub">{{ node.productName }}</span>
|
|
|
+ <span v-if="node.passage" class="lineage-qc-node-title__sub">代次 {{ node.passage }}</span>
|
|
|
+ <span v-if="node.receptionSourceLabel" class="lineage-qc-node-title__sub">
|
|
|
+ {{ node.receptionSourceLabel }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <div v-if="!(node.workOrders || []).length" class="lineage-qc-empty">暂无质检工单</div>
|
|
|
+ <div v-for="wo in node.workOrders || []" :key="wo.id" class="lineage-qc-wo">
|
|
|
+ <div class="lineage-qc-wo__head">
|
|
|
+ <span>{{ wo.workOrderNo || '—' }}</span>
|
|
|
+ <el-tag v-if="wo.workOrderStatus" size="small">{{ wo.workOrderStatusLabel || wo.workOrderStatus }}</el-tag>
|
|
|
+ </div>
|
|
|
+ <QcWorkOrderTaskResultPanel :tasks="mapTasks(wo.tasks)" />
|
|
|
+ </div>
|
|
|
+ </el-collapse-item>
|
|
|
+ </el-collapse>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template #footer>
|
|
|
+ <el-button class="btn-ghost" @click="emit('update:modelValue', false)">
|
|
|
+ <el-icon><Close /></el-icon>
|
|
|
+ 关闭
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+</template>
|
|
|
+```
|
|
|
+
|
|
|
+Script 要点:
|
|
|
+
|
|
|
+- props: `modelValue: boolean`, `productId: string`
|
|
|
+- watch `[modelValue, productId]` → 打开且有 id 时 `fetchCellProductLineageQc`
|
|
|
+- `activeNames` 默认 `nodes.map((_, i) => String(i))`(全部展开)
|
|
|
+- `mapTasks`:把 API task 转成 `QcWorkOrderTaskResultPanel` 所需形状(含 `results`、`attachmentFiles`;`materialUsages` 不传)
|
|
|
+- 导入:`Close`、`fetchCellProductLineageQc`、`QcWorkOrderTaskResultPanel`(路径 `@/views/quality-inspection/qc-work-order/QcWorkOrderTaskResultPanel.vue`)
|
|
|
+- 样式:Fresh Mint;节点标题 flex + gap;工单卡片边框圆角与物料弹窗 section-card 一致
|
|
|
+
|
|
|
+- [ ] **Step 2: Commit(若要求提交)**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/components/business/cell-product-lineage-qc-dialog/index.vue
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat: add CellProductLineageQcDialog readonly panel
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 6: 接入出库物料准备弹窗
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/views/pilot-service/outbound-release-review/OutboundReleaseMaterialDialog.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 操作列增加「查看质检」**
|
|
|
+
|
|
|
+在出库细胞表格操作列模板中,删除按钮前增加:
|
|
|
+
|
|
|
+```vue
|
|
|
+<el-button type="primary" link @click="openLineageQc(row)">
|
|
|
+ <el-icon><View /></el-icon>
|
|
|
+ 查看质检
|
|
|
+</el-button>
|
|
|
+```
|
|
|
+
|
|
|
+- 操作列 `min-width` 调到 `180` 或 `200`
|
|
|
+- `View` 已从 `@element-plus/icons-vue` 导入则复用;否则补 import
|
|
|
+- 状态:
|
|
|
+
|
|
|
+```typescript
|
|
|
+const lineageQcVisible = ref(false)
|
|
|
+const lineageQcProductId = ref('')
|
|
|
+
|
|
|
+function openLineageQc(row: OutboundReleaseCellRow) {
|
|
|
+ const id = String(row.cellProductId || '')
|
|
|
+ if (!id) {
|
|
|
+ ElMessage.warning('缺少细胞产品信息')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ lineageQcProductId.value = id
|
|
|
+ lineageQcVisible.value = true
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- 模板根内(主 dialog 外层已有 `<div>`)追加:
|
|
|
+
|
|
|
+```vue
|
|
|
+<CellProductLineageQcDialog
|
|
|
+ v-model="lineageQcVisible"
|
|
|
+ :product-id="lineageQcProductId"
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+- import:`import CellProductLineageQcDialog from '@/components/business/cell-product-lineage-qc-dialog/index.vue'`
|
|
|
+
|
|
|
+- [ ] **Step 2: 浏览器验收**
|
|
|
+
|
|
|
+1. 打开 `/#/market/basic-experiment/outbound-release-review` → 出库物料准备
|
|
|
+2. 添加有来源链的细胞产品 → 点「查看质检」
|
|
|
+3. 确认:独立弹窗、节点上游→当前、任务参数/结论/备注/附件、未完成状态可见
|
|
|
+4. 关闭质检弹窗后物料准备仍可继续编辑并保存
|
|
|
+
|
|
|
+- [ ] **Step 3: 更新知识图谱**
|
|
|
+
|
|
|
+```bash
|
|
|
+./scripts/update-graph.sh frontend
|
|
|
+./scripts/update-graph.sh backend
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit(若要求提交)**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/views/pilot-service/outbound-release-review/OutboundReleaseMaterialDialog.vue web-admin/graphify-out backend/graphify-out
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat: wire lineage QC viewer into outbound material dialog
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Spec Coverage Checklist
|
|
|
+
|
|
|
+| Spec 要求 | Task |
|
|
|
+|-----------|------|
|
|
|
+| 整条来源链(产品+样本) | Task 1–2 |
|
|
|
+| 全部任务状态 | Task 2(不过滤) |
|
|
|
+| 参数/结论/备注/附件 | Task 2 slim task + Task 5 panel |
|
|
|
+| 行按钮独立弹窗 | Task 5–6 |
|
|
|
+| 实时查询不落库 | 全程 GET,不改 save |
|
|
|
+| 不展示物料使用 | Task 5 不传 usages;全局 flag 已为 false |
|
|
|
+| 批量避免 N+1 | Task 2 `list_tasks_by_work_order_ids` + `list_saved_results_by_task_ids` |
|
|
|
+| 权限可进物料准备即可 | Task 3 `require_admin_or_super_admin` |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Self-Review Notes
|
|
|
+
|
|
|
+- 检测参数:现有工单列表任务**不含** `results`,必须走 `list_saved_results_by_task_ids`(已写入 Task 2),避免前端再 N 次 `fetchQcTaskResults`。
|
|
|
+- `list_tasks_by_work_order_ids` 的 100 上限已要求分块。
|
|
|
+- 未改 `outbound-material-form-sections`:当前放行页使用 `OutboundReleaseMaterialDialog`;若联调发现实际渲染走共用组件,在 Task 6 改为改共用组件并两边验证。
|