Bladeren bron

docs: add lineage QC design/plan and pass project fields to material dialog

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 maanden geleden
bovenliggende
commit
56cee052d4

+ 927 - 0
docs/superpowers/plans/2026-07-29-outbound-cell-lineage-qc.md

@@ -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 改为改共用组件并两边验证。

+ 247 - 0
docs/superpowers/specs/2026-07-29-outbound-cell-lineage-qc-design.md

@@ -0,0 +1,247 @@
+# 出库物料准备 — 细胞产品来源链质检查看设计
+
+> 日期:2026-07-29  
+> 状态:已确认(brainstorming)  
+> 方案:A(后端聚合接口 + 独立只读弹窗)  
+> 入口:出库放行审核 → 出库物料准备 → 出库细胞列表
+
+## 1. 需求概述
+
+在「出库物料准备」弹窗的「出库细胞」列表中,用户可通过行操作查看所选细胞产品相关的质检信息,用于放行前核对。
+
+必须展示:
+
+- 质检任务的**检测参数**、**结论**、**备注**、**附件**(业务所称「检测材料」= 质检任务附件)
+- **整条来源链**上的质检:祖先细胞产品 + 上游样本相关质检(不仅当前产品)
+- **全部任务状态**(待配置 / 进行中 / 已完成等,含未完成)
+
+数据策略:**实时查询**,不写入出库台账快照;质检事后变更后再次打开可见最新结果。
+
+交互:主列表保持简洁;行上「查看质检」打开**独立弹窗**(非行内展开)。
+
+### 1.1 已确认决策
+
+| 项 | 选择 |
+|----|------|
+| 父级范围 | B:整条来源链(祖先细胞产品 + 上游样本) |
+| 任务状态 | B:全部状态 |
+| 检测材料 | 质检任务附件(非出库 `qcMaterials` 库存扣减,非实验室物料使用明细) |
+| 交互 | B:行按钮 → 独立弹窗 |
+| 数据 | A:实时查询 |
+| 实现路径 | A:后端聚合 API + 只读弹窗 |
+
+### 1.2 非目标(本期不做)
+
+- 出库保存时落质检快照
+- 行内展开 / 添加细胞时自动弹出质检
+- 改造细胞库来源链图谱(`/chain` 可视化)
+- 展示实验室任务「物料使用」库存明细
+- 同步改造出库审核弹窗 / 查看出库详情(若需要另开需求)
+- 前端拼装多次现有接口作为主方案
+
+## 2. 背景与现有能力
+
+### 2.1 传代 / 来源链
+
+`biz_cell_product` **无** `parent_id`。父子关系来自生产任务投入/产出:
+
+- 祖先:`get_product_chain` → `_build_ancestor_chain`(`backend/app/crud/cell_product.py`)
+- 前端已有:`fetchCellProductChain` → `GET /web/admin/cell-products/{id}/chain`
+- 设计参考:`docs/superpowers/specs/2026-05-24-cell-product-source-chain-design.md`
+
+本需求只消费 **current + ancestors**,**不取 descendants**。
+
+### 2.2 质检关联
+
+- 细胞产品:`BizQcWorkOrder.cell_product_id`
+- 样本入库质检:`list_work_orders_by_sample`(`sample_code` + `reception_source`,`work_order_type=inbound`)
+- 任务详情字段:`results`(参数)、`conclusion` / `remark`、`attachmentFiles`(由附件 URL 解析)
+- 只读 UI 参考:`web-admin/.../QcWorkOrderTaskResultPanel.vue`
+
+### 2.3 出库物料弹窗现状
+
+- 组件:`OutboundReleaseMaterialDialog.vue`
+- 出库细胞仅存 `cellProductId` 等台账字段;保存逻辑**不因本需求变更**
+
+## 3. 架构与数据流
+
+```
+出库细胞行 [查看质检]
+        │
+        ▼
+GET /web/admin/cell-products/{id}/lineage-qc
+        │
+        ├─ get_product_chain(复用)→ current + ancestors
+        ├─ 展平节点(根→叶,当前最后;按 nodeType+id 去重)
+        ├─ cell_product 节点:按 cell_product_id 查工单(不限 work_order_type)
+        ├─ sample 节点:按 sampleCode + receptionSource 查入库质检
+        └─ 工单批量拉任务详情(参数/结论/备注/附件;不过滤状态)
+        │
+        ▼
+el-dialog「来源链质检」只读展示
+```
+
+## 4. 后端设计
+
+### 4.1 接口
+
+- **方法/路径**:`GET /web/admin/cell-products/{product_id}/lineage-qc`
+- **注册**:与 `/chain` 同域(`backend/app/api/production.py`)
+- **编排**:`get_product_lineage_qc`(建议在 `cell_product.py`,内部复用 qc CRUD,禁止复制序列化逻辑)
+
+### 4.2 响应骨架
+
+```json
+{
+  "productId": "...",
+  "productCode": "...",
+  "productName": "...",
+  "nodes": [
+    {
+      "nodeType": "sample | cell_product",
+      "id": "...",
+      "label": "样本编码或产品编码",
+      "productName": "...",
+      "passage": "P1",
+      "receptionSource": "cell_storage | research_donation | null",
+      "isCurrent": false,
+      "workOrders": [
+        {
+          "id": "...",
+          "workOrderNo": "...",
+          "workOrderType": "...",
+          "workOrderStatus": "...",
+          "workOrderStatusLabel": "...",
+          "tasks": [
+            {
+              "id": "...",
+              "taskNo": "...",
+              "itemName": "...",
+              "itemCode": "...",
+              "taskType": "...",
+              "taskStatus": "...",
+              "conclusion": "...",
+              "conclusionLabel": "...",
+              "remark": "...",
+              "completedTime": "...",
+              "results": [
+                { "paramName": "", "paramValue": "", "paramUnit": "" }
+              ],
+              "attachmentFiles": [
+                { "fileName": "", "fileUrl": "", "fileType": "" }
+              ]
+            }
+          ]
+        }
+      ]
+    }
+  ]
+}
+```
+
+字段命名与现有质检详情 camelCase 对齐,便于前端复用 Tag/附件组件。
+
+### 4.3 查询规则
+
+1. **链路**:`get_product_chain`;展平 nested ancestor `parent`;深度上限沿用 `_MAX_CHAIN_DEPTH`。
+2. **细胞产品工单**:`cell_product_id = node.id`,`del_flag=0`,租户过滤;**不限** `work_order_type`。
+3. **样本工单**:对齐 `list_work_orders_by_sample`;`reception_source` 缺失时该节点 `workOrders: []`,不 500。
+4. **任务**:复用 inspection batch → task → result / attachments 拼装;**不过滤**任务状态。
+5. **性能(一期)**:批量查工单与任务,避免 N 次串行 `get_work_order_detail`;全量一次返回。二期可按节点懒加载。
+
+### 4.4 错误与边界
+
+| 情况 | 行为 |
+|------|------|
+| 产品不存在 / 跨租户 | 业务 400/404,文案明确 |
+| 无祖先(如手动录入) | `nodes` 仅含 current |
+| 节点无工单 | 仍返回节点,`workOrders: []` |
+| 单节点加载异常 | 该节点空列表 + 可选 `loadError`,不拖垮整响应 |
+| 附件 URL 坏项 | 跳过该项 |
+
+### 4.5 权限
+
+- 能进入「出库物料准备」的用户应能调用本只读接口。
+- 实现时使用 `require_any_menu_code`:**任一**匹配即可——出库放行相关菜单码,或质检工单列表码,或细胞产品查看码(具体码表在实现计划中对照菜单 SQL 写死)。
+- 租户隔离强制。数据范围:优先按工单 `tenant_no` 过滤;若套用质检 data scope 会导致「能准备出库物料却看不到质检」则本期对放行入口放宽为租户内只读(实现计划写明最终策略)。
+
+## 5. 前端设计
+
+### 5.1 入口
+
+- 文件:`OutboundReleaseMaterialDialog.vue`(及若共用的 `outbound-material-form-sections`,以实现时是否共用为准;**至少**出库放行物料弹窗落地)
+- 出库细胞操作列增加「查看质检」(`View` 图标 + 文案);保留「删除」
+- 操作列 `min-width` 约 ≥ 180px
+- 仅存在 `cellProductId` 时可点
+
+### 5.2 弹窗组件
+
+- 新建 `CellProductLineageQcDialog.vue`(建议 `components/business/` 或 `outbound-release-review/`)
+- `el-dialog` + `dialog-form-layout`,宽约 960–1120px,`append-to-body`,无 backdrop blur
+- 标题:`来源链质检`;副信息:当前产品编码 / 名称
+- 打开时请求 `lineage-qc`;`v-loading`;失败 `ElMessage.error`
+- 底栏仅「关闭」(Close 图标)
+
+### 5.3 信息架构
+
+自上而下:
+
+1. 当前产品摘要条(编码 · 名称 · 代次)
+2. 按 `nodes` 分区(可折叠;默认均展开)
+   - 样本:编码 + 来源标签
+   - 产品:编码、名称、代次;当前节点 Tag「当前」+ 主色强调
+3. 工单卡片:工单号 · 类型 · 状态 Tag
+4. 任务卡片:对齐 `QcWorkOrderTaskResultPanel` 只读结构
+   - 项目名、任务状态、结论 Tag
+   - 检测参数
+   - 备注(有则显示)
+   - 附件(预览/下载,对齐现有附件卡片)
+5. 空态:节点无工单 →「暂无质检工单」;整单无数据 → 友好空状态
+
+节点顺序:**上游 → 下游**(当前产品在最后)。
+
+### 5.4 API 封装
+
+- `web-admin/src/api/cell-product.ts` 增加 `fetchCellProductLineageQc(productId)`
+
+### 5.5 UI 规范
+
+- Fresh Mint;状态/结论用 `el-tag` + `status-tag-registry`
+- 按钮带图标;遮罩无模糊
+
+## 6. 测试与验收
+
+### 6.1 后端
+
+- 仅 current、无祖先
+- P1→P2→当前,顺序与挂载正确
+- 链上样本节点挂入库质检
+- 多状态任务与参数/结论/备注/附件齐全
+- 产品不存在、租户隔离、深度截断
+
+### 6.2 前端
+
+- 独立弹窗不打断主弹窗编辑
+- loading / 错误 / 空态
+- 附件可预览下载
+- 操作列不被裁切
+
+### 6.3 验收(产品语言)
+
+1. 出库细胞行可打开「来源链质检」,列表保持简洁  
+2. 可见该产品 + 全部上游样本/祖先产品的质检任务  
+3. 每任务可见检测参数、结论、备注、附件;全部状态可见  
+4. 再次打开为实时数据;不写入出库台账  
+
+## 7. 实现顺序建议
+
+1. 后端:`get_product_lineage_qc` + 路由 + 单测(展平/样本键/批量)  
+2. 前端 API + `CellProductLineageQcDialog`  
+3. 接入 `OutboundReleaseMaterialDialog` 操作列  
+4. 联调验收清单  
+
+## 8. 开放项(实现计划阶段闭合)
+
+- 精确菜单权限码列表(对照 `docs/sql` 菜单)  
+- 组件最终目录(`components/business` vs 业务目录)  
+- 若 `outbound-material-form-sections` 与放行弹窗分叉,是否两边都接入口(默认:放行物料弹窗必做;共用组件则一并改)

+ 4 - 0
web-admin/src/views/pilot-service/outbound-release-review/index.vue

@@ -242,6 +242,8 @@
   const materialScheduleSummary = ref<{
     applicationNo?: string
     patientName?: string
+    projectTitle?: string
+    customerName?: string
     infusionTime?: string
   }>()
 
@@ -250,6 +252,8 @@
     materialScheduleSummary.value = {
       applicationNo: row.applicationNo,
       patientName: row.patientName,
+      projectTitle: row.projectTitle,
+      customerName: row.customerName,
       infusionTime: row.infusionTime
     }
     materialDialogVisible.value = true