zlwu 5 ヶ月 前
親
コミット
41aa90aa05
100 ファイル変更、7260 行追加、1761 行削除
  1. 1 1
      backend/app.pid
  2. 138 48
      backend/app/api/outbound_release_review.py
  3. 2 3
      backend/app/api/pilot_application.py
  4. 61 0
      backend/app/api/pilot_infusion_schedule_mgmt.py
  5. 245 0
      backend/app/crud/outbound_handover.py
  6. 307 0
      backend/app/crud/outbound_handover_inventory.py
  7. 431 0
      backend/app/crud/outbound_release_material.py
  8. 552 82
      backend/app/crud/outbound_release_review.py
  9. 12 0
      backend/app/crud/pilot_application.py
  10. 4 0
      backend/app/crud/pilot_delivery.py
  11. 31 0
      backend/app/crud/pilot_infusion_confirm.py
  12. 77 1
      backend/app/crud/pilot_infusion_schedule_mgmt.py
  13. 213 0
      backend/app/crud/pilot_outbound_status_sync.py
  14. 6 0
      backend/app/crud/production_work_order.py
  15. 6 0
      backend/app/crud/qc_work_order.py
  16. 0 0
      backend/app/graphify-out/cache/ast/049dff20cbc0ffb702db017e5ae150b557aeb5d43bf496a747f195accfa37d00.json
  17. 0 0
      backend/app/graphify-out/cache/ast/08599d03b7c9518d133fec21caf5cae4b1db7f5806553db50982ee4cf31ad23c.json
  18. 0 0
      backend/app/graphify-out/cache/ast/0aa11781f470b5521698fdaa3e5ca3a474104ced45afa5e3ba3e7452be731235.json
  19. 0 0
      backend/app/graphify-out/cache/ast/189561d19e545046c8ee2c1beae9b5f867b806cc1feee8c2040b71abb2e25e5d.json
  20. 0 0
      backend/app/graphify-out/cache/ast/1e13ae9e8dea00e1cef6be684bc9819b7034a6ab9e31eea883b5cf0d59b83697.json
  21. 0 0
      backend/app/graphify-out/cache/ast/2685b36fe9c3efd29481be03ab7745a44c20e882af2ec956a3d6c08fc9c41fd3.json
  22. 0 0
      backend/app/graphify-out/cache/ast/26b58166a73eb3fcb00700de56e76062696091c1852f4050fe19f0648ec81e8a.json
  23. 0 0
      backend/app/graphify-out/cache/ast/2c20aa2d11696a8336dc39571906d3ae8eec9758e1bd38a352ef5993eab71156.json
  24. 0 0
      backend/app/graphify-out/cache/ast/33f716d0078b7b0cdf9417652e18942c21f5aa3900b406c9d23376ae3e02e163.json
  25. 0 0
      backend/app/graphify-out/cache/ast/4d0d3931ae3b99efad229aa34c07c74272925ed9eb8223fca89c931e9615c0b7.json
  26. 0 0
      backend/app/graphify-out/cache/ast/61b7212932cf096babef6386c1661f6eeb703405b08483b3451cabc1f29ac22f.json
  27. 0 0
      backend/app/graphify-out/cache/ast/65b0d09b11c300f9a77985fb24b1c28b4a8585f3519d4a56ebe2ecbf8ac2b0cf.json
  28. 0 0
      backend/app/graphify-out/cache/ast/68b1e24f37b1bc6566b12da411806b7df78d3c0b29561e1d11b8048307df0369.json
  29. 0 0
      backend/app/graphify-out/cache/ast/68d8ed1dee192449d32369d9be96849873e9028b75da6e9841d29849b385a931.json
  30. 0 0
      backend/app/graphify-out/cache/ast/73627e17a16e8f52dd9f1c230502eee2820a67c7cfe70ff78d96e21e2e9bdf32.json
  31. 0 0
      backend/app/graphify-out/cache/ast/795c730de8c2c8f273959facbf37e33340fb1812a7044af3f1141cb32899c850.json
  32. 0 0
      backend/app/graphify-out/cache/ast/7c39c236db7cf30efabba64ae701ddde2f5dd839216c75e6d2ea7ea372d37074.json
  33. 0 0
      backend/app/graphify-out/cache/ast/857589f18e57aa2db8db04f16bda645a2871bf15679bbf8a1392bed2aec16535.json
  34. 0 0
      backend/app/graphify-out/cache/ast/943a933a343d1fd2d671b59a3d736d7bd265a0b3056f9275c5b0e34a729bdbde.json
  35. 0 0
      backend/app/graphify-out/cache/ast/95b3ad8055cb50d8ec52b6465518f4518cb0145a80bf67b2bff0cc19705c3385.json
  36. 0 0
      backend/app/graphify-out/cache/ast/9f6683c21c0bef574e35606dd98c48c8c71bf85159589650bf8761056200768a.json
  37. 0 0
      backend/app/graphify-out/cache/ast/b95f2eb206b8f6622e2db5e01baa14bd9e3e34097f5e314b52bd88db4d367efa.json
  38. 0 0
      backend/app/graphify-out/cache/ast/c92d5136903da42fc6dc3a810b5c9d3adeaccbd2f3bb686cdbb0623e6a7cb180.json
  39. 0 0
      backend/app/graphify-out/cache/ast/cdd760331699d3672f900d6de7fe797c58ed15f0c3d112cddeb5ddd39ccd953f.json
  40. 0 0
      backend/app/graphify-out/cache/ast/ce4f6cc817e6428294c0b563a5b61f1131962c67a7259ee833ac74310bce89fb.json
  41. 0 0
      backend/app/graphify-out/cache/ast/d2f7821d1155a66f0afd35423490b500fbafc3ac30300cabc40ada74bbd50528.json
  42. 119 0
      backend/app/models/models.py
  43. 23 0
      backend/app/schemas/outbound_handover.py
  44. 45 0
      backend/app/schemas/outbound_release_material.py
  45. 31 0
      backend/app/schemas/outbound_release_review.py
  46. 324 394
      backend/graphify-out/.graphify_analysis.json
  47. 359 100
      backend/graphify-out/.graphify_extract.json
  48. 108 173
      backend/graphify-out/GRAPH_REPORT.md
  49. 1 1
      backend/graphify-out/graph.html
  50. 112 112
      backend/graphify-out/graph.json
  51. 18 11
      backend/graphify-out/manifest.json
  52. 23 0
      docs/sql/outbound_handover.sql
  53. 71 0
      docs/sql/outbound_release_material.sql
  54. 22 0
      docs/sql/outbound_release_paper_material.sql
  55. 4 0
      docs/sql/outbound_release_review_add_fields.sql
  56. 349 0
      docs/superpowers/specs/2026-05-27-infusion-schedule-outbound-handover-design.md
  57. 284 0
      docs/superpowers/specs/2026-05-27-outbound-release-material-design.md
  58. 2 0
      openspec/changes/outbound-release-review/.openspec.yaml
  59. 54 0
      openspec/changes/outbound-release-review/design.md
  60. 29 0
      openspec/changes/outbound-release-review/proposal.md
  61. 71 0
      openspec/changes/outbound-release-review/specs/outbound-release-review-api/spec.md
  62. 68 0
      openspec/changes/outbound-release-review/specs/outbound-release-review-ui/spec.md
  63. 43 0
      openspec/changes/outbound-release-review/tasks.md
  64. 419 554
      web-admin/graphify-out/.graphify_analysis.json
  65. 474 143
      web-admin/graphify-out/.graphify_extract.json
  66. 117 99
      web-admin/graphify-out/GRAPH_REPORT.md
  67. 1 1
      web-admin/graphify-out/graph.html
  68. 28 19
      web-admin/graphify-out/manifest.json
  69. 0 13
      web-admin/src/api/admin.ts
  70. 60 0
      web-admin/src/api/infusion-schedule-handover.ts
  71. 219 0
      web-admin/src/api/outbound-release-review.ts
  72. 682 0
      web-admin/src/components/business/infusion-schedule-outbound-chain-view/index.vue
  73. 977 0
      web-admin/src/components/business/outbound-material-form-sections/index.vue
  74. 6 3
      web-admin/src/components/business/qc-task-view-dialog/index.vue
  75. 7 1
      web-admin/src/components/business/sample-qc-info-cards/index.vue
  76. 9 2
      web-admin/src/components/business/system-view-dialog/index.vue
  77. 0 0
      web-admin/src/graphify-out/cache/ast/0edd9b6b6b1cec54db798cae4810f2b31cbef01029a6c5003e9ffa249d786ef2.json
  78. 1 0
      web-admin/src/graphify-out/cache/ast/13cd88a45016eeced320861b0fe9d858da3cd6ab84c57a16bd7de07f0d4f4808.json
  79. 0 0
      web-admin/src/graphify-out/cache/ast/2588828b1cfdc37a1853a55ef18845e88c224e3c4a4a7be211ef6af2dcb3e750.json
  80. 0 0
      web-admin/src/graphify-out/cache/ast/3625267039d2410cddccecf83814ece9a0127351267bd142de4a223fab91d0ca.json
  81. 0 0
      web-admin/src/graphify-out/cache/ast/3d7d8ea1a7392766f77531d4f9b51064ed8b397f3381c862516cf35b7bd92b41.json
  82. 1 0
      web-admin/src/graphify-out/cache/ast/45e753a089a9efe57113d34da758451e33c25645912fcb67e93081c69cb9e77e.json
  83. 0 0
      web-admin/src/graphify-out/cache/ast/592ffa0a02e1eb34982e64a304455bf8b03188dd5441cb745ce23f0e0de88605.json
  84. 1 0
      web-admin/src/graphify-out/cache/ast/66b02e04f2a671a74142e5301c5e90b898bd7d51d0207b3682c5fc3f21049bba.json
  85. 1 0
      web-admin/src/graphify-out/cache/ast/7899d5657daccc895a3538887050d2933c855f8d9350e3f2c0c454c929d4aa4b.json
  86. 1 0
      web-admin/src/graphify-out/cache/ast/8116c7788caa36899de1a6bc4b7e75723af2a4dfc05c21e233c437010297fb15.json
  87. 1 0
      web-admin/src/graphify-out/cache/ast/82c7e35f3f542c9c66108690750b032ab2dce4fbb479c315b13e6c9cc9fe75a5.json
  88. 1 0
      web-admin/src/graphify-out/cache/ast/9a6ab390efc7b37afa9440f34291e24a681626306c97d9c4b9c1ab31f92e5ffe.json
  89. 1 0
      web-admin/src/graphify-out/cache/ast/9f313beb742ef8dc3d5df59f5e69d4e5843b8298a1607d0f0dd11a9e3dadf022.json
  90. 1 0
      web-admin/src/graphify-out/cache/ast/a42b32dfa1d5d691c120e92c585342c55203d82af0ff97db0d60779b585c27ac.json
  91. 1 0
      web-admin/src/graphify-out/cache/ast/aa71e74f935c5d8f33d9ee4d060f1d367d754e3dec8634fcf2f1d487099e981c.json
  92. 1 0
      web-admin/src/graphify-out/cache/ast/afdc060cb85e6032e03741e4b993e1b71d64edea4d0313157148f41342875469.json
  93. 0 0
      web-admin/src/graphify-out/cache/ast/b07af941e6127670464402c8c0ef1c02234c698e52a17acf5896c49c79b17152.json
  94. 1 0
      web-admin/src/graphify-out/cache/ast/b08866dd4d5a36b03f33dc45bae64a9dbd7da511083f2d92cc9743c6aa9c4748.json
  95. 0 0
      web-admin/src/graphify-out/cache/ast/b42860a3b47456bb11251f3a163297982d551d72826ee4b7a2c931ef7d562b28.json
  96. 1 0
      web-admin/src/graphify-out/cache/ast/b844be3aee52e3c7e231da342c98f7620782af72bc80ccabbb205899972966f5.json
  97. 0 0
      web-admin/src/graphify-out/cache/ast/bf3b2c12cbb2deffec550e9007e2417a3e7b5271a24313b3c2e0118d443414f3.json
  98. 1 0
      web-admin/src/graphify-out/cache/ast/d5e94ea93839553f8e45318241fa5556c97985fb829d4ca122971f7d3c449bcd.json
  99. 1 0
      web-admin/src/graphify-out/cache/ast/dba0a6d9c7b180a231671f2fb6d8006d5013ef805c03fd742121bccbf349fa69.json
  100. 0 0
      web-admin/src/graphify-out/cache/ast/dca657caf1c62d2355196f294efcd6022bce2cd7837c9850c6a7cec31c4fe679.json

+ 1 - 1
backend/app.pid

@@ -1 +1 @@
-91471
+53718

+ 138 - 48
backend/app/api/outbound_release_review.py

@@ -1,13 +1,20 @@
 """出库放行审核 API"""
+from typing import Optional
+
 from fastapi import APIRouter, Depends, Query
-from pydantic import BaseModel
 from sqlalchemy.orm import Session
 
 from app.core.database import get_db
-from app.core.menu_guard import require_admin_or_super_admin
+from app.core.menu_guard import require_admin_or_super_admin, require_menu_code
 from app.core.response import error_response, success_response
 from app.crud import outbound_release_review as svc
 from app.models.models import SysUser
+from app.schemas.outbound_release_review import (
+    OutboundBatchReviewBody,
+    OutboundReviewSubmitBody,
+)
+from app.schemas.outbound_release_material import OutboundReleaseMaterialSaveBody
+from app.crud import outbound_release_material as orm_svc
 
 router = APIRouter(prefix="/web/admin", tags=["出库放行审核"])
 
@@ -22,86 +29,169 @@ def _get_tenant_no(db: Session, user: dict) -> str:
     return sys_user.tenant_no
 
 
-class CreateBody(BaseModel):
-    scheduleId: str
+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 UpdateBody(BaseModel):
-    id: str
-    reviewConclusion: str
-    reviewRemark: str = ""
+@router.get("/pilot-release-reviews")
+async def list_pilot_release_reviews(
+    page: int = Query(1, ge=1),
+    pageSize: int = Query(10, ge=1, le=100),
+    keyword: Optional[str] = None,
+    reviewConclusion: Optional[str] = None,
+    scheduleStatus: Optional[str] = None,
+    qcStatus: Optional[str] = None,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:outboundReleaseReview:list")),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        result = svc.list_reviews_paginated(
+            db,
+            tenant_no=tenant_no,
+            keyword=keyword,
+            review_conclusion=reviewConclusion,
+            schedule_status=scheduleStatus,
+            qc_status=qcStatus,
+            page=page,
+            page_size=pageSize,
+            user_info=user,
+        )
+        return success_response(result)
+    except PermissionError as e:
+        return error_response(403, str(e))
+    except Exception as e:
+        return error_response(400, str(e))
 
 
-class IdBody(BaseModel):
-    id: str
+@router.get("/pilot-release-reviews/{schedule_id}/detail")
+async def get_pilot_release_review_detail(
+    schedule_id: str,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:outboundReleaseReview:list")),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        detail = svc.get_schedule_detail(db, schedule_id, tenant_no)
+        if not detail:
+            return error_response(404, "输注计划不存在")
+        return success_response(detail)
+    except PermissionError as e:
+        return error_response(403, str(e))
+    except Exception as e:
+        return error_response(400, str(e))
 
 
-@router.post("/pilot-release-review/create")
-async def create_review(
-    body: CreateBody,
-    user: dict = Depends(require_admin_or_super_admin),
+@router.put("/pilot-release-reviews/{schedule_id}/review")
+async def submit_single_review(
+    schedule_id: str,
+    body: OutboundReviewSubmitBody,
     db: Session = Depends(get_db),
+    user: dict = Depends(require_admin_or_super_admin),
 ):
     try:
         tenant_no = _get_tenant_no(db, user)
-        result = svc.create_release_review(
+        user_id = _get_current_user_id(user)
+        result = svc.review_schedule(
             db,
-            schedule_id=body.scheduleId,
+            schedule_id,
+            conclusion=body.reviewConclusion,
+            remark=body.reviewRemark,
+            attachments=body.reviewAttachments,
             tenant_no=tenant_no,
-            user_id=user.get("user_id"),
-            create_dept_id=user.get("region_id", ""),
+            user_id=user_id,
         )
         db.commit()
-        return success_response(result, message="创建成功")
+        return success_response(result)
+    except PermissionError as e:
+        return error_response(403, str(e))
     except ValueError as e:
         return error_response(400, str(e))
+    except Exception as e:
+        return error_response(400, str(e))
 
 
-@router.get("/pilot-release-review/list")
-async def list_reviews(
-    scheduleId: str = Query(...),
-    user: dict = Depends(require_admin_or_super_admin),
+@router.put("/pilot-release-reviews/batch-review")
+async def batch_review(
+    body: OutboundBatchReviewBody,
     db: Session = Depends(get_db),
-):
-    tenant_no = _get_tenant_no(db, user)
-    result = svc.list_reviews_by_schedule(
-        db, schedule_id=scheduleId, tenant_no=tenant_no
-    )
-    return success_response(result)
-
-
-@router.put("/pilot-release-review/update")
-async def update_review(
-    body: UpdateBody,
     user: dict = Depends(require_admin_or_super_admin),
-    db: Session = Depends(get_db),
 ):
     try:
         tenant_no = _get_tenant_no(db, user)
-        result = svc.update_review_conclusion(
+        user_id = _get_current_user_id(user)
+        result = svc.batch_review(
             db,
-            review_id=body.id,
+            body.scheduleIds,
             conclusion=body.reviewConclusion,
             remark=body.reviewRemark,
+            attachments=body.reviewAttachments,
             tenant_no=tenant_no,
-            user_id=user.get("user_id"),
+            user_id=user_id,
         )
-        return success_response(result, message="更新成功")
+        db.commit()
+        return success_response(result)
+    except PermissionError as e:
+        return error_response(403, str(e))
     except ValueError as e:
         return error_response(400, str(e))
+    except Exception as e:
+        return error_response(400, str(e))
 
 
-@router.delete("/pilot-release-review/delete")
-async def delete_review(
-    body: IdBody,
-    user: dict = Depends(require_admin_or_super_admin),
+@router.get("/pilot-release-reviews/{schedule_id}/outbound-materials")
+async def get_outbound_release_materials(
+    schedule_id: str,
     db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:outboundReleaseReview:list")),
 ):
     try:
         tenant_no = _get_tenant_no(db, user)
-        svc.delete_release_review(
-            db, review_id=body.id, tenant_no=tenant_no, user_id=user.get("user_id")
+        result = orm_svc.get_outbound_material(db, schedule_id, tenant_no)
+        return success_response(result)
+    except PermissionError as e:
+        return error_response(403, str(e))
+    except Exception as e:
+        if hasattr(e, "status_code") and getattr(e, "status_code") == 404:
+            return error_response(404, str(getattr(e, "detail", e)))
+        return error_response(400, str(getattr(e, "detail", e)))
+
+
+@router.put("/pilot-release-reviews/{schedule_id}/outbound-materials")
+async def save_outbound_release_materials(
+    schedule_id: str,
+    body: OutboundReleaseMaterialSaveBody,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_admin_or_super_admin),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        user_id = _get_current_user_id(user)
+        sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
+        dept_id = (sys_user.dept_id or "") if sys_user else ""
+        result = orm_svc.save_outbound_material(
+            db,
+            schedule_id,
+            paper_attachments=body.paperAttachments,
+            paper_materials=[p.model_dump(by_alias=True) for p in body.paperMaterials],
+            remark=body.remark,
+            cells=[c.model_dump(by_alias=True) for c in body.cells],
+            qc_materials=[m.model_dump(by_alias=True) for m in body.qcMaterials],
+            tenant_no=tenant_no,
+            user_id=user_id,
+            create_dept_id=dept_id,
         )
-        return success_response(None, message="删除成功")
-    except ValueError as e:
-        return error_response(400, str(e))
+        db.commit()
+        return success_response(result)
+    except PermissionError as e:
+        return error_response(403, str(e))
+    except Exception as e:
+        db.rollback()
+        if hasattr(e, "status_code"):
+            code = int(getattr(e, "status_code", 400))
+            detail = getattr(e, "detail", str(e))
+            return error_response(code, detail if isinstance(detail, str) else str(detail))
+        return error_response(400, str(getattr(e, "detail", e)))

+ 2 - 3
backend/app/api/pilot_application.py

@@ -733,9 +733,8 @@ async def api_create_outbound_production(
             user_id=user.get("user_id"),
             create_dept_id=user.get("dept_id", ""),
         )
-        pilot_application.mark_schedule_complete(
-            db, schedule_id=body.scheduleId, tenant_no=tenant_no, user_id=user.get("user_id")
-        )
+        db.commit()
+        db.refresh(wo)
         return success_response(pwo_svc._wo_to_dict(wo), message="创建出库生产工单成功")
     except ValueError as e:
         return error_response(400, str(e))

+ 61 - 0
backend/app/api/pilot_infusion_schedule_mgmt.py

@@ -9,7 +9,9 @@ from app.core.database import get_db
 from app.core.menu_guard import require_admin_or_super_admin
 from app.core.response import error_response, success_response
 from app.crud import pilot_infusion_schedule_mgmt as crud
+from app.crud import outbound_handover as handover_crud
 from app.models.models import SysUser
+from app.schemas.outbound_handover import OutboundHandoverSaveBody
 
 router = APIRouter(prefix="/web/admin", tags=["中试输注计划管理"])
 
@@ -200,3 +202,62 @@ async def api_update(
         return success_response(result, message="更新成功")
     except ValueError as e:
         return error_response(400, str(e))
+
+
+@router.get("/pilot-infusion-schedule-mgmt/{schedule_id}/outbound-handover")
+async def api_get_outbound_handover(
+    schedule_id: str,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        result = handover_crud.get_outbound_handover(db, schedule_id, tenant_no)
+        return success_response(result)
+    except PermissionError as e:
+        return error_response(403, str(e))
+    except Exception as e:
+        if hasattr(e, "status_code") and getattr(e, "status_code") == 404:
+            return error_response(404, str(getattr(e, "detail", e)))
+        return error_response(400, str(getattr(e, "detail", e)))
+
+
+@router.put("/pilot-infusion-schedule-mgmt/{schedule_id}/outbound-handover")
+async def api_save_outbound_handover(
+    schedule_id: str,
+    body: OutboundHandoverSaveBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        user_id = user.get("user_id") or ""
+        sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
+        dept_id = (sys_user.dept_id or "") if sys_user else ""
+        om = body.outboundMaterial
+        result = handover_crud.save_outbound_handover(
+            db,
+            schedule_id,
+            outbound_material={
+                "remark": om.remark,
+                "cells": [c.model_dump(by_alias=True) for c in om.cells],
+                "qcMaterials": [m.model_dump(by_alias=True) for m in om.qcMaterials],
+                "paperMaterials": [p.model_dump(by_alias=True) for p in om.paperMaterials],
+            },
+            handover=body.handover.model_dump(by_alias=True),
+            tenant_no=tenant_no,
+            user_id=user_id,
+            create_dept_id=dept_id,
+        )
+        db.commit()
+        return success_response(result, message="出库交接已保存")
+    except PermissionError as e:
+        db.rollback()
+        return error_response(403, str(e))
+    except Exception as e:
+        db.rollback()
+        if hasattr(e, "status_code"):
+            code = int(getattr(e, "status_code", 400))
+            detail = getattr(e, "detail", str(e))
+            return error_response(code, detail if isinstance(detail, str) else str(detail))
+        return error_response(400, str(getattr(e, "detail", e)))

+ 245 - 0
backend/app/crud/outbound_handover.py

@@ -0,0 +1,245 @@
+"""输注计划管理 · 出库交接 CRUD"""
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Dict, List, Optional
+
+from fastapi import HTTPException
+from sqlalchemy.orm import Session
+
+from app.core.utils import generate_uuid
+from app.crud import outbound_release_material as orm_material_svc
+from app.crud import outbound_handover_inventory as handover_inv_svc
+from app.crud import pilot_infusion_schedule_mgmt as schedule_mgmt_svc
+from app.models.models import BizOutboundHandover, SysUser
+
+
+def _fmt_dt(t: Optional[datetime]) -> str:
+    return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
+
+
+def _parse_handover_time(value: str) -> datetime:
+    raw = (value or "").strip()
+    if not raw:
+        raise HTTPException(status_code=400, detail="交接时间不能为空")
+    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
+        try:
+            return datetime.strptime(raw, fmt)
+        except ValueError:
+            continue
+    raise HTTPException(status_code=400, detail="交接时间格式无效")
+
+
+def _handover_row_to_dict(row: Optional[BizOutboundHandover]) -> Dict[str, Any]:
+    if not row:
+        return {
+            "handoverUserId": "",
+            "handoverPerson": "",
+            "handoverTime": "",
+            "handoverLocation": "",
+            "receiverPerson": "",
+            "remark": "",
+        }
+    return {
+        "handoverUserId": row.handover_user_id or "",
+        "handoverPerson": row.handover_person or "",
+        "handoverTime": _fmt_dt(row.handover_time),
+        "handoverLocation": row.handover_location or "",
+        "receiverPerson": row.receiver_person or "",
+        "remark": row.remark or "",
+    }
+
+
+def _handover_view_dict(row: Optional[BizOutboundHandover]) -> Dict[str, Any]:
+    if not row:
+        return {
+            "handoverPerson": "",
+            "handoverTime": "",
+            "handoverLocation": "",
+            "receiverPerson": "",
+            "remark": "",
+        }
+    return {
+        "handoverPerson": row.handover_person or "",
+        "handoverTime": _fmt_dt(row.handover_time),
+        "handoverLocation": row.handover_location or "",
+        "receiverPerson": row.receiver_person or "",
+        "remark": row.remark or "",
+    }
+
+
+def get_handover_by_schedule(
+    db: Session,
+    schedule_id: str,
+    tenant_no: str,
+) -> Optional[BizOutboundHandover]:
+    sid = (schedule_id or "").strip()
+    if not sid:
+        return None
+    return (
+        db.query(BizOutboundHandover)
+        .filter(
+            BizOutboundHandover.infusion_schedule_id == sid,
+            BizOutboundHandover.tenant_no == tenant_no,
+            BizOutboundHandover.del_flag == 0,
+        )
+        .first()
+    )
+
+
+def get_outbound_handover_view(
+    db: Session,
+    schedule_id: str,
+    tenant_no: str,
+) -> Dict[str, Any]:
+    """供输注计划 detail 聚合的交接只读信息。"""
+    row = get_handover_by_schedule(db, schedule_id, tenant_no)
+    return _handover_view_dict(row)
+
+
+def get_outbound_handover(
+    db: Session,
+    schedule_id: str,
+    tenant_no: str,
+) -> Dict[str, Any]:
+    """获取出库交接编辑数据(物料 + 交接信息 + 摘要)。"""
+    detail = schedule_mgmt_svc.get_infusion_schedule_detail(db, schedule_id, tenant_no)
+    if not detail:
+        raise HTTPException(status_code=404, detail="输注计划不存在")
+
+    outbound_material = orm_material_svc.get_outbound_material(db, schedule_id, tenant_no)
+    handover_row = get_handover_by_schedule(db, schedule_id, tenant_no)
+
+    return {
+        "scheduleSummary": {
+            "applicationNo": detail.get("applicationNo") or "",
+            "patientName": detail.get("patientName") or "",
+            "infusionTime": detail.get("infusionTime") or "",
+        },
+        "outboundMaterial": {
+            "remark": outbound_material.get("remark") or "",
+            "cells": outbound_material.get("cells") or [],
+            "qcMaterials": outbound_material.get("qcMaterials") or [],
+            "paperMaterials": outbound_material.get("paperMaterials") or [],
+        },
+        "handover": _handover_row_to_dict(handover_row),
+    }
+
+
+def save_outbound_handover(
+    db: Session,
+    schedule_id: str,
+    *,
+    outbound_material: Dict[str, Any],
+    handover: Dict[str, Any],
+    tenant_no: str,
+    user_id: str,
+    create_dept_id: str = "",
+) -> Dict[str, Any]:
+    """保存出库物料台账 + 出库交接(单事务,调用方 commit)。"""
+    detail = schedule_mgmt_svc.get_infusion_schedule_detail(db, schedule_id, tenant_no)
+    if not detail:
+        raise HTTPException(status_code=404, detail="输注计划不存在")
+
+    handover_user_id = (handover.get("handoverUserId") or handover.get("handover_user_id") or "").strip()
+    receiver_person = (handover.get("receiverPerson") or handover.get("receiver_person") or "").strip()
+    handover_location = (handover.get("handoverLocation") or handover.get("handover_location") or "").strip()
+    handover_remark = (handover.get("remark") or "").strip()
+    handover_time_raw = handover.get("handoverTime") or handover.get("handover_time") or ""
+
+    if not handover_user_id:
+        raise HTTPException(status_code=400, detail="交接人不能为空")
+    if not receiver_person:
+        raise HTTPException(status_code=400, detail="接收人不能为空")
+
+    sys_user = (
+        db.query(SysUser)
+        .filter(
+            SysUser.id == handover_user_id,
+            SysUser.del_flag == 0,
+        )
+        .first()
+    )
+    if not sys_user:
+        raise HTTPException(status_code=400, detail="交接人用户不存在或已删除")
+
+    handover_time = _parse_handover_time(str(handover_time_raw))
+    handover_person = (sys_user.nick_name or sys_user.user_no or "").strip()
+    if not handover_person:
+        handover_person = handover_user_id
+
+    om = outbound_material or {}
+    cells = om.get("cells") or []
+    qc_materials = om.get("qcMaterials") or om.get("qc_materials") or []
+    paper_materials = om.get("paperMaterials") or om.get("paper_materials") or []
+    material_remark = om.get("remark")
+
+    # 读取保存前台账,用于回滚上次出库交接的库存/状态变更
+    previous_material = orm_material_svc.get_outbound_material(db, schedule_id, tenant_no)
+    previous_cell_ids = [
+        (c.get("cellProductId") or c.get("cell_product_id") or "").strip()
+        for c in (previous_material.get("cells") or [])
+    ]
+    had_previous_handover = get_handover_by_schedule(db, schedule_id, tenant_no) is not None
+
+    handover_inv_svc.revert_handover_inventory(
+        db,
+        schedule_id,
+        cell_product_ids=previous_cell_ids if had_previous_handover else [],
+        tenant_no=tenant_no,
+        user_id=user_id,
+    )
+
+    orm_material_svc.save_outbound_material(
+        db,
+        schedule_id,
+        paper_attachments=None,
+        paper_materials=paper_materials,
+        remark=material_remark,
+        cells=cells,
+        qc_materials=qc_materials,
+        tenant_no=tenant_no,
+        user_id=user_id,
+        create_dept_id=create_dept_id,
+    )
+
+    handover_inv_svc.apply_handover_inventory(
+        db,
+        schedule_id,
+        cells=cells,
+        qc_materials=qc_materials,
+        tenant_no=tenant_no,
+        user_id=user_id,
+        create_dept_id=create_dept_id,
+        receiver_person=receiver_person,
+    )
+
+    now = datetime.now()
+    row = get_handover_by_schedule(db, schedule_id, tenant_no)
+    if not row:
+        row = BizOutboundHandover(
+            id=generate_uuid(),
+            infusion_schedule_id=schedule_id,
+            tenant_no=tenant_no,
+            order_num=0,
+            create_by=user_id,
+            create_dept_id=create_dept_id or "",
+            create_time=now,
+            del_flag=0,
+        )
+        db.add(row)
+        db.flush()
+    else:
+        row.update_by = user_id
+        row.update_time = now
+
+    row.handover_user_id = handover_user_id
+    row.handover_person = handover_person
+    row.handover_time = handover_time
+    row.handover_location = handover_location or None
+    row.receiver_person = receiver_person
+    row.remark = handover_remark or None
+    db.add(row)
+    db.flush()
+
+    return get_outbound_handover(db, schedule_id, tenant_no)

+ 307 - 0
backend/app/crud/outbound_handover_inventory.py

@@ -0,0 +1,307 @@
+"""输注计划出库交接 — 细胞产品状态与质检物料库存联动。"""
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+from typing import Any, Dict, Iterable, List, Optional, Set
+
+from fastapi import HTTPException
+from sqlalchemy.orm import Session
+
+from app.core.utils import generate_uuid
+from app.crud import storage_occupancy as occupancy_svc
+from app.models.models import BizCellProduct, BizStorageSlotOccupancy
+from app.models.qc_material import BizQcMaterial, BizQcMaterialUsage
+
+# 出库交接后细胞产品目标状态(已售卖/已出库)
+HANDOVER_CELL_OUT_STATUS = "sold"
+HANDOVER_QC_SOURCE_TYPE = "outbound_handover"
+
+
+def _handover_usage_remark(schedule_id: str) -> str:
+    return f"输注计划出库交接;scheduleId={schedule_id}"
+
+
+def _handover_usage_remark_prefix(schedule_id: str) -> str:
+    return f"scheduleId={schedule_id}"
+
+
+def _remove_cell_occupancy_no_commit(
+    db: Session,
+    item_id: str,
+    *,
+    user_id: Optional[str] = None,
+) -> None:
+    """按细胞产品 ID 释放库位占用(不 commit)。"""
+    obj = (
+        db.query(BizStorageSlotOccupancy)
+        .filter(
+            BizStorageSlotOccupancy.item_type == "cell_product",
+            BizStorageSlotOccupancy.item_id == item_id,
+            BizStorageSlotOccupancy.occupancy_status == "stored",
+            BizStorageSlotOccupancy.del_flag == 0,
+        )
+        .first()
+    )
+    if not obj:
+        return
+    now = datetime.now()
+    obj.occupancy_status = "removed"
+    obj.removed_at = now
+    obj.update_by = user_id
+    obj.update_time = now
+    db.add(obj)
+    db.flush()
+    occupancy_svc._refresh_slot_cache(db, obj.slot_id)
+
+
+def _restore_cell_occupancy_no_commit(
+    db: Session,
+    product: BizCellProduct,
+    *,
+    tenant_no: str,
+    user_id: Optional[str] = None,
+) -> None:
+    """出库交接回滚时恢复库位占用(不 commit)。"""
+    slot_id = (product.storage_slot_id or "").strip()
+    if not slot_id:
+        return
+    existing = (
+        db.query(BizStorageSlotOccupancy)
+        .filter(
+            BizStorageSlotOccupancy.item_type == "cell_product",
+            BizStorageSlotOccupancy.item_id == product.id,
+            BizStorageSlotOccupancy.occupancy_status == "stored",
+            BizStorageSlotOccupancy.del_flag == 0,
+        )
+        .first()
+    )
+    if existing:
+        return
+    now = datetime.now()
+    db.add(
+        BizStorageSlotOccupancy(
+            id=generate_uuid(),
+            slot_id=slot_id,
+            item_type="cell_product",
+            item_id=product.id,
+            item_code=(product.product_code or "").strip() or None,
+            item_name=(product.product_name or "").strip() or None,
+            item_source="outbound_handover_revert",
+            stored_at=now,
+            removed_at=None,
+            occupancy_status="stored",
+            tenant_no=tenant_no,
+            order_num=0,
+            remark="出库交接回滚恢复占用",
+            create_by=user_id,
+            create_dept_id=product.create_dept_id or "",
+            create_time=now,
+            update_by=user_id,
+            update_time=now,
+            del_flag=0,
+        )
+    )
+    db.flush()
+    occupancy_svc._refresh_slot_cache(db, slot_id)
+
+
+def _get_cell_product_locked(
+    db: Session, cell_product_id: str, tenant_no: str
+) -> BizCellProduct:
+    product = (
+        db.query(BizCellProduct)
+        .with_for_update()
+        .filter(
+            BizCellProduct.id == cell_product_id,
+            BizCellProduct.tenant_no == tenant_no,
+            BizCellProduct.del_flag == 0,
+        )
+        .first()
+    )
+    if not product:
+        raise HTTPException(
+            status_code=400,
+            detail=f"细胞产品不存在或已删除(ID: {cell_product_id})",
+        )
+    return product
+
+
+def revert_handover_inventory(
+    db: Session,
+    schedule_id: str,
+    *,
+    cell_product_ids: Iterable[str],
+    tenant_no: str,
+    user_id: Optional[str] = None,
+) -> None:
+    """回滚本输注计划上次出库交接产生的库存/状态变更(不 commit)。"""
+    sid = (schedule_id or "").strip()
+    if not sid:
+        return
+
+    remark_prefix = _handover_usage_remark_prefix(sid)
+    usage_rows = (
+        db.query(BizQcMaterialUsage)
+        .filter(
+            BizQcMaterialUsage.tenant_no == tenant_no,
+            BizQcMaterialUsage.del_flag == 0,
+            BizQcMaterialUsage.source_type == HANDOVER_QC_SOURCE_TYPE,
+            BizQcMaterialUsage.change_type == "use",
+            BizQcMaterialUsage.remark.like(f"%{remark_prefix}%"),
+        )
+        .all()
+    )
+    now = datetime.now()
+    for usage in usage_rows:
+        restore_qty = abs(usage.change_qty) if usage.change_qty is not None else Decimal("0")
+        if restore_qty > 0:
+            material = (
+                db.query(BizQcMaterial)
+                .with_for_update()
+                .filter(
+                    BizQcMaterial.id == usage.material_id,
+                    BizQcMaterial.tenant_no == tenant_no,
+                    BizQcMaterial.del_flag == 0,
+                )
+                .first()
+            )
+            if material:
+                material.stock_qty = (material.stock_qty or Decimal("0")) + restore_qty
+                material.update_by = user_id
+                material.update_time = now
+                db.add(material)
+        usage.del_flag = 1
+        usage.update_by = user_id
+        usage.update_time = now
+        db.add(usage)
+
+    seen: Set[str] = set()
+    for raw_id in cell_product_ids:
+        cp_id = (raw_id or "").strip()
+        if not cp_id or cp_id in seen:
+            continue
+        seen.add(cp_id)
+        product = _get_cell_product_locked(db, cp_id, tenant_no)
+        current = (product.product_status or "frozen").strip()
+        if current != HANDOVER_CELL_OUT_STATUS:
+            continue
+        product.product_status = "frozen"
+        product.update_by = user_id
+        product.update_time = now
+        db.add(product)
+        _restore_cell_occupancy_no_commit(
+            db, product, tenant_no=tenant_no, user_id=user_id
+        )
+
+    db.flush()
+
+
+def apply_handover_inventory(
+    db: Session,
+    schedule_id: str,
+    *,
+    cells: List[Dict[str, Any]],
+    qc_materials: List[Dict[str, Any]],
+    tenant_no: str,
+    user_id: Optional[str] = None,
+    create_dept_id: str = "",
+    receiver_person: str = "",
+) -> None:
+    """应用出库交接:细胞产品出库 + 质检物料扣减(不 commit)。"""
+    sid = (schedule_id or "").strip()
+    usage_remark = _handover_usage_remark(sid)
+    receiver = (receiver_person or "").strip() or None
+    now = datetime.now()
+
+    for item in cells or []:
+        cp_id = (item.get("cellProductId") or item.get("cell_product_id") or "").strip()
+        if not cp_id:
+            continue
+        product = _get_cell_product_locked(db, cp_id, tenant_no)
+        current = (product.product_status or "frozen").strip()
+        if current != "frozen":
+            label = product.product_code or product.product_name or cp_id
+            raise HTTPException(
+                status_code=400,
+                detail=(
+                    f"细胞产品「{label}」当前状态不可出库交接"
+                    f"(仅「已冻存」可出库,当前为 {current or '未知'})"
+                ),
+            )
+        product.product_status = HANDOVER_CELL_OUT_STATUS
+        product.update_by = user_id
+        product.update_time = now
+        db.add(product)
+        _remove_cell_occupancy_no_commit(db, cp_id, user_id=user_id)
+
+    for item in qc_materials or []:
+        mid = (item.get("materialId") or item.get("material_id") or "").strip()
+        qty_raw = item.get("qty")
+        if not mid:
+            raise HTTPException(status_code=400, detail="质检物料ID不能为空")
+        try:
+            qty_dec = Decimal(str(qty_raw))
+        except Exception:
+            raise HTTPException(status_code=400, detail="物料用量格式无效")
+        if qty_dec <= 0:
+            raise HTTPException(status_code=400, detail="物料用量必须大于0")
+
+        material = (
+            db.query(BizQcMaterial)
+            .with_for_update()
+            .filter(
+                BizQcMaterial.id == mid,
+                BizQcMaterial.tenant_no == tenant_no,
+                BizQcMaterial.del_flag == 0,
+                BizQcMaterial.status == "1",
+            )
+            .first()
+        )
+        if not material:
+            raise HTTPException(
+                status_code=400,
+                detail=f"质检物料不存在或已停用(ID: {mid})",
+            )
+
+        stock_before = material.stock_qty if material.stock_qty is not None else Decimal("0")
+        if stock_before < qty_dec:
+            raise HTTPException(
+                status_code=400,
+                detail=(
+                    f"物料「{material.material_name}」库存不足,"
+                    f"当前库存 {stock_before},需出库 {qty_dec}"
+                ),
+            )
+
+        stock_after = stock_before - qty_dec
+        material.stock_qty = stock_after
+        material.update_by = user_id
+        material.update_time = now
+        db.add(material)
+
+        line_remark = (item.get("remark") or "").strip()
+        db.add(
+            BizQcMaterialUsage(
+                id=generate_uuid(),
+                material_id=mid,
+                material_code=material.material_code,
+                material_name=material.material_name,
+                change_type="use",
+                change_qty=-qty_dec,
+                stock_after=stock_after,
+                source_type=HANDOVER_QC_SOURCE_TYPE,
+                tenant_no=tenant_no,
+                receiver=receiver,
+                purpose="输注计划出库交接",
+                remark=usage_remark if not line_remark else f"{usage_remark};{line_remark}",
+                create_by=user_id,
+                create_dept_id=create_dept_id or "",
+                create_time=now,
+                update_by=user_id,
+                update_time=now,
+                del_flag=0,
+            )
+        )
+
+    db.flush()

+ 431 - 0
backend/app/crud/outbound_release_material.py

@@ -0,0 +1,431 @@
+"""出库放行审核 · 出库物料准备(台账 CRUD)"""
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+from typing import Any, Dict, List, Optional
+
+from fastapi import HTTPException
+from sqlalchemy.orm import Session
+
+from app.core.utils import generate_uuid
+from app.crud import cell_product as cell_product_svc
+from app.models.models import (
+    BizCellProduct,
+    BizOutboundReleaseCell,
+    BizOutboundReleaseMaterial,
+    BizOutboundReleasePaper,
+    BizOutboundReleaseQcMaterial,
+    BizPilotInfusionSchedule,
+)
+from app.models.qc_material import BizQcMaterial
+
+
+def _fmt_dt(t: Optional[datetime]) -> str:
+    return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
+
+
+def _cell_row_to_dict(row: BizOutboundReleaseCell) -> Dict[str, Any]:
+    return {
+        "id": row.id,
+        "materialId": row.material_id or "",
+        "infusionScheduleId": row.infusion_schedule_id or "",
+        "cellProductId": row.cell_product_id or "",
+        "productCode": row.product_code or "",
+        "productName": row.product_name or "",
+        "productTypeName": row.product_type_name or "",
+        "specName": row.spec_name or "",
+        "passage": row.passage or "",
+        "batchNo": row.batch_no or "",
+        "storageSlotName": row.storage_slot_name or "",
+        "productStatus": row.product_status or "",
+        "remark": row.remark or "",
+        "orderNum": row.order_num if row.order_num is not None else 0,
+    }
+
+
+def _qc_material_row_to_dict(row: BizOutboundReleaseQcMaterial) -> Dict[str, Any]:
+    return {
+        "id": row.id,
+        "materialId": row.material_id or "",
+        "infusionScheduleId": row.infusion_schedule_id or "",
+        "qcMaterialId": row.qc_material_id or "",
+        "qty": float(row.qty) if row.qty is not None else 0,
+        "materialCode": row.material_code or "",
+        "materialName": row.material_name or "",
+        "specModel": row.spec_model or "",
+        "unit": row.unit or "",
+        "remark": row.remark or "",
+        "orderNum": row.order_num if row.order_num is not None else 0,
+    }
+
+
+def _paper_row_to_dict(row: BizOutboundReleasePaper) -> Dict[str, Any]:
+    return {
+        "id": row.id,
+        "materialId": row.material_id or "",
+        "infusionScheduleId": row.infusion_schedule_id or "",
+        "paperName": row.paper_name or "",
+        "copies": row.copies if row.copies is not None else 1,
+        "attachmentUrl": row.attachment_url or "",
+        "remark": row.remark or "",
+        "orderNum": row.order_num if row.order_num is not None else 0,
+    }
+
+
+def _legacy_paper_rows_from_attachments(paper_attachments: Optional[str]) -> List[Dict[str, Any]]:
+    urls = (paper_attachments or "").strip()
+    if not urls:
+        return []
+    rows: List[Dict[str, Any]] = []
+    for idx, file_url in enumerate([u.strip() for u in urls.split(",") if u.strip()]):
+        file_name = file_url.split("/").pop() or "附件"
+        rows.append(
+            {
+                "id": "",
+                "materialId": "",
+                "infusionScheduleId": "",
+                "paperName": file_name,
+                "copies": 1,
+                "attachmentUrl": file_url,
+                "remark": "",
+                "orderNum": idx + 1,
+            }
+        )
+    return rows
+
+
+def _material_header_to_dict(
+    header: Optional[BizOutboundReleaseMaterial],
+    cells: List[Dict[str, Any]],
+    qc_materials: List[Dict[str, Any]],
+    paper_materials: List[Dict[str, Any]],
+) -> Dict[str, Any]:
+    if not header:
+        return {
+            "id": "",
+            "infusionScheduleId": "",
+            "paperAttachments": "",
+            "paperMaterials": paper_materials,
+            "remark": "",
+            "cells": cells,
+            "qcMaterials": qc_materials,
+            "createTime": "",
+            "updateTime": "",
+        }
+    return {
+        "id": header.id,
+        "infusionScheduleId": header.infusion_schedule_id or "",
+        "paperAttachments": header.paper_attachments or "",
+        "paperMaterials": paper_materials,
+        "remark": header.remark or "",
+        "cells": cells,
+        "qcMaterials": qc_materials,
+        "createTime": _fmt_dt(header.create_time),
+        "updateTime": _fmt_dt(header.update_time),
+    }
+
+
+def _get_schedule(db: Session, schedule_id: str, tenant_no: str) -> Optional[BizPilotInfusionSchedule]:
+    sid = (schedule_id or "").strip()
+    if not sid:
+        return None
+    return (
+        db.query(BizPilotInfusionSchedule)
+        .filter(
+            BizPilotInfusionSchedule.id == sid,
+            BizPilotInfusionSchedule.tenant_no == tenant_no,
+            BizPilotInfusionSchedule.del_flag == 0,
+        )
+        .first()
+    )
+
+
+def get_outbound_material(
+    db: Session,
+    schedule_id: str,
+    tenant_no: str,
+) -> Dict[str, Any]:
+    """获取输注计划的出库物料准备(台账)。"""
+    schedule = _get_schedule(db, schedule_id, tenant_no)
+    if not schedule:
+        raise HTTPException(status_code=404, detail="输注计划不存在")
+
+    header = (
+        db.query(BizOutboundReleaseMaterial)
+        .filter(
+            BizOutboundReleaseMaterial.infusion_schedule_id == schedule_id,
+            BizOutboundReleaseMaterial.tenant_no == tenant_no,
+            BizOutboundReleaseMaterial.del_flag == 0,
+        )
+        .first()
+    )
+
+    cell_rows = (
+        db.query(BizOutboundReleaseCell)
+        .filter(
+            BizOutboundReleaseCell.infusion_schedule_id == schedule_id,
+            BizOutboundReleaseCell.tenant_no == tenant_no,
+            BizOutboundReleaseCell.del_flag == 0,
+        )
+        .order_by(
+            BizOutboundReleaseCell.order_num.asc(),
+            BizOutboundReleaseCell.create_time.asc(),
+        )
+        .all()
+    )
+    qc_rows = (
+        db.query(BizOutboundReleaseQcMaterial)
+        .filter(
+            BizOutboundReleaseQcMaterial.infusion_schedule_id == schedule_id,
+            BizOutboundReleaseQcMaterial.tenant_no == tenant_no,
+            BizOutboundReleaseQcMaterial.del_flag == 0,
+        )
+        .order_by(
+            BizOutboundReleaseQcMaterial.order_num.asc(),
+            BizOutboundReleaseQcMaterial.create_time.asc(),
+        )
+        .all()
+    )
+    paper_rows = (
+        db.query(BizOutboundReleasePaper)
+        .filter(
+            BizOutboundReleasePaper.infusion_schedule_id == schedule_id,
+            BizOutboundReleasePaper.tenant_no == tenant_no,
+            BizOutboundReleasePaper.del_flag == 0,
+        )
+        .order_by(
+            BizOutboundReleasePaper.order_num.asc(),
+            BizOutboundReleasePaper.create_time.asc(),
+        )
+        .all()
+    )
+
+    paper_materials = [_paper_row_to_dict(r) for r in paper_rows]
+    if not paper_materials and header and (header.paper_attachments or "").strip():
+        paper_materials = _legacy_paper_rows_from_attachments(header.paper_attachments)
+
+    return _material_header_to_dict(
+        header,
+        [_cell_row_to_dict(r) for r in cell_rows],
+        [_qc_material_row_to_dict(r) for r in qc_rows],
+        paper_materials,
+    )
+
+
+def save_outbound_material(
+    db: Session,
+    schedule_id: str,
+    *,
+    paper_attachments: Optional[str],
+    paper_materials: List[Dict[str, Any]],
+    remark: Optional[str],
+    cells: List[Dict[str, Any]],
+    qc_materials: List[Dict[str, Any]],
+    tenant_no: str,
+    user_id: str,
+    create_dept_id: str = "",
+) -> Dict[str, Any]:
+    """全量保存出库物料准备(台账,不联动库存)。"""
+    schedule = _get_schedule(db, schedule_id, tenant_no)
+    if not schedule:
+        raise HTTPException(status_code=404, detail="输注计划不存在")
+
+    cell_ids = [
+        (it.get("cellProductId") or it.get("cell_product_id") or "").strip()
+        for it in (cells or [])
+    ]
+    qc_ids = [
+        (it.get("materialId") or it.get("material_id") or "").strip()
+        for it in (qc_materials or [])
+    ]
+
+    if len([x for x in cell_ids if x]) != len(set(x for x in cell_ids if x)):
+        raise HTTPException(status_code=400, detail="同一输注计划中不能重复添加相同细胞产品")
+    if len([x for x in qc_ids if x]) != len(set(x for x in qc_ids if x)):
+        raise HTTPException(status_code=400, detail="同一输注计划中不能重复添加相同质检物料")
+
+    now = datetime.now()
+
+    header = (
+        db.query(BizOutboundReleaseMaterial)
+        .filter(
+            BizOutboundReleaseMaterial.infusion_schedule_id == schedule_id,
+            BizOutboundReleaseMaterial.tenant_no == tenant_no,
+            BizOutboundReleaseMaterial.del_flag == 0,
+        )
+        .first()
+    )
+    if not header:
+        header = BizOutboundReleaseMaterial(
+            id=generate_uuid(),
+            infusion_schedule_id=schedule_id,
+            tenant_no=tenant_no,
+            order_num=0,
+            create_by=user_id,
+            create_dept_id=create_dept_id or "",
+            create_time=now,
+            del_flag=0,
+        )
+        db.add(header)
+        db.flush()
+    else:
+        header.update_by = user_id
+        header.update_time = now
+
+    header.paper_attachments = None
+    header.remark = (remark or "").strip() or None
+    db.add(header)
+
+    db.query(BizOutboundReleaseCell).filter(
+        BizOutboundReleaseCell.infusion_schedule_id == schedule_id,
+        BizOutboundReleaseCell.tenant_no == tenant_no,
+        BizOutboundReleaseCell.del_flag == 0,
+    ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
+
+    db.query(BizOutboundReleaseQcMaterial).filter(
+        BizOutboundReleaseQcMaterial.infusion_schedule_id == schedule_id,
+        BizOutboundReleaseQcMaterial.tenant_no == tenant_no,
+        BizOutboundReleaseQcMaterial.del_flag == 0,
+    ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
+
+    db.query(BizOutboundReleasePaper).filter(
+        BizOutboundReleasePaper.infusion_schedule_id == schedule_id,
+        BizOutboundReleasePaper.tenant_no == tenant_no,
+        BizOutboundReleasePaper.del_flag == 0,
+    ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
+    db.flush()
+
+    for idx, item in enumerate(cells or []):
+        cp_id = (item.get("cellProductId") or item.get("cell_product_id") or "").strip()
+        if not cp_id:
+            raise HTTPException(status_code=400, detail="细胞产品ID不能为空")
+
+        product = (
+            db.query(BizCellProduct)
+            .filter(
+                BizCellProduct.id == cp_id,
+                BizCellProduct.tenant_no == tenant_no,
+                BizCellProduct.del_flag == 0,
+            )
+            .first()
+        )
+        if not product:
+            raise HTTPException(status_code=400, detail=f"细胞产品不存在或已删除(ID: {cp_id})")
+
+        enriched = cell_product_svc._enrich_product_dicts(
+            db,
+            tenant_no,
+            [cell_product_svc._row_to_dict(product)],
+        )[0]
+
+        db.add(
+            BizOutboundReleaseCell(
+                id=generate_uuid(),
+                material_id=header.id,
+                infusion_schedule_id=schedule_id,
+                cell_product_id=cp_id,
+                product_code=enriched.get("productCode") or "",
+                product_name=enriched.get("productName") or "",
+                product_type_name=enriched.get("productTypeName") or "",
+                spec_name=enriched.get("specName") or "",
+                passage=enriched.get("passage") or "",
+                batch_no=enriched.get("batchNo") or "",
+                storage_slot_name=enriched.get("storageSlotName") or "",
+                product_status=enriched.get("productStatus") or "",
+                tenant_no=tenant_no,
+                order_num=idx + 1,
+                remark=(item.get("remark") or "").strip() or None,
+                create_by=user_id,
+                create_dept_id=create_dept_id or "",
+                create_time=now,
+                update_by=user_id,
+                update_time=now,
+                del_flag=0,
+            )
+        )
+
+    for idx, item in enumerate(qc_materials or []):
+        mid = (item.get("materialId") or item.get("material_id") or "").strip()
+        qty_raw = item.get("qty")
+        if not mid:
+            raise HTTPException(status_code=400, detail="质检物料ID不能为空")
+        try:
+            qty_dec = Decimal(str(qty_raw))
+        except Exception:
+            raise HTTPException(status_code=400, detail="物料用量格式无效")
+        if qty_dec <= 0:
+            raise HTTPException(status_code=400, detail="物料用量必须大于0")
+
+        material = (
+            db.query(BizQcMaterial)
+            .filter(
+                BizQcMaterial.id == mid,
+                BizQcMaterial.tenant_no == tenant_no,
+                BizQcMaterial.del_flag == 0,
+            )
+            .first()
+        )
+        if not material:
+            raise HTTPException(status_code=400, detail=f"质检物料不存在或已删除(ID: {mid})")
+
+        db.add(
+            BizOutboundReleaseQcMaterial(
+                id=generate_uuid(),
+                material_id=header.id,
+                infusion_schedule_id=schedule_id,
+                qc_material_id=mid,
+                qty=qty_dec,
+                material_code=material.material_code or "",
+                material_name=material.material_name or "",
+                spec_model=material.spec_model or "",
+                unit=material.unit or "",
+                tenant_no=tenant_no,
+                order_num=idx + 1,
+                remark=(item.get("remark") or "").strip() or None,
+                create_by=user_id,
+                create_dept_id=create_dept_id or "",
+                create_time=now,
+                update_by=user_id,
+                update_time=now,
+                del_flag=0,
+            )
+        )
+
+    for idx, item in enumerate(paper_materials or []):
+        paper_name = (item.get("paperName") or item.get("paper_name") or "").strip()
+        attachment_url = (item.get("attachmentUrl") or item.get("attachment_url") or "").strip()
+        copies_raw = item.get("copies")
+        if not paper_name:
+            raise HTTPException(status_code=400, detail="纸质材料名称不能为空")
+        if not attachment_url:
+            raise HTTPException(status_code=400, detail="纸质材料附件不能为空")
+        try:
+            copies = int(copies_raw)
+        except Exception:
+            raise HTTPException(status_code=400, detail="纸质材料份数格式无效")
+        if copies <= 0:
+            raise HTTPException(status_code=400, detail="纸质材料份数必须大于0")
+
+        db.add(
+            BizOutboundReleasePaper(
+                id=generate_uuid(),
+                material_id=header.id,
+                infusion_schedule_id=schedule_id,
+                paper_name=paper_name,
+                copies=copies,
+                attachment_url=attachment_url,
+                tenant_no=tenant_no,
+                order_num=idx + 1,
+                remark=(item.get("remark") or "").strip() or None,
+                create_by=user_id,
+                create_dept_id=create_dept_id or "",
+                create_time=now,
+                update_by=user_id,
+                update_time=now,
+                del_flag=0,
+            )
+        )
+
+    db.flush()
+    return get_outbound_material(db, schedule_id, tenant_no)

+ 552 - 82
backend/app/crud/outbound_release_review.py

@@ -1,149 +1,619 @@
 """出库放行审核 CRUD"""
 from __future__ import annotations
 
+import math
 from datetime import datetime
 from typing import Any, Dict, List, Optional
 
-from sqlalchemy import and_
+from sqlalchemy import func, or_
 from sqlalchemy.orm import Session
 
 from app.core.utils import generate_uuid
-from app.models.models import BizOutboundReleaseReview
+from app.models.models import (
+    BizOutboundReleaseReview,
+    BizPilotApplication,
+    BizPilotInfusionPlan,
+    BizPilotInfusionSchedule,
+    BizPilotPatient,
+    BizProductionTask,
+    BizProductionWorkOrder,
+    BizQcTask,
+    BizQcWorkOrder,
+    SysUser,
+)
+from app.models.qc_param import BizQcTaskResult
+
+_REVIEW_CONCLUSION_LABELS = {"tg": "通过", "btg": "不通过"}
+_SCHEDULE_STATUS_LABELS = {
+    "dsc": "待生产",
+    "ysc": "已生产",
+    "yjf": "已交付",
+    "yqx": "已取消",
+}
+_QC_STATUS_LABELS = {
+    "dzc": "待质检",
+    "yzj": "已质检",
+    "yqx": "已取消",
+}
+_BIZ_TYPE_LABELS = {
+    "pilot_service": "中试服务",
+    "clinical_application": "临床应用",
+}
 
 
 # ────────────────────────────────────────────
-# Helpers: _row_to_dict
+# Helpers
 # ────────────────────────────────────────────
 
 
+def _fmt_dt(t: Optional[datetime]) -> str:
+    return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
+
+
+def _total_pages(total: int, page_size: int) -> int:
+    if total <= 0:
+        return 0
+    return int(math.ceil(total / float(page_size)))
+
+
+def _get_user_name(db: Session, user_id: str) -> str:
+    u = db.query(SysUser).filter(SysUser.id == user_id).first()
+    return (u.nick_name or "") if u else ""
+
+
+def _resolve_schedule_biz_type(
+    app: Optional[BizPilotApplication],
+    qwo: Optional[BizQcWorkOrder],
+    pwo: Optional[BizProductionWorkOrder],
+) -> str:
+    """输注计划业务类型:优先出库工单的 biz_type,否则关联中试申请单视为中试服务。"""
+    if qwo and (qwo.biz_type or "").strip():
+        return (qwo.biz_type or "").strip()
+    if pwo and (pwo.biz_type or "").strip():
+        return (pwo.biz_type or "").strip()
+    if app:
+        return "pilot_service"
+    return ""
+
+
 def _row_to_dict(row) -> Dict[str, Any]:
+    """将 BizOutboundReleaseReview ORM 对象转为前端字典。"""
     return {
         "id": row.id,
         "infusionScheduleId": row.infusion_schedule_id or "",
         "productionWorkOrderId": row.production_work_order_id or "",
         "qcWorkOrderId": row.qc_work_order_id or "",
         "reviewConclusion": row.review_conclusion or "",
+        "reviewConclusionLabel": _REVIEW_CONCLUSION_LABELS.get(
+            row.review_conclusion or "", row.review_conclusion or ""
+        ),
         "reviewerUserId": row.reviewer_user_id or "",
-        "reviewTime": row.review_time.strftime("%Y-%m-%d %H:%M:%S") if row.review_time else "",
+        "reviewByName": row.review_by_name or "",
+        "reviewAttachments": row.review_attachments or "",
         "reviewRemark": row.review_remark or "",
+        "reviewTime": _fmt_dt(row.review_time),
         "tenantNo": row.tenant_no or "",
         "orderNum": row.order_num if row.order_num is not None else 0,
         "remark": row.remark or "",
         "createBy": row.create_by or "",
-        "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
+        "createTime": _fmt_dt(row.create_time),
         "updateBy": row.update_by or "",
-        "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
+        "updateTime": _fmt_dt(row.update_time),
     }
 
 
 # ────────────────────────────────────────────
-# CRUD
+# Paginated list
 # ────────────────────────────────────────────
 
 
-def create_release_review(
+def list_reviews_paginated(
     db: Session,
     *,
-    schedule_id: str,
     tenant_no: str,
-    user_id: str,
-    create_dept_id: str,
+    keyword: Optional[str] = None,
+    review_conclusion: Optional[str] = None,
+    schedule_status: Optional[str] = None,
+    qc_status: Optional[str] = None,
+    page: int = 1,
+    page_size: int = 10,
+    user_info=None,
 ) -> Dict[str, Any]:
-    now = datetime.now()
-    row = BizOutboundReleaseReview(
-        id=generate_uuid(),
-        infusion_schedule_id=schedule_id,
-        production_work_order_id="",
-        qc_work_order_id="",
-        review_conclusion="",
-        reviewer_user_id=user_id,
-        review_time=now,
-        review_remark="",
-        tenant_no=tenant_no,
-        order_num=0,
-        remark="",
-        create_by=user_id,
-        create_dept_id=create_dept_id or "",
-        create_time=now,
-        update_by=user_id,
-        update_time=now,
-        del_flag=0,
+    """分页列出出库放行审核数据,以输注计划为主表 LEFT JOIN 审核/生产/质检工单。"""
+    # ── 基础查询:输注计划 ──
+    q0 = db.query(BizPilotInfusionSchedule).filter(
+        BizPilotInfusionSchedule.tenant_no == tenant_no,
+        BizPilotInfusionSchedule.del_flag == 0,
     )
-    db.add(row)
-    db.flush()
-    return _row_to_dict(row)
 
+    # ── 关键字过滤(application_no, patient_name)需要子查询 ──
+    kw = (keyword or "").strip()
+    if kw:
+        like = f"%{kw}%"
+        # 通过 infusion_plan_id 关联 plan → application → patient
+        plan_ids = (
+            db.query(BizPilotInfusionPlan.id)
+            .filter(
+                BizPilotInfusionPlan.tenant_no == tenant_no,
+                BizPilotInfusionPlan.del_flag == 0,
+            )
+            .subquery()
+        )
+        app_ids = (
+            db.query(BizPilotApplication.id)
+            .filter(
+                BizPilotApplication.tenant_no == tenant_no,
+                BizPilotApplication.del_flag == 0,
+                or_(
+                    BizPilotApplication.application_no.like(like),
+                    BizPilotApplication.application_name.like(like),
+                ),
+            )
+            .subquery()
+        )
+        patient_app_ids = (
+            db.query(BizPilotPatient.pilot_application_id)
+            .filter(
+                BizPilotPatient.tenant_no == tenant_no,
+                BizPilotPatient.del_flag == 0,
+                BizPilotPatient.patient_name.like(like),
+            )
+            .subquery()
+        )
+        # 找出满足条件的 infusion_plan_id
+        matched_plan_ids = (
+            db.query(BizPilotInfusionPlan.id)
+            .filter(
+                BizPilotInfusionPlan.tenant_no == tenant_no,
+                BizPilotInfusionPlan.del_flag == 0,
+                or_(
+                    BizPilotInfusionPlan.pilot_application_id.in_(app_ids),
+                    BizPilotInfusionPlan.pilot_application_id.in_(patient_app_ids),
+                ),
+            )
+            .subquery()
+        )
+        q0 = q0.filter(
+            BizPilotInfusionSchedule.infusion_plan_id.in_(matched_plan_ids)
+        )
+
+    # ── schedule_status 过滤 ──
+    ss = (schedule_status or "").strip()
+    if ss:
+        q0 = q0.filter(BizPilotInfusionSchedule.schedule_status == ss)
 
-def list_reviews_by_schedule(
-    db: Session, schedule_id: str, tenant_no: str
-) -> List[Dict[str, Any]]:
-    rows = (
-        db.query(BizOutboundReleaseReview)
-        .filter(
-            and_(
-                BizOutboundReleaseReview.infusion_schedule_id == schedule_id,
+    # ── qc_status 过滤 ──
+    qs = (qc_status or "").strip()
+    if qs:
+        q0 = q0.filter(BizPilotInfusionSchedule.qc_status == qs)
+
+    # ── review_conclusion 过滤 ──
+    rc = (review_conclusion or "").strip()
+    if rc == "pending":
+        # 待审核:不存在审核记录
+        reviewed_schedule_ids = (
+            db.query(BizOutboundReleaseReview.infusion_schedule_id)
+            .filter(
+                BizOutboundReleaseReview.tenant_no == tenant_no,
+                BizOutboundReleaseReview.del_flag == 0,
+                BizOutboundReleaseReview.review_conclusion != "",
+            )
+            .subquery()
+        )
+        q0 = q0.filter(
+            ~BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids)
+        )
+    elif rc:
+        # 有具体结论
+        reviewed_schedule_ids = (
+            db.query(BizOutboundReleaseReview.infusion_schedule_id)
+            .filter(
                 BizOutboundReleaseReview.tenant_no == tenant_no,
                 BizOutboundReleaseReview.del_flag == 0,
+                BizOutboundReleaseReview.review_conclusion == rc,
             )
+            .subquery()
         )
-        .order_by(BizOutboundReleaseReview.create_time.desc())
+        q0 = q0.filter(
+            BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids)
+        )
+
+    # ── 数据权限 ──
+    if user_info:
+        from app.core.data_scope import apply_data_scope
+
+        q0 = apply_data_scope(q0, BizPilotInfusionSchedule, user_info, db, tenant_no)
+
+    # ── 分页 ──
+    total = (
+        q0.order_by(None)
+        .with_entities(func.count(BizPilotInfusionSchedule.id))
+        .scalar()
+        or 0
+    )
+    page = max(1, page)
+    page_size = min(max(1, page_size), 100)
+    schedules = (
+        q0.order_by(BizPilotInfusionSchedule.create_time.desc())
+        .offset((page - 1) * page_size)
+        .limit(page_size)
         .all()
     )
-    return [_row_to_dict(r) for r in rows]
 
+    schedule_ids = [s.id for s in schedules]
+
+    # ── 批量加载关联数据 ──
+    # 审核记录
+    review_map: Dict[str, BizOutboundReleaseReview] = {}
+    if schedule_ids:
+        for rev in (
+            db.query(BizOutboundReleaseReview)
+            .filter(
+                BizOutboundReleaseReview.infusion_schedule_id.in_(schedule_ids),
+                BizOutboundReleaseReview.tenant_no == tenant_no,
+                BizOutboundReleaseReview.del_flag == 0,
+            )
+            .all()
+        ):
+            review_map[rev.infusion_schedule_id] = rev
+
+    # 生产工单(出库类型)
+    production_map: Dict[str, BizProductionWorkOrder] = {}
+    if schedule_ids:
+        for pwo in (
+            db.query(BizProductionWorkOrder)
+            .filter(
+                BizProductionWorkOrder.infusion_schedule_id.in_(schedule_ids),
+                BizProductionWorkOrder.work_order_type == "outbound",
+                BizProductionWorkOrder.biz_type.in_(
+                    list(_BIZ_TYPE_LABELS.keys())
+                ),
+                BizProductionWorkOrder.tenant_no == tenant_no,
+                BizProductionWorkOrder.del_flag == 0,
+            )
+            .all()
+        ):
+            production_map[pwo.infusion_schedule_id] = pwo
+
+    # 质检工单(出库类型)
+    qc_map: Dict[str, BizQcWorkOrder] = {}
+    if schedule_ids:
+        for qwo in (
+            db.query(BizQcWorkOrder)
+            .filter(
+                BizQcWorkOrder.infusion_schedule_id.in_(schedule_ids),
+                BizQcWorkOrder.qc_biz_type == "outbound",
+                BizQcWorkOrder.biz_type.in_(list(_BIZ_TYPE_LABELS.keys())),
+                BizQcWorkOrder.tenant_no == tenant_no,
+                BizQcWorkOrder.del_flag == 0,
+            )
+            .all()
+        ):
+            qc_map[qwo.infusion_schedule_id] = qwo
+
+    # 输注计划 → 申请单 → 患者
+    plan_ids_for_schedules = {
+        s.id: s.infusion_plan_id for s in schedules if s.infusion_plan_id
+    }
+    unique_plan_ids = list(set(plan_ids_for_schedules.values()))
+
+    plan_map: Dict[str, BizPilotInfusionPlan] = {}
+    if unique_plan_ids:
+        for plan in (
+            db.query(BizPilotInfusionPlan)
+            .filter(
+                BizPilotInfusionPlan.id.in_(unique_plan_ids),
+                BizPilotInfusionPlan.tenant_no == tenant_no,
+                BizPilotInfusionPlan.del_flag == 0,
+            )
+            .all()
+        ):
+            plan_map[plan.id] = plan
+
+    unique_app_ids = list(
+        {p.pilot_application_id for p in plan_map.values() if p.pilot_application_id}
+    )
+    app_map: Dict[str, BizPilotApplication] = {}
+    if unique_app_ids:
+        for app in (
+            db.query(BizPilotApplication)
+            .filter(
+                BizPilotApplication.id.in_(unique_app_ids),
+                BizPilotApplication.tenant_no == tenant_no,
+                BizPilotApplication.del_flag == 0,
+            )
+            .all()
+        ):
+            app_map[app.id] = app
+
+    # 患者
+    patient_map: Dict[str, BizPilotPatient] = {}
+    if unique_app_ids:
+        for patient in (
+            db.query(BizPilotPatient)
+            .filter(
+                BizPilotPatient.pilot_application_id.in_(unique_app_ids),
+                BizPilotPatient.tenant_no == tenant_no,
+                BizPilotPatient.del_flag == 0,
+            )
+            .all()
+        ):
+            patient_map[patient.pilot_application_id] = patient
+
+    # ── 组装列表项 ──
+    items: List[Dict[str, Any]] = []
+    for s in schedules:
+        plan = plan_map.get(s.infusion_plan_id)
+        app = app_map.get(plan.pilot_application_id) if plan else None
+        patient = patient_map.get(app.id) if app else None
+        rev = review_map.get(s.id)
+        pwo = production_map.get(s.id)
+        qwo = qc_map.get(s.id)
+        biz_type = _resolve_schedule_biz_type(app, qwo, pwo)
+
+        item: Dict[str, Any] = {
+            "scheduleId": s.id,
+            "bizType": biz_type,
+            "bizTypeLabel": _BIZ_TYPE_LABELS.get(biz_type, biz_type),
+            "applicationNo": (app.application_no or "") if app else "",
+            "applicationName": (app.application_name or "") if app else "",
+            "channelName": (app.channel_name or "") if app else "",
+            "patientName": (patient.patient_name or "") if patient else "",
+            "cellType": s.cell_type or (plan.cell_type or "") if plan else (s.cell_type or ""),
+            "specText": s.spec_text or (plan.spec_text or "") if plan else (s.spec_text or ""),
+            "infusionTime": _fmt_dt(s.infusion_time),
+            "scheduleStatus": s.schedule_status or "",
+            "scheduleStatusLabel": _SCHEDULE_STATUS_LABELS.get(
+                s.schedule_status or "", s.schedule_status or ""
+            ),
+            "qcStatus": s.qc_status or "",
+            "qcStatusLabel": _QC_STATUS_LABELS.get(
+                s.qc_status or "", s.qc_status or ""
+            ),
+            "reviewConclusion": "",
+            "reviewConclusionLabel": "",
+            "reviewByName": "",
+            "reviewTime": "",
+            "reviewRemark": "",
+            "reviewAttachments": "",
+            "reviewId": "",
+            "productionWorkOrderNo": "",
+            "productionWorkOrderStatus": "",
+            "qcWorkOrderNo": "",
+            "qcWorkOrderStatus": "",
+        }
+        if rev:
+            rc_val = rev.review_conclusion or ""
+            item["reviewConclusion"] = rc_val
+            item["reviewConclusionLabel"] = _REVIEW_CONCLUSION_LABELS.get(rc_val, rc_val)
+            item["reviewByName"] = rev.review_by_name or ""
+            item["reviewTime"] = _fmt_dt(rev.review_time)
+            item["reviewRemark"] = rev.review_remark or ""
+            item["reviewAttachments"] = rev.review_attachments or ""
+            item["reviewId"] = rev.id
+        else:
+            item["reviewConclusionLabel"] = "待审核"
+
+        if pwo:
+            item["productionWorkOrderNo"] = pwo.work_order_no or ""
+            item["productionWorkOrderStatus"] = pwo.work_order_status or ""
+        if qwo:
+            item["qcWorkOrderNo"] = qwo.work_order_no or ""
+            item["qcWorkOrderStatus"] = qwo.work_order_status or ""
+
+        items.append(item)
+
+    return {
+        "list": items,
+        "total": int(total),
+        "page": page,
+        "pageSize": page_size,
+        "totalPages": _total_pages(int(total), page_size),
+    }
+
+
+# ────────────────────────────────────────────
+# Detail aggregation
+# ────────────────────────────────────────────
+
+
+def get_schedule_detail(
+    db: Session, schedule_id: str, tenant_no: str
+) -> Optional[Dict[str, Any]]:
+    """按输注计划 ID 聚合出库放行审核详情(中试上下文 + 生产 + 质检 + 审核)。"""
+    from app.crud.qc_work_order import build_outbound_pilot_context_by_schedule
+
+    pilot_context = build_outbound_pilot_context_by_schedule(db, schedule_id, tenant_no)
+
+    # ── 生产工单 + 任务 ──
+    production_info: Dict[str, Any] = {}
+    pwo = (
+        db.query(BizProductionWorkOrder)
+        .filter(
+            BizProductionWorkOrder.infusion_schedule_id == schedule_id,
+            BizProductionWorkOrder.work_order_type == "outbound",
+            BizProductionWorkOrder.biz_type == "pilot_service",
+            BizProductionWorkOrder.tenant_no == tenant_no,
+            BizProductionWorkOrder.del_flag == 0,
+        )
+        .first()
+    )
+    if pwo:
+        from app.crud.production_work_order import (
+            _production_info_item,
+        )
+
+        production_info = _production_info_item(db, pwo)
+
+    # ── 质检工单 + 任务 + 结果 ──
+    qc_info: Dict[str, Any] = {}
+    qwo = (
+        db.query(BizQcWorkOrder)
+        .filter(
+            BizQcWorkOrder.infusion_schedule_id == schedule_id,
+            BizQcWorkOrder.qc_biz_type == "outbound",
+            BizQcWorkOrder.biz_type == "pilot_service",
+            BizQcWorkOrder.tenant_no == tenant_no,
+            BizQcWorkOrder.del_flag == 0,
+        )
+        .first()
+    )
+    if qwo:
+        from app.crud.qc_work_order import _qc_info_item
+
+        qc_info = _qc_info_item(db, qwo, tenant_no)
+
+    # ── 审核记录 ──
+    review_info: Dict[str, Any] = {}
+    rev = (
+        db.query(BizOutboundReleaseReview)
+        .filter(
+            BizOutboundReleaseReview.infusion_schedule_id == schedule_id,
+            BizOutboundReleaseReview.tenant_no == tenant_no,
+            BizOutboundReleaseReview.del_flag == 0,
+        )
+        .first()
+    )
+    if rev:
+        review_info = _row_to_dict(rev)
+
+    outbound_material: Dict[str, Any] = {}
+    try:
+        from app.crud import outbound_release_material as orm_svc
+
+        outbound_material = orm_svc.get_outbound_material(db, schedule_id, tenant_no)
+    except Exception:
+        outbound_material = {}
+
+    # 与 OutboundReleaseReviewDetail / 出库质检详情一致:中试字段展平到顶层,勿嵌套 pilotContext
+    return {
+        **(pilot_context or {}),
+        "productionInfo": production_info,
+        "qcInfo": qc_info,
+        "reviewInfo": review_info,
+        "outboundMaterial": outbound_material,
+    }
 
-def update_review_conclusion(
+
+# ────────────────────────────────────────────
+# Review operations
+# ────────────────────────────────────────────
+
+
+def review_schedule(
     db: Session,
-    review_id: str,
+    schedule_id: str,
+    *,
     conclusion: str,
-    remark: str,
+    remark: Optional[str] = None,
+    attachments: Optional[str] = None,
     tenant_no: str,
     user_id: str,
 ) -> Dict[str, Any]:
-    row = (
+    """对指定输注计划执行出库放行审核(upsert)。"""
+    now = datetime.now()
+    user_name = _get_user_name(db, user_id)
+
+    # 查找已有审核记录
+    rev = (
         db.query(BizOutboundReleaseReview)
         .filter(
-            and_(
-                BizOutboundReleaseReview.id == review_id,
-                BizOutboundReleaseReview.tenant_no == tenant_no,
-                BizOutboundReleaseReview.del_flag == 0,
-            )
+            BizOutboundReleaseReview.infusion_schedule_id == schedule_id,
+            BizOutboundReleaseReview.tenant_no == tenant_no,
+            BizOutboundReleaseReview.del_flag == 0,
         )
         .first()
     )
-    if not row:
-        raise ValueError("审核记录不存在")
 
-    now = datetime.now()
-    row.review_conclusion = conclusion
-    row.review_remark = remark
-    row.reviewer_user_id = user_id
-    row.review_time = now
-    row.update_by = user_id
-    row.update_time = now
-    db.commit()
-    db.refresh(row)
-    return _row_to_dict(row)
-
-
-def delete_release_review(
-    db: Session, review_id: str, tenant_no: str, user_id: str
-) -> None:
-    row = (
-        db.query(BizOutboundReleaseReview)
+    # 自动填充生产工单 ID
+    pwo = (
+        db.query(BizProductionWorkOrder)
         .filter(
-            and_(
-                BizOutboundReleaseReview.id == review_id,
-                BizOutboundReleaseReview.tenant_no == tenant_no,
-                BizOutboundReleaseReview.del_flag == 0,
-            )
+            BizProductionWorkOrder.infusion_schedule_id == schedule_id,
+            BizProductionWorkOrder.work_order_type == "outbound",
+            BizProductionWorkOrder.tenant_no == tenant_no,
+            BizProductionWorkOrder.del_flag == 0,
         )
         .first()
     )
-    if not row:
-        raise ValueError("审核记录不存在")
+    # 自动填充质检工单 ID
+    qwo = (
+        db.query(BizQcWorkOrder)
+        .filter(
+            BizQcWorkOrder.infusion_schedule_id == schedule_id,
+            BizQcWorkOrder.qc_biz_type == "outbound",
+            BizQcWorkOrder.tenant_no == tenant_no,
+            BizQcWorkOrder.del_flag == 0,
+        )
+        .first()
+    )
+
+    if rev:
+        # update
+        rev.review_conclusion = conclusion
+        rev.review_remark = (remark or "").strip() or None
+        rev.review_attachments = (attachments or "").strip() or None
+        rev.reviewer_user_id = user_id
+        rev.review_by_name = user_name
+        rev.review_time = now
+        rev.production_work_order_id = pwo.id if pwo else (rev.production_work_order_id or "")
+        rev.qc_work_order_id = qwo.id if qwo else (rev.qc_work_order_id or "")
+        rev.update_by = user_id
+        rev.update_time = now
+    else:
+        # create
+        rev = BizOutboundReleaseReview(
+            id=generate_uuid(),
+            infusion_schedule_id=schedule_id,
+            production_work_order_id=pwo.id if pwo else "",
+            qc_work_order_id=qwo.id if qwo else "",
+            review_conclusion=conclusion,
+            reviewer_user_id=user_id,
+            review_by_name=user_name,
+            review_time=now,
+            review_remark=(remark or "").strip() or None,
+            review_attachments=(attachments or "").strip() or None,
+            tenant_no=tenant_no,
+            order_num=0,
+            remark="",
+            create_by=user_id,
+            create_dept_id="",
+            create_time=now,
+            update_by=user_id,
+            update_time=now,
+            del_flag=0,
+        )
+        db.add(rev)
+
+    db.flush()
+    return _row_to_dict(rev)
+
 
-    row.del_flag = 1
-    row.update_by = user_id
-    row.update_time = datetime.now()
-    db.commit()
+def batch_review(
+    db: Session,
+    schedule_ids: List[str],
+    *,
+    conclusion: str,
+    remark: Optional[str] = None,
+    attachments: Optional[str] = None,
+    tenant_no: str,
+    user_id: str,
+) -> Dict[str, Any]:
+    """批量审核多个输注计划。"""
+    success = 0
+    fail = 0
+    for sid in schedule_ids:
+        try:
+            review_schedule(
+                db,
+                sid,
+                conclusion=conclusion,
+                remark=remark,
+                attachments=attachments,
+                tenant_no=tenant_no,
+                user_id=user_id,
+            )
+            success += 1
+        except Exception:
+            fail += 1
+    return {"success": success, "fail": fail}

+ 12 - 0
backend/app/crud/pilot_application.py

@@ -1496,9 +1496,15 @@ def soft_delete_schedule(
         )
         .first()
     )
+    row.schedule_status = "yqx"
+    row.qc_status = "yqx"
+
     if outbound_wo:
         outbound_wo.work_order_status = "yzf"
         outbound_wo.update_time = now
+        from app.crud.pilot_outbound_status_sync import sync_schedule_from_production_work_order
+
+        sync_schedule_from_production_work_order(db, outbound_wo)
 
     for qc_wo in (
         db.query(BizQcWorkOrder)
@@ -1511,6 +1517,9 @@ def soft_delete_schedule(
     ):
         qc_wo.work_order_status = "yzf"
         qc_wo.update_time = now
+        from app.crud.pilot_outbound_status_sync import sync_schedule_from_qc_work_order
+
+        sync_schedule_from_qc_work_order(db, qc_wo)
 
     db.query(BizPilotInfusionScheduleProduct).filter(
         BizPilotInfusionScheduleProduct.infusion_schedule_id == schedule_id,
@@ -1544,6 +1553,9 @@ def mark_schedule_complete(
     row.schedule_status = "ysc"
     row.update_by = user_id
     row.update_time = datetime.now()
+    from app.crud.pilot_outbound_status_sync import sync_pilot_outbound_from_schedule
+
+    sync_pilot_outbound_from_schedule(db, row, user_id=user_id)
     db.commit()
     db.refresh(row)
     return _row_to_dict_infusion_schedule(row)

+ 4 - 0
backend/app/crud/pilot_delivery.py

@@ -461,6 +461,10 @@ def create_delivery(
     schedule.update_by = user_id
     schedule.update_time = now
 
+    from app.crud.pilot_outbound_status_sync import sync_pilot_outbound_from_schedule
+
+    sync_pilot_outbound_from_schedule(db, schedule, user_id=user_id)
+
     db.commit()
     db.refresh(row)
     return _row_to_dict_delivery(row, db, tenant_no)

+ 31 - 0
backend/app/crud/pilot_infusion_confirm.py

@@ -107,6 +107,37 @@ def list_confirms_by_application(
     return [_row_to_dict(r) for r in rows]
 
 
+def get_confirm_by_schedule(
+    db: Session, schedule_id: str, tenant_no: str
+) -> Optional[Dict[str, Any]]:
+    """按输注计划 ID 获取应用确认(含确认人姓名快照)。"""
+    from app.models.models import SysUser
+
+    row = (
+        db.query(BizPilotInfusionConfirm)
+        .filter(
+            BizPilotInfusionConfirm.infusion_schedule_id == schedule_id,
+            BizPilotInfusionConfirm.tenant_no == tenant_no,
+            BizPilotInfusionConfirm.del_flag == 0,
+        )
+        .first()
+    )
+    if not row:
+        return None
+    data = _row_to_dict(row)
+    uid = (row.confirm_user_id or "").strip()
+    if uid:
+        user = (
+            db.query(SysUser)
+            .filter(SysUser.id == uid, SysUser.del_flag == 0)
+            .first()
+        )
+        data["confirmUserName"] = (user.nick_name or user.user_no or "") if user else ""
+    else:
+        data["confirmUserName"] = ""
+    return data
+
+
 def get_confirm_by_id(
     db: Session, confirm_id: str, tenant_no: str
 ) -> Optional[Dict[str, Any]]:

+ 77 - 1
backend/app/crud/pilot_infusion_schedule_mgmt.py

@@ -28,6 +28,9 @@ from app.crud.pilot_service import (
     _reduce_channel_product_quota,
     _resolve_agreement_price_cell_type,
 )
+from app.crud import outbound_handover as handover_svc
+from app.crud import outbound_release_review as orr_svc
+from app.crud import pilot_infusion_confirm as confirm_svc
 
 
 # ────────────────────────────────────────────
@@ -449,10 +452,73 @@ def get_infusion_schedule_detail(
     if row.infusion_plan_id:
         batch_meta = _get_plan_schedule_course_meta(db, [row.infusion_plan_id], tenant_no)
         plan_meta = batch_meta.get(row.infusion_plan_id)
-    return _row_to_dict_schedule(
+    flat = _row_to_dict_schedule(
         row, application, plan_row, patient, products, service_order, plan_meta=plan_meta
     )
 
+    aggregated = orr_svc.get_schedule_detail(db, schedule_id, tenant_no) or {}
+
+    app = dict(aggregated.get("application") or {})
+    app["applicationNo"] = app.get("applicationNo") or flat.get("applicationNo") or ""
+    app["applicationName"] = app.get("applicationName") or flat.get("applicationName") or ""
+    app["applicationStatus"] = app.get("applicationStatus") or flat.get("applicationStatus") or ""
+    app["channelName"] = app.get("channelName") or flat.get("channelName") or ""
+    app["orderType"] = flat.get("orderType") or ""
+    app["channelServiceOrderName"] = flat.get("channelServiceOrderName") or ""
+    app["channelServiceOrderId"] = flat.get("channelServiceOrderId") or ""
+
+    plan = dict(aggregated.get("infusionPlan") or {})
+    if plan_row:
+        plan.setdefault("cellType", plan_row.cell_type or "")
+        plan.setdefault("specText", plan_row.spec_text or "")
+        plan.setdefault("totalCourseCount", plan_row.total_course_count)
+        plan.setdefault("courseInterval", plan_row.course_interval or "")
+        plan.setdefault("infusionStartDate", plan_row.infusion_start_date or "")
+
+    sched = dict(aggregated.get("infusionSchedule") or {})
+    sched["sortNo"] = flat.get("sortNo")
+    sched["totalCourseCount"] = flat.get("totalCourseCount")
+    sched["cellType"] = flat.get("cellType") or sched.get("cellType") or ""
+    sched["specText"] = flat.get("specText") or sched.get("specText") or ""
+    sched["infusionTime"] = flat.get("infusionTime") or sched.get("infusionTime") or ""
+    sched["infusionLocation"] = flat.get("infusionLocation") or sched.get("infusionLocation") or ""
+    sched["infusionMethod"] = flat.get("infusionMethod") or sched.get("infusionMethod") or ""
+    sched["scheduleStatus"] = flat.get("scheduleStatus") or sched.get("scheduleStatus") or ""
+    sched["infusionStatus"] = flat.get("infusionStatus") or sched.get("infusionStatus") or ""
+    sched["qcStatus"] = flat.get("qcStatus") or sched.get("qcStatus") or ""
+    sched["cancelReason"] = flat.get("cancelReason") or sched.get("cancelReason") or ""
+    sched["remark"] = flat.get("remark") or ""
+
+    patient_data = dict(aggregated.get("patient") or {})
+    if patient:
+        patient_data.setdefault("patientName", patient.patient_name or "")
+        patient_data.setdefault("sex", patient.sex or "")
+        patient_data.setdefault("age", str(patient.age) if patient.age is not None else "")
+        patient_data.setdefault("phoneNo", patient.phone_no or "")
+        patient_data.setdefault("idCardNo", patient.id_card_no or "")
+        patient_data.setdefault("emergencyContact", patient.emergency_contact or "")
+        patient_data.setdefault("emergencyPhone", patient.emergency_phone or "")
+        patient_data.setdefault("clinicalDiagnosis", patient.clinical_diagnosis or "")
+        patient_data.setdefault("diagnosisAttachments", patient.diagnosis_attachments or "")
+        patient_data.setdefault("needCheckup", patient.need_checkup)
+
+    result: Dict[str, Any] = {
+        **aggregated,
+        **flat,
+        "application": app,
+        "patient": patient_data,
+        "infusionPlan": plan,
+        "infusionSchedule": sched,
+        "products": flat.get("products") or aggregated.get("products") or [],
+        "productionInfo": aggregated.get("productionInfo") or {},
+        "qcInfo": aggregated.get("qcInfo") or {},
+        "reviewInfo": aggregated.get("reviewInfo") or {},
+        "outboundMaterial": aggregated.get("outboundMaterial") or {},
+        "outboundHandover": handover_svc.get_outbound_handover_view(db, schedule_id, tenant_no),
+        "infusionConfirm": confirm_svc.get_confirm_by_schedule(db, schedule_id, tenant_no) or {},
+    }
+    return result
+
 
 # ────────────────────────────────────────────
 # Status update
@@ -496,6 +562,8 @@ def update_infusion_status(
 
     if infusion_status == "yqx":
         row.cancel_reason = cancel_reason or ""
+        row.schedule_status = "yqx"
+        row.qc_status = "yqx"
 
         # 自动作废关联的未完成出库生产工单
         outbound_wo = (
@@ -529,6 +597,10 @@ def update_infusion_status(
             order.update_by = user_id
             order.update_time = now
 
+        from app.crud.pilot_outbound_status_sync import sync_pilot_outbound_from_schedule
+
+        sync_pilot_outbound_from_schedule(db, row, user_id=user_id)
+
     # Sync all related products' product_status
     product_rows = (
         db.query(BizPilotInfusionScheduleProduct)
@@ -585,6 +657,10 @@ def update_qc_status(
     row.update_by = user_id
     row.update_time = now
 
+    from app.crud.pilot_outbound_status_sync import sync_pilot_outbound_from_schedule
+
+    sync_pilot_outbound_from_schedule(db, row, user_id=user_id)
+
     db.commit()
     db.refresh(row)
     return get_infusion_schedule_detail(db, row.id, tenant_no)

+ 213 - 0
backend/app/crud/pilot_outbound_status_sync.py

@@ -0,0 +1,213 @@
+"""中试服务出库生产/质检工单与输注计划生产状态、质检状态双向联动。"""
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Optional
+
+from sqlalchemy.orm import Session
+
+from app.models.models import (
+    BizPilotInfusionSchedule,
+    BizProductionWorkOrder,
+    BizQcWorkOrder,
+)
+
+
+def _is_pilot_outbound_production(wo: BizProductionWorkOrder) -> bool:
+    return (
+        (getattr(wo, "work_order_type", None) or "") == "outbound"
+        and (getattr(wo, "biz_type", None) or "") == "pilot_service"
+        and bool(getattr(wo, "infusion_schedule_id", None))
+    )
+
+
+def _is_pilot_outbound_qc(wo: BizQcWorkOrder) -> bool:
+    return (
+        (getattr(wo, "work_order_type", None) or "") == "outbound"
+        and (getattr(wo, "biz_type", None) or "") == "pilot_service"
+        and bool(getattr(wo, "infusion_schedule_id", None))
+    )
+
+
+def _get_schedule(
+    db: Session, schedule_id: str, tenant_no: str
+) -> Optional[BizPilotInfusionSchedule]:
+    if not (schedule_id or "").strip():
+        return None
+    return (
+        db.query(BizPilotInfusionSchedule)
+        .filter(
+            BizPilotInfusionSchedule.id == schedule_id.strip(),
+            BizPilotInfusionSchedule.tenant_no == tenant_no,
+            BizPilotInfusionSchedule.del_flag == 0,
+        )
+        .first()
+    )
+
+
+def _touch(row, user_id: Optional[str]) -> None:
+    now = datetime.now()
+    if user_id:
+        row.update_by = user_id
+    row.update_time = now
+
+
+def sync_schedule_from_production_work_order(
+    db: Session,
+    wo: BizProductionWorkOrder,
+    *,
+    user_id: Optional[str] = None,
+) -> None:
+    """出库生产工单状态 → 输注计划 schedule_status。"""
+    if not _is_pilot_outbound_production(wo):
+        return
+    sched = _get_schedule(db, wo.infusion_schedule_id or "", wo.tenant_no or "")
+    if not sched:
+        return
+
+    wo_status = (wo.work_order_status or "").strip()
+    current = (sched.schedule_status or "").strip()
+    new_status: Optional[str] = None
+
+    if wo_status in ("dpz", "jxz"):
+        if current not in ("ysc", "yjf", "yqx"):
+            new_status = "dsc"
+    elif wo_status == "ywc":
+        if current not in ("yjf", "yqx"):
+            new_status = "ysc"
+    elif wo_status == "yzf":
+        new_status = "yqx"
+
+    if new_status and new_status != current:
+        sched.schedule_status = new_status
+        _touch(sched, user_id)
+        db.add(sched)
+
+
+def sync_schedule_from_qc_work_order(
+    db: Session,
+    wo: BizQcWorkOrder,
+    *,
+    user_id: Optional[str] = None,
+) -> None:
+    """出库质检工单状态 → 输注计划 qc_status。"""
+    if not _is_pilot_outbound_qc(wo):
+        return
+    sched = _get_schedule(db, wo.infusion_schedule_id or "", wo.tenant_no or "")
+    if not sched:
+        return
+
+    wo_status = (wo.work_order_status or "").strip()
+    current = (sched.qc_status or "").strip()
+    new_status: Optional[str] = None
+
+    if wo_status in ("dpz", "ypz"):
+        if current not in ("yzj", "yqx"):
+            new_status = "dzc"
+    elif wo_status == "ywc":
+        if current != "yqx":
+            new_status = "yzj"
+    elif wo_status == "yzf":
+        new_status = "yqx"
+
+    if new_status and new_status != current:
+        sched.qc_status = new_status
+        _touch(sched, user_id)
+        db.add(sched)
+
+
+def sync_production_work_order_from_schedule(
+    db: Session,
+    schedule: BizPilotInfusionSchedule,
+    *,
+    user_id: Optional[str] = None,
+) -> None:
+    """输注计划 schedule_status → 出库生产工单状态。"""
+    schedule_id = schedule.id or ""
+    tenant_no = schedule.tenant_no or ""
+    if not schedule_id:
+        return
+
+    wo = (
+        db.query(BizProductionWorkOrder)
+        .filter(
+            BizProductionWorkOrder.infusion_schedule_id == schedule_id,
+            BizProductionWorkOrder.tenant_no == tenant_no,
+            BizProductionWorkOrder.del_flag == 0,
+            BizProductionWorkOrder.work_order_type == "outbound",
+            BizProductionWorkOrder.biz_type == "pilot_service",
+        )
+        .first()
+    )
+    if not wo:
+        return
+
+    sched_status = (schedule.schedule_status or "").strip()
+    current = (wo.work_order_status or "").strip()
+    new_status: Optional[str] = None
+
+    if sched_status in ("ysc", "yjf"):
+        if current not in ("ywc", "yzf"):
+            new_status = "ywc"
+    elif sched_status == "yqx":
+        if current != "ywc":
+            new_status = "yzf"
+
+    if new_status and new_status != current:
+        wo.work_order_status = new_status
+        _touch(wo, user_id)
+        db.add(wo)
+
+
+def sync_qc_work_order_from_schedule(
+    db: Session,
+    schedule: BizPilotInfusionSchedule,
+    *,
+    user_id: Optional[str] = None,
+) -> None:
+    """输注计划 qc_status → 出库质检工单状态。"""
+    schedule_id = schedule.id or ""
+    tenant_no = schedule.tenant_no or ""
+    if not schedule_id:
+        return
+
+    wo = (
+        db.query(BizQcWorkOrder)
+        .filter(
+            BizQcWorkOrder.infusion_schedule_id == schedule_id,
+            BizQcWorkOrder.tenant_no == tenant_no,
+            BizQcWorkOrder.del_flag == 0,
+            BizQcWorkOrder.work_order_type == "outbound",
+            BizQcWorkOrder.biz_type == "pilot_service",
+        )
+        .first()
+    )
+    if not wo:
+        return
+
+    qc_status = (schedule.qc_status or "").strip()
+    current = (wo.work_order_status or "").strip()
+    new_status: Optional[str] = None
+
+    if qc_status == "yzj":
+        if current not in ("ywc", "yzf"):
+            new_status = "ywc"
+    elif qc_status == "yqx":
+        if current != "ywc":
+            new_status = "yzf"
+
+    if new_status and new_status != current:
+        wo.work_order_status = new_status
+        _touch(wo, user_id)
+        db.add(wo)
+
+
+def sync_pilot_outbound_from_schedule(
+    db: Session,
+    schedule: BizPilotInfusionSchedule,
+    *,
+    user_id: Optional[str] = None,
+) -> None:
+    """输注计划状态变更后,回写关联出库工单。"""
+    sync_production_work_order_from_schedule(db, schedule, user_id=user_id)
+    sync_qc_work_order_from_schedule(db, schedule, user_id=user_id)

+ 6 - 0
backend/app/crud/production_work_order.py

@@ -501,6 +501,9 @@ def sync_production_work_order_status_from_tasks(
     wo.update_by = user_id
     wo.update_time = now
     db.add(wo)
+    from app.crud.pilot_outbound_status_sync import sync_schedule_from_production_work_order
+
+    sync_schedule_from_production_work_order(db, wo, user_id=user_id)
 
 
 def get_production_work_order(
@@ -556,6 +559,9 @@ def complete_production_work_order(
     if data and data.remark is not None:
         wo.remark = data.remark
     db.add(wo)
+    from app.crud.pilot_outbound_status_sync import sync_schedule_from_production_work_order
+
+    sync_schedule_from_production_work_order(db, wo, user_id=user_id)
     db.commit()
     db.refresh(wo)
     return wo

+ 6 - 0
backend/app/crud/qc_work_order.py

@@ -988,6 +988,9 @@ def configure_work_order(
     wo.update_by = user_id
     wo.update_time = datetime.now()
     db.add(wo)
+    from app.crud.pilot_outbound_status_sync import sync_schedule_from_qc_work_order
+
+    sync_schedule_from_qc_work_order(db, wo, user_id=user_id)
     db.commit()
     db.refresh(wo)
     return wo
@@ -1011,6 +1014,9 @@ def try_complete_work_order(db: Session, work_order_id: str, tenant_no: str) ->
         wo.work_order_status = "ywc"
         wo.update_time = datetime.now()
         db.add(wo)
+        from app.crud.pilot_outbound_status_sync import sync_schedule_from_qc_work_order
+
+        sync_schedule_from_qc_work_order(db, wo)
     db.commit()
 
 

ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/049dff20cbc0ffb702db017e5ae150b557aeb5d43bf496a747f195accfa37d00.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/08599d03b7c9518d133fec21caf5cae4b1db7f5806553db50982ee4cf31ad23c.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/0aa11781f470b5521698fdaa3e5ca3a474104ced45afa5e3ba3e7452be731235.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/189561d19e545046c8ee2c1beae9b5f867b806cc1feee8c2040b71abb2e25e5d.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/1e13ae9e8dea00e1cef6be684bc9819b7034a6ab9e31eea883b5cf0d59b83697.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/2685b36fe9c3efd29481be03ab7745a44c20e882af2ec956a3d6c08fc9c41fd3.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/26b58166a73eb3fcb00700de56e76062696091c1852f4050fe19f0648ec81e8a.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/2c20aa2d11696a8336dc39571906d3ae8eec9758e1bd38a352ef5993eab71156.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/33f716d0078b7b0cdf9417652e18942c21f5aa3900b406c9d23376ae3e02e163.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/4d0d3931ae3b99efad229aa34c07c74272925ed9eb8223fca89c931e9615c0b7.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/61b7212932cf096babef6386c1661f6eeb703405b08483b3451cabc1f29ac22f.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/65b0d09b11c300f9a77985fb24b1c28b4a8585f3519d4a56ebe2ecbf8ac2b0cf.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/68b1e24f37b1bc6566b12da411806b7df78d3c0b29561e1d11b8048307df0369.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/68d8ed1dee192449d32369d9be96849873e9028b75da6e9841d29849b385a931.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/73627e17a16e8f52dd9f1c230502eee2820a67c7cfe70ff78d96e21e2e9bdf32.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/795c730de8c2c8f273959facbf37e33340fb1812a7044af3f1141cb32899c850.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/7c39c236db7cf30efabba64ae701ddde2f5dd839216c75e6d2ea7ea372d37074.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/857589f18e57aa2db8db04f16bda645a2871bf15679bbf8a1392bed2aec16535.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/943a933a343d1fd2d671b59a3d736d7bd265a0b3056f9275c5b0e34a729bdbde.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/95b3ad8055cb50d8ec52b6465518f4518cb0145a80bf67b2bff0cc19705c3385.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/9f6683c21c0bef574e35606dd98c48c8c71bf85159589650bf8761056200768a.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/b95f2eb206b8f6622e2db5e01baa14bd9e3e34097f5e314b52bd88db4d367efa.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/c92d5136903da42fc6dc3a810b5c9d3adeaccbd2f3bb686cdbb0623e6a7cb180.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/cdd760331699d3672f900d6de7fe797c58ed15f0c3d112cddeb5ddd39ccd953f.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/ce4f6cc817e6428294c0b563a5b61f1131962c67a7259ee833ac74310bce89fb.json


ファイルの差分が大きいため隠しています
+ 0 - 0
backend/app/graphify-out/cache/ast/d2f7821d1155a66f0afd35423490b500fbafc3ac30300cabc40ada74bbd50528.json


+ 119 - 0
backend/app/models/models.py

@@ -3105,6 +3105,123 @@ class BizPilotInfusionConfirm(Base):
     del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
 
 
+class BizOutboundReleaseMaterial(Base):
+    """出库放行-出库物料准备主表 biz_outbound_release_material"""
+
+    __tablename__ = "biz_outbound_release_material"
+
+    id = Column(String(64), primary_key=True, comment="主键ID")
+    infusion_schedule_id = Column(String(64), nullable=False, comment="输注计划ID")
+    paper_attachments = Column(String(4000), nullable=True, comment="其他纸质材料附件")
+    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="删除标记")
+
+
+class BizOutboundReleaseCell(Base):
+    """出库放行-出库细胞(台账) biz_outbound_release_cell"""
+
+    __tablename__ = "biz_outbound_release_cell"
+
+    id = Column(String(64), primary_key=True, comment="主键ID")
+    material_id = Column(String(64), nullable=False, comment="出库物料主表ID")
+    infusion_schedule_id = Column(String(64), nullable=False, comment="输注计划ID")
+    cell_product_id = Column(String(64), nullable=False, comment="细胞产品ID")
+    product_code = Column(String(128), nullable=True, comment="产品编码快照")
+    product_name = Column(String(200), nullable=True, comment="产品名称快照")
+    product_type_name = Column(String(100), nullable=True, comment="产品类型快照")
+    spec_name = Column(String(100), nullable=True, comment="规格快照")
+    passage = Column(String(32), nullable=True, comment="代次快照")
+    batch_no = Column(String(64), nullable=True, comment="批次号快照")
+    storage_slot_name = Column(String(200), nullable=True, comment="储物格快照")
+    product_status = Column(String(32), nullable=True, comment="保存时状态快照")
+    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="删除标记")
+
+
+class BizOutboundReleasePaper(Base):
+    """出库放行-其他纸质材料(台账) biz_outbound_release_paper"""
+
+    __tablename__ = "biz_outbound_release_paper"
+
+    id = Column(String(64), primary_key=True, comment="主键ID")
+    material_id = Column(String(64), nullable=False, comment="出库物料主表ID")
+    infusion_schedule_id = Column(String(64), nullable=False, comment="输注计划ID")
+    paper_name = Column(String(200), nullable=False, comment="材料名称")
+    copies = Column(Integer, nullable=False, default=1, comment="份数")
+    attachment_url = Column(String(2000), nullable=True, comment="附件URL")
+    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="删除标记")
+
+
+class BizOutboundHandover(Base):
+    """输注计划-出库交接 biz_outbound_handover"""
+
+    __tablename__ = "biz_outbound_handover"
+
+    id = Column(String(64), primary_key=True, comment="主键ID")
+    infusion_schedule_id = Column(String(64), nullable=False, comment="输注计划ID")
+    handover_user_id = Column(String(64), nullable=True, comment="交接人用户ID")
+    handover_person = Column(String(64), nullable=True, comment="交接人姓名快照")
+    handover_time = Column(DateTime, nullable=True, comment="交接时间")
+    handover_location = Column(String(200), nullable=True, comment="交接地点")
+    receiver_person = Column(String(200), nullable=True, comment="接收人(文本)")
+    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="删除标记")
+
+
+class BizOutboundReleaseQcMaterial(Base):
+    """出库放行-质检物料(台账) biz_outbound_release_qc_material"""
+
+    __tablename__ = "biz_outbound_release_qc_material"
+
+    id = Column(String(64), primary_key=True, comment="主键ID")
+    material_id = Column(String(64), nullable=False, comment="出库物料主表ID")
+    infusion_schedule_id = Column(String(64), nullable=False, comment="输注计划ID")
+    qc_material_id = Column(String(64), nullable=False, comment="质检物料ID")
+    qty = Column(Numeric(18, 4), nullable=False, comment="计划用量")
+    material_code = Column(String(64), nullable=True, comment="物料编码快照")
+    material_name = Column(String(200), nullable=True, comment="物料名称快照")
+    spec_model = Column(String(200), nullable=True, comment="规格型号快照")
+    unit = Column(String(32), nullable=True, comment="单位快照")
+    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="删除标记")
+
+
 class BizOutboundReleaseReview(Base):
     """出库放行审核 biz_outbound_release_review"""
 
@@ -3118,6 +3235,8 @@ class BizOutboundReleaseReview(Base):
     reviewer_user_id = Column(String(64), nullable=True, comment="审核人")
     review_time = Column(DateTime, nullable=True, comment="审核时间")
     review_remark = Column(String(2000), nullable=True, comment="审核备注")
+    review_attachments = Column(String(4000), nullable=True, comment="审核附件(逗号分隔URL)")
+    review_by_name = Column(String(64), nullable=True, comment="审核人姓名")
     tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
     order_num = Column(Integer, nullable=True, comment="排序号")
     remark = Column(String(512), nullable=True, comment="备注")

+ 23 - 0
backend/app/schemas/outbound_handover.py

@@ -0,0 +1,23 @@
+from datetime import datetime
+from typing import List, Optional
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from app.schemas.outbound_release_material import OutboundReleaseMaterialSaveBody
+
+
+class OutboundHandoverItem(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    handoverUserId: str = Field(..., min_length=1)
+    handoverTime: str = Field(..., min_length=1)
+    handoverLocation: Optional[str] = None
+    receiverPerson: str = Field(..., min_length=1)
+    remark: Optional[str] = None
+
+
+class OutboundHandoverSaveBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    outboundMaterial: OutboundReleaseMaterialSaveBody
+    handover: OutboundHandoverItem

+ 45 - 0
backend/app/schemas/outbound_release_material.py

@@ -0,0 +1,45 @@
+from decimal import Decimal
+from typing import List, Optional
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+
+class OutboundReleaseCellItem(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    cellProductId: str = Field(..., min_length=1)
+    remark: Optional[str] = None
+
+
+class OutboundReleaseQcMaterialItem(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    materialId: str = Field(..., min_length=1)
+    qty: Decimal = Field(..., gt=0)
+    remark: Optional[str] = None
+
+    @field_validator("qty", mode="before")
+    @classmethod
+    def _coerce_qty(cls, v):
+        if v is None or v == "":
+            raise ValueError("用量必须大于0")
+        return v
+
+
+class OutboundReleasePaperItem(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    paperName: str = Field(..., min_length=1)
+    copies: int = Field(..., ge=1)
+    attachmentUrl: str = Field(..., min_length=1)
+    remark: Optional[str] = None
+
+
+class OutboundReleaseMaterialSaveBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    paperAttachments: Optional[str] = None
+    paperMaterials: List[OutboundReleasePaperItem] = Field(default_factory=list)
+    remark: Optional[str] = None
+    cells: List[OutboundReleaseCellItem] = Field(default_factory=list)
+    qcMaterials: List[OutboundReleaseQcMaterialItem] = Field(default_factory=list)

+ 31 - 0
backend/app/schemas/outbound_release_review.py

@@ -0,0 +1,31 @@
+from typing import List, Optional
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class OutboundReviewListParams(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    page: int = Field(1, ge=1)
+    pageSize: int = Field(10, ge=1, le=100)
+    keyword: Optional[str] = None
+    reviewConclusion: Optional[str] = None
+    scheduleStatus: Optional[str] = None
+    qcStatus: Optional[str] = None
+
+
+class OutboundReviewSubmitBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    reviewConclusion: str = Field(..., description="tg/btg")
+    reviewRemark: Optional[str] = None
+    reviewAttachments: Optional[str] = None
+
+
+class OutboundBatchReviewBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    scheduleIds: List[str] = Field(..., min_length=1)
+    reviewConclusion: str = Field(..., description="tg/btg")
+    reviewRemark: Optional[str] = None
+    reviewAttachments: Optional[str] = None

ファイルの差分が大きいため隠しています
+ 324 - 394
backend/graphify-out/.graphify_analysis.json


ファイルの差分が大きいため隠しています
+ 359 - 100
backend/graphify-out/.graphify_extract.json


+ 108 - 173
backend/graphify-out/GRAPH_REPORT.md

@@ -1,12 +1,12 @@
-# Graph Report - app  (2026-05-26)
+# Graph Report - app  (2026-05-27)
 
 ## Corpus Check
-- 160 files · ~167,784 words
+- 167 files · ~173,555 words
 - Verdict: corpus is large enough that graph structure adds value.
 
 ## Summary
-- 3093 nodes · 13377 edges · 60 communities detected
-- Extraction: 39% EXTRACTED · 61% INFERRED · 0% AMBIGUOUS · INFERRED: 8198 edges (avg confidence: 0.62)
+- 3181 nodes · 13761 edges · 47 communities detected
+- Extraction: 39% EXTRACTED · 61% INFERRED · 0% AMBIGUOUS · INFERRED: 8443 edges (avg confidence: 0.62)
 - Token cost: 0 input · 0 output
 
 ## Community Hubs (Navigation)
@@ -51,37 +51,24 @@
 - [[_COMMUNITY_Community 38|Community 38]]
 - [[_COMMUNITY_Community 39|Community 39]]
 - [[_COMMUNITY_Community 40|Community 40]]
-- [[_COMMUNITY_Community 41|Community 41]]
-- [[_COMMUNITY_Community 42|Community 42]]
 - [[_COMMUNITY_Community 43|Community 43]]
 - [[_COMMUNITY_Community 44|Community 44]]
 - [[_COMMUNITY_Community 45|Community 45]]
 - [[_COMMUNITY_Community 46|Community 46]]
 - [[_COMMUNITY_Community 47|Community 47]]
 - [[_COMMUNITY_Community 48|Community 48]]
-- [[_COMMUNITY_Community 49|Community 49]]
-- [[_COMMUNITY_Community 50|Community 50]]
-- [[_COMMUNITY_Community 51|Community 51]]
-- [[_COMMUNITY_Community 52|Community 52]]
-- [[_COMMUNITY_Community 53|Community 53]]
-- [[_COMMUNITY_Community 56|Community 56]]
-- [[_COMMUNITY_Community 57|Community 57]]
-- [[_COMMUNITY_Community 58|Community 58]]
-- [[_COMMUNITY_Community 59|Community 59]]
-- [[_COMMUNITY_Community 60|Community 60]]
-- [[_COMMUNITY_Community 61|Community 61]]
 
 ## God Nodes (most connected - your core abstractions)
-1. `success_response()` - 502 edges
-2. `error_response()` - 401 edges
-3. `SysUser` - 323 edges
-4. `BizQcTaskResult` - 246 edges
-5. `BizQcMaterialUsage` - 244 edges
-6. `BizMaterialStockLog` - 240 edges
-7. `BizQcMaterial` - 240 edges
-8. `BizMaterial` - 237 edges
-9. `BizQcItemParam` - 235 edges
-10. `generate_uuid()` - 168 edges
+1. `success_response()` - 506 edges
+2. `error_response()` - 406 edges
+3. `SysUser` - 329 edges
+4. `BizQcTaskResult` - 261 edges
+5. `BizQcMaterialUsage` - 260 edges
+6. `BizQcMaterial` - 257 edges
+7. `BizMaterialStockLog` - 250 edges
+8. `BizMaterial` - 247 edges
+9. `BizQcItemParam` - 245 edges
+10. `generate_uuid()` - 172 edges
 
 ## Surprising Connections (you probably didn't know these)
 - `统一记录 API 请求/响应出入参的中间件` --uses--> `SysApiRequestLog`  [INFERRED]
@@ -99,284 +86,232 @@
 
 ### Community 0 - "Community 0"
 Cohesion: 0.02
-Nodes (553): attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post() (+545 more)
+Nodes (560): attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post() (+552 more)
 
 ### Community 1 - "Community 1"
 Cohesion: 0.01
-Nodes (473): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+465 more)
+Nodes (468): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+460 more)
 
 ### Community 2 - "Community 2"
 Cohesion: 0.04
-Nodes (363): AuditCreateBody, BatchDetailIdsBody, BatchRefundFinanceSubmitBody, FinanceConfirmBody, FullmoonTodoInsuranceBody, ProtocolCreateBody, ProtocolUpdateBody, 细胞存储 — 采集协议单 / 审核 / 财务确认 / 分期预警 API (+355 more)
+Nodes (373): AuditCreateBody, BatchDetailIdsBody, BatchRefundFinanceSubmitBody, FinanceConfirmBody, FullmoonTodoInsuranceBody, ProtocolCreateBody, ProtocolUpdateBody, 细胞存储 — 采集协议单 / 审核 / 财务确认 / 分期预警 API (+365 more)
 
 ### Community 3 - "Community 3"
 Cohesion: 0.02
-Nodes (252): api_save_application(), delete(), get_multi_with_filters(), _format_attachment_file_size(), get_protocol_nos_used_in_protocols(), urls_to_attachment_list(), create_dict_type(), dict_type_code_exists() (+244 more)
+Nodes (165): generate_uuid(), batch_stock_in(), batch_stock_out(), consumable_code_exists(), create_consumable(), _dec(), get_consumable(), _is_enabled_status() (+157 more)
 
 ### Community 4 - "Community 4"
-Cohesion: 0.06
-Nodes (156): generate_uuid(), activate_channel_agreement(), _apply_gmcp_order_quotas(), _apply_purchase_txn_holdings(), _apply_unsubscribe_txn_holdings(), _calc_gmcp_purchase_total(), cell_product_price_code_exists(), cell_product_spec_code_exists() (+148 more)
+Cohesion: 0.05
+Nodes (173): 中试交付管理 CRUD(交付信息、交付物料、交付方式 + 费用扣减), 创建交付时扣减费用。      流程:     1. schedule → plan → application     2. application.chan, List all deliveries for an application (through plan → schedule → delivery)., Bulk save: soft-delete existing + insert new., Create or update delivery method (upsert pattern)., activate_channel_agreement(), _apply_gmcp_order_quotas(), _apply_purchase_txn_holdings() (+165 more)
 
 ### Community 5 - "Community 5"
-Cohesion: 0.03
-Nodes (105): _leave_local_callback(), submit_leave(), get_designer_data(), ProcessDefCreateBody, ProcessDefUpdateBody, PublishVersionBody, 审批意见:前端传 commentText,兼容 comment。, StartInstanceBody (+97 more)
+Cohesion: 0.02
+Nodes (142): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, create_collection_region(), CustomerCreateBody, CustomerUpdateBody, delete_cell_sample_type() (+134 more)
 
 ### Community 6 - "Community 6"
-Cohesion: 0.04
-Nodes (121): create_collection_protocol(), delete_collection_protocol(), _ensure_customer_for_mother(), get_collection_protocol_detail(), _get_current_user_id(), _get_tenant_no(), list_collection_protocol_audits(), list_collection_protocols() (+113 more)
+Cohesion: 0.03
+Nodes (97): get_designer_data(), ProcessDefCreateBody, ProcessDefUpdateBody, PublishVersionBody, 审批意见:前端传 commentText,兼容 comment。, StartInstanceBody, TaskActionBody, _error_json() (+89 more)
 
 ### Community 7 - "Community 7"
-Cohesion: 0.03
-Nodes (93): get_role_menus(), _avatar_url_field(), _build_user_info_payload(), get_web_auth_menu_codes(), _jwt_user_payload(), logout(), 公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/zkxw.sql 基线), 登出:吊销 Redis 会话(若已配置 REDIS_HOST)。 (+85 more)
+Cohesion: 0.04
+Nodes (102): create_lab_item(), get_lab_item(), item_code_exists(), list_active_lab_items(), list_lab_items(), _row_to_dict(), soft_delete_lab_item(), update_lab_item() (+94 more)
 
 ### Community 8 - "Community 8"
-Cohesion: 0.05
-Nodes (79): get_next_cell_product_code(), _build_ancestor_chain(), _build_descendant_chain(), _build_facility_path_label(), build_split_product_codes(), create_cell_products_batch(), create_cell_products_by_quantity(), delete_cell_product() (+71 more)
+Cohesion: 0.04
+Nodes (75): get_by_schedule(), get_method(), _get_tenant_no(), list_by_application(), list_materials(), 中试服务交付管理 API(交付信息、交付物料、交付方式), soft_delete(), get_protocol_nos_used_in_protocols() (+67 more)
 
 ### Community 9 - "Community 9"
-Cohesion: 0.07
-Nodes (67): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, OccupancyCreateBody, 运营支撑 API(洁净室、进出记录、仓储设施、储位、展厅预约) (+59 more)
+Cohesion: 0.04
+Nodes (64): _avatar_url_field(), _build_user_info_payload(), get_web_auth_menu_codes(), _jwt_user_payload(), logout(), 公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/zkxw.sql 基线), 登出:吊销 Redis 会话(若已配置 REDIS_HOST)。, 获取当前用户菜单权限标识(sys_menu.perms,经角色继承)。 (+56 more)
 
 ### Community 10 - "Community 10"
-Cohesion: 0.35
-Nodes (61): BatchStockInBody, BatchStockInItem, BatchStockOutBody, BatchStockOutItem, ConsumableCreateBody, ConsumablePackCreateBody, ConsumablePackUpdateBody, ConsumableUpdateBody (+53 more)
+Cohesion: 0.06
+Nodes (80): _persist_protocol_sub_resources(), _add_months_yyyymmdd(), align_fq_payment_details_payload(), _apply_payment_detail_list_filters(), _apply_submit_receipt_finance(), _apply_submit_refund_finance(), attachments_to_urls(), _audit_to_dict() (+72 more)
 
 ### Community 11 - "Community 11"
-Cohesion: 0.07
-Nodes (13): BaseStorage, convert_heic_to_jpeg(), get_storage(), LocalStorage, MinIOStorage, OSSStorage, process_image_content(), QiniuStorage (+5 more)
+Cohesion: 0.06
+Nodes (69): _format_attachment_file_size(), urls_to_attachment_list(), _accept_conclusion_label(), _attach_outbound_detail(), _attach_reception_detail(), complete_production_work_order(), configure_temp_production_work_order(), create_outbound_production_work_order() (+61 more)
 
 ### Community 12 - "Community 12"
-Cohesion: 0.11
-Nodes (40): create_collection_protocol(), _active_link_query(), assert_biz_no_available(), create_convert_link(), get_convert_partner(), get_partner_sample_codes(), has_active_convert_link(), upsert_convert_link_for_to_storage() (+32 more)
+Cohesion: 0.07
+Nodes (66): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, OccupancyCreateBody, 运营支撑 API(洁净室、进出记录、仓储设施、储位、展厅预约) (+58 more)
 
 ### Community 13 - "Community 13"
-Cohesion: 0.12
-Nodes (11): get_wechat_pay_client(), get_wechat_pay_v2(), 微信支付工具模块  支持微信支付 V3 API 文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.ht, 统一下单接口 (V3)          Args:             out_trade_no: 商户订单号             total, 申请退款          Args:             out_trade_no: 商户订单号             total_amount, 小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致), 统一下单 (V2 API)          Args:             out_trade_no: 商户订单号             tot, 生成签名 V3 (SHA256 with RSA) (+3 more)
+Cohesion: 0.35
+Nodes (61): BatchStockInBody, BatchStockInItem, BatchStockOutBody, BatchStockOutItem, ConsumableCreateBody, ConsumablePackCreateBody, ConsumablePackUpdateBody, ConsumableUpdateBody (+53 more)
 
 ### Community 14 - "Community 14"
-Cohesion: 0.13
-Nodes (22): _accept_conclusion_label(), _base_query(), _ensure_notification(), _fmt_dt(), generate_sms_content(), list_notifications(), _normalize_date_bound(), resolve_notification() (+14 more)
+Cohesion: 0.07
+Nodes (54): _fmt_dt(), get_handover_by_schedule(), get_outbound_handover(), get_outbound_handover_view(), _handover_row_to_dict(), _handover_view_dict(), _parse_handover_time(), 获取出库交接编辑数据(物料 + 交接信息 + 摘要)。 (+46 more)
 
 ### Community 15 - "Community 15"
-Cohesion: 0.18
-Nodes (24): add_step(), copy_step(), create_route(), _create_step_consumable(), _create_step_data_collection(), _create_step_output(), _create_step_reagent(), get_route() (+16 more)
+Cohesion: 0.08
+Nodes (54): api_save_application(), auto_generate_schedules(), complete_application(), create_application(), create_infusion_plan(), create_schedule(), _create_schedule_products(), _format_yyyy_mm_dd() (+46 more)
 
 ### Community 16 - "Community 16"
-Cohesion: 0.17
-Nodes (12): BusinessException, InvalidStatusTransitionException, log_delivery_operation(), log_operation(), log_order_operation(), log_settlement_operation(), log_user_operation(), OrderLockedException (+4 more)
+Cohesion: 0.07
+Nodes (13): BaseStorage, convert_heic_to_jpeg(), get_storage(), LocalStorage, MinIOStorage, OSSStorage, process_image_content(), QiniuStorage (+5 more)
 
 ### Community 17 - "Community 17"
-Cohesion: 0.21
-Nodes (19): _consumable_row_to_dict(), create_process(), _create_sub_consumable(), _create_sub_data_collection(), _create_sub_output(), _create_sub_reagent(), _data_collection_row_to_dict(), get_process() (+11 more)
+Cohesion: 0.08
+Nodes (41): get_next_cell_product_code(), _build_ancestor_chain(), _build_descendant_chain(), _build_facility_path_label(), build_split_product_codes(), create_cell_products_batch(), create_cell_products_by_quantity(), delete_cell_product() (+33 more)
 
 ### Community 18 - "Community 18"
-Cohesion: 0.22
-Nodes (18): _create_settlement_txn(), _deduct_fee_on_complete(), _find_existing_settlement_txn(), _find_service_order_product(), get_infusion_schedule_detail(), _get_plan_schedule_course_meta(), _get_products_for_schedule(), list_infusion_schedules_mgmt() (+10 more)
+Cohesion: 0.11
+Nodes (42): create_collection_protocol(), _active_link_query(), assert_biz_no_available(), create_convert_link(), get_convert_partner(), get_partner_sample_codes(), has_active_convert_link(), upsert_convert_link_for_to_storage() (+34 more)
 
 ### Community 19 - "Community 19"
-Cohesion: 0.23
-Nodes (14): batch_stock_in(), batch_stock_out(), consumable_code_exists(), create_consumable(), _dec(), get_consumable(), _is_enabled_status(), list_consumables() (+6 more)
+Cohesion: 0.12
+Nodes (11): get_wechat_pay_client(), get_wechat_pay_v2(), 微信支付工具模块  支持微信支付 V3 API 文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.ht, 统一下单接口 (V3)          Args:             out_trade_no: 商户订单号             total, 申请退款          Args:             out_trade_no: 商户订单号             total_amount, 小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致), 统一下单 (V2 API)          Args:             out_trade_no: 商户订单号             tot, 生成签名 V3 (SHA256 with RSA) (+3 more)
 
 ### Community 20 - "Community 20"
 Cohesion: 0.25
-Nodes (14): batch_stock_in(), batch_stock_out(), create_reagent(), _dec(), get_reagent(), _is_enabled_status(), list_reagents(), _norm_status() (+6 more)
+Nodes (13): code_exists(), create_material(), _dec(), _fmt_dt(), get_material(), list_materials(), _norm_status(), options_list() (+5 more)
 
 ### Community 21 - "Community 21"
-Cohesion: 0.21
-Nodes (13): get_method(), create_delivery(), _deduct_fee_on_delivery(), _generate_delivery_no(), get_delivery_by_schedule(), get_delivery_method(), list_deliveries_by_application(), list_delivery_materials() (+5 more)
+Cohesion: 0.27
+Nodes (11): _accept_conclusion_label(), _base_query(), _ensure_notification(), _fmt_dt(), generate_sms_content(), list_notifications(), _normalize_date_bound(), resolve_notification() (+3 more)
 
 ### Community 22 - "Community 22"
-Cohesion: 0.25
-Nodes (13): code_exists(), create_material(), _dec(), _fmt_dt(), get_material(), list_materials(), _norm_status(), options_list() (+5 more)
+Cohesion: 0.24
+Nodes (9): 微信消息通知服务  支持微信小程序模板消息推送  使用方式:     from app.core.wechat_message import Wech, 发送模板消息                  Args:             openid: 用户openid             templ, send_delivery_notice(), send_delivery_warning(), send_order_status(), send_payment_success(), send_refund_result(), send_urge_notification() (+1 more)
 
 ### Community 23 - "Community 23"
-Cohesion: 0.25
-Nodes (13): batch_issue_protocol_nos(), batch_revoke_protocol_nos(), create_protocol_no(), get_protocol_no(), list_protocol_nos(), _parse_issued_time(), protocol_no_exists(), 采集协议号池 CRUD(biz_collection_protocol_no) (+5 more)
+Cohesion: 0.27
+Nodes (11): _build_tree(), _cascade_soft_delete(), _collect_with_ancestors(), create_dept(), get_dept(), list_depts(), list_depts_tree(), 保留匹配节点及其全部上级,供关键词检索后仍能正确组树。 (+3 more)
 
 ### Community 24 - "Community 24"
-Cohesion: 0.26
-Nodes (12): batch_issue_sample_codes(), batch_revoke_sample_codes(), create_sample_code(), get_sample_code(), list_sample_codes(), _parse_issued_time(), 样本编码池 CRUD(biz_sample_code), _resolve_issued_user_display_name() (+4 more)
+Cohesion: 0.29
+Nodes (9): _build_tree(), _cascade_soft_delete(), create_menu(), get_menu(), list_menus(), list_menus_tree(), _row_to_dict(), soft_delete_menu() (+1 more)
 
 ### Community 25 - "Community 25"
-Cohesion: 0.23
-Nodes (12): create_collection_region(), create_hospital(), get_collection_region(), get_hospital(), _hospital_names_by_region_ids(), _hospital_to_dict(), list_collection_regions(), list_hospitals() (+4 more)
-
-### Community 26 - "Community 26"
-Cohesion: 0.24
-Nodes (9): 微信消息通知服务  支持微信小程序模板消息推送  使用方式:     from app.core.wechat_message import Wech, 发送模板消息                  Args:             openid: 用户openid             templ, send_delivery_notice(), send_delivery_warning(), send_order_status(), send_payment_success(), send_refund_result(), send_urge_notification() (+1 more)
-
-### Community 27 - "Community 27"
-Cohesion: 0.23
-Nodes (12): Config, ProductBase, ProductCategoryBase, ProductCategoryCreate, ProductCategoryResponse, ProductCreate, ProductResponse, ProductUnitBase (+4 more)
-
-### Community 28 - "Community 28"
-Cohesion: 0.31
-Nodes (12): _code_exists(), create_material(), _dec(), _fmt_dt(), get_material(), list_materials(), options_list(), _row_to_dict() (+4 more)
-
-### Community 29 - "Community 29"
-Cohesion: 0.24
-Nodes (12): list_depts(), _build_tree(), _cascade_soft_delete(), _collect_with_ancestors(), create_dept(), get_dept(), list_depts(), list_depts_tree() (+4 more)
-
-### Community 30 - "Community 30"
-Cohesion: 0.26
-Nodes (10): list_menus(), _build_tree(), _cascade_soft_delete(), create_menu(), get_menu(), list_menus(), list_menus_tree(), _row_to_dict() (+2 more)
-
-### Community 31 - "Community 31"
-Cohesion: 0.3
-Nodes (10): create_consumable_pack(), get_consumable_pack(), get_pack_detail(), _item_row_to_dict(), list_all_packs(), list_consumable_packs(), pack_code_exists(), _row_to_dict() (+2 more)
-
-### Community 32 - "Community 32"
-Cohesion: 0.24
-Nodes (9): dict_data_options(), create_dict_data(), get_dict_data(), list_dict_data(), list_dict_data_options(), 字典数据 CRUD(sys_dict_data), _row_to_dict(), soft_delete_dict_data() (+1 more)
-
-### Community 33 - "Community 33"
-Cohesion: 0.31
-Nodes (8): create_sop_document(), get_sop_document(), list_sop_documents(), SOP文档 CRUD(biz_sop_document), _row_to_dict(), soft_delete_sop_document(), sop_code_exists(), update_sop_document()
-
-### Community 34 - "Community 34"
-Cohesion: 0.36
-Nodes (9): deduct_for_task(), _display_remark(), list_usages(), list_usages_by_task(), _parse_receiver_purpose_from_remark(), _resolve_receiver_purpose(), revert_task_material_usages(), _row_to_dict() (+1 more)
-
-### Community 35 - "Community 35"
 Cohesion: 0.31
 Nodes (8): create_express_company(), express_code_exists(), get_express_company(), list_express_companies(), 快递公司 CRUD(biz_express_company), _row_to_dict(), soft_delete_express_company(), update_express_company()
 
-### Community 36 - "Community 36"
+### Community 26 - "Community 26"
 Cohesion: 0.38
 Nodes (9): create_check_item(), get_check_item(), item_code_exists(), list_active_check_items(), list_check_items(), _order_query(), _row_to_dict(), soft_delete_check_item() (+1 more)
 
-### Community 37 - "Community 37"
+### Community 27 - "Community 27"
+Cohesion: 0.27
+Nodes (8): create_dict_data(), get_dict_data(), list_dict_data(), list_dict_data_options(), 字典数据 CRUD(sys_dict_data), _row_to_dict(), soft_delete_dict_data(), update_dict_data()
+
+### Community 28 - "Community 28"
 Cohesion: 0.31
 Nodes (8): category_code_exists(), create_process_category(), get_process_category(), list_process_categories(), 工艺工序分类 CRUD(biz_process_category), _row_to_dict(), soft_delete_process_category(), update_process_category()
 
-### Community 38 - "Community 38"
+### Community 29 - "Community 29"
 Cohesion: 0.31
 Nodes (8): create_item_unit(), get_item_unit(), list_item_units(), 物品单位 CRUD(biz_item_unit), _row_to_dict(), soft_delete_item_unit(), unit_code_exists(), update_item_unit()
 
-### Community 39 - "Community 39"
-Cohesion: 0.31
-Nodes (8): create_cell_sample_type(), get_cell_sample_type(), list_cell_sample_types(), 细胞样本类型 CRUD(biz_cell_sample_type), _row_to_dict(), soft_delete_cell_sample_type(), type_code_exists(), update_cell_sample_type()
-
-### Community 40 - "Community 40"
+### Community 30 - "Community 30"
 Cohesion: 0.31
 Nodes (8): create_output_type(), get_output_type(), list_output_types(), 工艺产出物类型 CRUD(biz_output_type), _row_to_dict(), soft_delete_output_type(), type_code_exists(), update_output_type()
 
-### Community 41 - "Community 41"
+### Community 31 - "Community 31"
 Cohesion: 0.36
 Nodes (7): create_post(), get_post(), list_posts(), post_code_exists(), _row_to_dict(), soft_delete_post(), update_post()
 
-### Community 42 - "Community 42"
+### Community 32 - "Community 32"
 Cohesion: 0.36
 Nodes (7): create_tenant(), get_tenant(), list_tenants(), _row_to_dict(), soft_delete_tenant(), tenant_no_exists(), update_tenant()
 
-### Community 43 - "Community 43"
+### Community 33 - "Community 33"
 Cohesion: 0.36
 Nodes (7): create_site(), get_site(), list_sites(), _row_to_dict(), site_code_exists(), soft_delete_site(), update_site()
 
-### Community 44 - "Community 44"
+### Community 34 - "Community 34"
 Cohesion: 0.29
 Nodes (2): BaseModel, SoftDeleteModel
 
-### Community 45 - "Community 45"
-Cohesion: 0.43
-Nodes (7): confirm_infusion(), create_infusion_confirm(), delete_infusion_confirm(), get_confirm_by_id(), list_confirms_by_application(), _row_to_dict(), update_infusion_confirm()
-
-### Community 46 - "Community 46"
-Cohesion: 0.43
-Nodes (7): _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), _resolve_receiver_purpose(), revert_step_stock_changes(), _row_to_dict(), _user_nick_name_map()
-
-### Community 47 - "Community 47"
+### Community 35 - "Community 35"
 Cohesion: 0.38
 Nodes (6): derive_legacy_status_from_three(), derive_three_status_from_legacy(), 阶段A兼容:从旧 status 推导三字段。     约束:旧 status 本身混合了支付/配送/订单生命周期,因此只能尽力推断;     若业务侧已有更精确, 阶段A兼容:从三字段反推旧 status(用于旧前端/旧筛选参数兼容)。, ThreeStatus, _to_decimal()
 
-### Community 48 - "Community 48"
-Cohesion: 0.53
-Nodes (5): create_release_review(), delete_release_review(), list_reviews_by_schedule(), _row_to_dict(), update_review_conclusion()
-
-### Community 49 - "Community 49"
+### Community 36 - "Community 36"
 Cohesion: 0.5
 Nodes (3): BaseSettings, Config, Settings
 
-### Community 50 - "Community 50"
+### Community 37 - "Community 37"
 Cohesion: 0.5
 Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID
 
-### Community 51 - "Community 51"
+### Community 38 - "Community 38"
 Cohesion: 0.67
 Nodes (2): get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,供后端菜单模式联调)
 
-### Community 52 - "Community 52"
+### Community 39 - "Community 39"
 Cohesion: 1.0
 Nodes (1): 请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传
 
-### Community 53 - "Community 53"
+### Community 40 - "Community 40"
 Cohesion: 1.0
 Nodes (1): API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)
 
-### Community 56 - "Community 56"
+### Community 43 - "Community 43"
 Cohesion: 1.0
 Nodes (1): 发送订单状态通知                  Args:             openid: 用户openid             ord
 
-### Community 57 - "Community 57"
+### Community 44 - "Community 44"
 Cohesion: 1.0
 Nodes (1): 发送支付成功通知                  Args:             openid: 用户openid             ord
 
-### Community 58 - "Community 58"
+### Community 45 - "Community 45"
 Cohesion: 1.0
 Nodes (1): 发送配送通知                  Args:             openid: 配送员openid             task
 
-### Community 59 - "Community 59"
+### Community 46 - "Community 46"
 Cohesion: 1.0
 Nodes (1): 发送配送时效预警                  Args:             openid: 配送员openid             ta
 
-### Community 60 - "Community 60"
+### Community 47 - "Community 47"
 Cohesion: 1.0
 Nodes (1): 发送退款结果通知                  Args:             openid: 用户openid             ord
 
-### Community 61 - "Community 61"
+### Community 48 - "Community 48"
 Cohesion: 1.0
 Nodes (1): 发送催单通知给配送员                  Args:             deliver_openid: 配送员openid
 
 ## Knowledge Gaps
 - **90 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `登录凭据 RSA 解密(与前端 JSEncrypt PKCS#1 v1.5 兼容)。`, `Base64 密文 -> JSON 对象(须含 username、password、ts 毫秒时间戳)。` (+85 more)
   These have ≤1 connection - possible missing edges or undocumented components.
-- **Thin community `Community 44`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
+- **Thin community `Community 34`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 50`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
+- **Thin community `Community 37`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 51`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,供后端菜单模式联调)`
+- **Thin community `Community 38`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,供后端菜单模式联调)`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 52`** (2 nodes): `context.py`, `请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
+- **Thin community `Community 39`** (2 nodes): `context.py`, `请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 53`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
+- **Thin community `Community 40`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 56`** (1 nodes): `发送订单状态通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 43`** (1 nodes): `发送订单状态通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 57`** (1 nodes): `发送支付成功通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 44`** (1 nodes): `发送支付成功通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 58`** (1 nodes): `发送配送通知                  Args:             openid: 配送员openid             task`
+- **Thin community `Community 45`** (1 nodes): `发送配送通知                  Args:             openid: 配送员openid             task`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 59`** (1 nodes): `发送配送时效预警                  Args:             openid: 配送员openid             ta`
+- **Thin community `Community 46`** (1 nodes): `发送配送时效预警                  Args:             openid: 配送员openid             ta`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 60`** (1 nodes): `发送退款结果通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 47`** (1 nodes): `发送退款结果通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 61`** (1 nodes): `发送催单通知给配送员                  Args:             deliver_openid: 配送员openid`
+- **Thin community `Community 48`** (1 nodes): `发送催单通知给配送员                  Args:             deliver_openid: 配送员openid`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
 
 ## Suggested Questions
 _Questions this graph is uniquely positioned to answer:_
 
-- **Why does `SysUser` connect `Community 1` to `Community 2`, `Community 4`, `Community 5`, `Community 7`, `Community 9`, `Community 10`, `Community 23`, `Community 24`?**
-  _High betweenness centrality (0.087) - this node is a cross-community bridge._
-- **Why does `generate_uuid()` connect `Community 4` to `Community 0`, `Community 1`, `Community 2`, `Community 3`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 12`, `Community 14`, `Community 15`, `Community 17`, `Community 18`, `Community 19`, `Community 20`, `Community 22`, `Community 23`, `Community 24`, `Community 25`, `Community 28`, `Community 29`, `Community 30`, `Community 31`, `Community 32`, `Community 33`, `Community 34`, `Community 35`, `Community 36`, `Community 37`, `Community 38`, `Community 39`, `Community 40`, `Community 41`, `Community 42`, `Community 43`, `Community 45`, `Community 48`?**
-  _High betweenness centrality (0.083) - this node is a cross-community bridge._
-- **Why does `success_response()` connect `Community 0` to `Community 32`, `Community 1`, `Community 3`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 51`, `Community 21`, `Community 29`, `Community 30`?**
-  _High betweenness centrality (0.078) - this node is a cross-community bridge._
-- **Are the 501 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
-  _`success_response()` has 501 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 477 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `extract_login_credentials()`) actually correct?**
-  _`str` has 477 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 400 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
-  _`error_response()` has 400 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 321 inferred relationships involving `SysUser` (e.g. with `数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤)  data_scope 值:   1 = 全部数据   2 = 本部门数据   3` and `从 SysUser 读取用户的 dept_id。`) actually correct?**
-  _`SysUser` has 321 INFERRED edges - model-reasoned connections that need verification._
+- **Why does `SysUser` connect `Community 1` to `Community 2`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 8`, `Community 9`, `Community 12`, `Community 13`, `Community 14`?**
+  _High betweenness centrality (0.116) - this node is a cross-community bridge._
+- **Why does `success_response()` connect `Community 0` to `Community 1`, `Community 5`, `Community 38`, `Community 6`, `Community 8`, `Community 9`, `Community 15`, `Community 17`?**
+  _High betweenness centrality (0.075) - this node is a cross-community bridge._
+- **Why does `generate_uuid()` connect `Community 3` to `Community 0`, `Community 2`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 14`, `Community 15`, `Community 17`, `Community 18`, `Community 20`, `Community 21`, `Community 23`, `Community 24`, `Community 25`, `Community 26`, `Community 27`, `Community 28`, `Community 29`, `Community 30`, `Community 31`, `Community 32`, `Community 33`?**
+  _High betweenness centrality (0.055) - this node is a cross-community bridge._
+- **Are the 505 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
+  _`success_response()` has 505 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 487 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `extract_login_credentials()`) actually correct?**
+  _`str` has 487 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 405 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
+  _`error_response()` has 405 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 327 inferred relationships involving `SysUser` (e.g. with `数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤)  data_scope 值:   1 = 全部数据   2 = 本部门数据   3` and `从 SysUser 读取用户的 dept_id。`) actually correct?**
+  _`SysUser` has 327 INFERRED edges - model-reasoned connections that need verification._

ファイルの差分が大きいため隠しています
+ 1 - 1
backend/graphify-out/graph.html


ファイルの差分が大きいため隠しています
+ 112 - 112
backend/graphify-out/graph.json


+ 18 - 11
backend/graphify-out/manifest.json

@@ -25,7 +25,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/wechat_message.py": 1778578258.0458734,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/material.py": 1779283385.245847,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/qc_param.py": 1779022399.0175831,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/models/models.py": 1779792400.2401285,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/models/models.py": 1779828240.789942,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/__init__.py": 1779418058.876012,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/research_donation.py": 1779177348.5945773,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/qc_material.py": 1779284411.5179992,
@@ -35,6 +35,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/process_route.py": 1779367496.1718137,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/auth.py": 1778578258.0939043,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/delivery.py": 1778578258.0949655,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/outbound_release_review.py": 1779808997.3299003,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/price_list.py": 1778578258.096609,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/user.py": 1778578258.099944,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/production_work_order.py": 1779692778.4620268,
@@ -58,9 +59,11 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/common.py": 1778578258.094703,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/unit.py": 1778578258.0996897,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/qc_work_order.py": 1779791557.200459,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/outbound_release_material.py": 1779827058.9935315,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/product.py": 1778578258.0968297,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/workflow.py": 1778633845.5937889,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/product_review.py": 1778578258.098316,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/outbound_handover.py": 1779828242.039992,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/category.py": 1778578258.0941947,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/leave.py": 1778666088.7680702,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/storage_release_review.py": 1779616246.7074304,
@@ -68,11 +71,11 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/process_route.py": 1779587424.6859367,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_code.py": 1779279460.690872,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_task_result.py": 1779331731.6720955,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_release_review.py": 1779656973.8652866,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_release_review.py": 1779825896.040372,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/post.py": 1779265648.38155,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception_check_result.py": 1779255909.8554008,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/user.py": 1778775532.8870275,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_work_order.py": 1779801387.303171,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_work_order.py": 1779807082.0841787,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/material.py": 1779283386.3879013,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/dict_type.py": 1779265621.5212462,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/storage_occupancy.py": 1779418213.0446064,
@@ -86,20 +89,20 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/reagent.py": 1779512669.9847593,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol.py": 1779692218.216856,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_usage.py": 1779332036.2049248,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_infusion_confirm.py": 1779657020.847086,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_infusion_confirm.py": 1779828818.7646794,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/reagent_stock_log.py": 1779515479.6599197,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_infusion_schedule_mgmt.py": 1779794445.3921702,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_infusion_schedule_mgmt.py": 1779828859.820152,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/clean_room.py": 1778775238.2993922,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/__init__.py": 1778605691.3816392,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/storage_entry_sms_notification.py": 1779691734.0972729,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/express_company.py": 1779265659.3453207,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_application.py": 1779794392.4726036,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_application.py": 1779807155.5996397,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_lab_test_item.py": 1779265673.3888667,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/role.py": 1779265616.3701816,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/cell_product.py": 1779600789.9863036,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/menu.py": 1779265729.0381103,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_region.py": 1779277867.6408322,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_delivery.py": 1779655560.688,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_delivery.py": 1779807151.291718,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception_check_item.py": 1779007909.2933013,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/site.py": 1779265643.0128026,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/api_request_log.py": 1778578258.0614743,
@@ -109,7 +112,9 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_checkup.py": 1779767080.896167,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/process_category.py": 1779265700.0606413,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/item_unit.py": 1779265653.7504313,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_work_order.py": 1779801381.700379,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_work_order.py": 1779807125.279701,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_handover_inventory.py": 1779830339.8633556,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_release_material.py": 1779827094.4205513,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/process.py": 1779309113.0447981,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_service.py": 1779792399.6927543,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/customer.py": 1779686149.7508218,
@@ -119,8 +124,10 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/consumable_pack.py": 1779265715.416005,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material.py": 1779284415.64655,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol_no.py": 1779279043.1571646,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_handover.py": 1779830359.238616,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/storage_facility.py": 1779270989.9713993,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/cell_sample_type.py": 1778733001.8375483,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_outbound_status_sync.py": 1779807071.7870579,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/consumable_stock_log.py": 1779515477.284446,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/dept.py": 1779265637.7914758,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/base.py": 1778578258.062207,
@@ -130,17 +137,17 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/output_type.py": 1779265703.7363207,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/exhibition_appointment.py": 1778801075.5211918,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/storage_release_review.py": 1779526635.1455016,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/api/outbound_release_review.py": 1779656994.641828,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/api/outbound_release_review.py": 1779827082.0736034,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/public.py": 1779313409.0685387,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/material.py": 1779526648.041865,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/qc.py": 1779791675.3703022,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/collection_protocol.py": 1779692218.217267,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/basic_info.py": 1779686149.7501295,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_infusion_confirm.py": 1779657043.1389203,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_infusion_schedule_mgmt.py": 1779781585.2199733,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_infusion_schedule_mgmt.py": 1779828267.2070124,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/__init__.py": 1779771972.6553016,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/storage_entry_sms_notification.py": 1779691738.9150457,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_application.py": 1779791571.100721,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_application.py": 1779807078.5962884,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/operations.py": 1779685679.1959367,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/menu.py": 1779263225.4964793,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_delivery.py": 1779655584.820777,

+ 23 - 0
docs/sql/outbound_handover.sql

@@ -0,0 +1,23 @@
+-- 输注计划管理 · 出库交接
+
+CREATE TABLE IF NOT EXISTS `biz_outbound_handover` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID',
+  `infusion_schedule_id` varchar(64) NOT NULL COMMENT '输注计划ID',
+  `handover_user_id` varchar(64) DEFAULT NULL COMMENT '交接人用户ID',
+  `handover_person` varchar(64) DEFAULT NULL COMMENT '交接人姓名快照',
+  `handover_time` datetime DEFAULT NULL COMMENT '交接时间',
+  `handover_location` varchar(200) DEFAULT NULL COMMENT '交接地点',
+  `receiver_person` varchar(200) DEFAULT NULL COMMENT '接收人(文本)',
+  `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
+  `order_num` int(11) 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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_oh_schedule` (`infusion_schedule_id`, `del_flag`),
+  KEY `idx_oh_tenant` (`tenant_no`, `del_flag`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='输注计划-出库交接';

+ 71 - 0
docs/sql/outbound_release_material.sql

@@ -0,0 +1,71 @@
+-- 出库放行审核 · 出库物料准备(台账,不联动库存)
+
+CREATE TABLE IF NOT EXISTS `biz_outbound_release_material` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID',
+  `infusion_schedule_id` varchar(64) NOT NULL COMMENT '输注计划ID',
+  `paper_attachments` varchar(4000) DEFAULT NULL COMMENT '其他纸质材料附件(逗号分隔URL)',
+  `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
+  `order_num` int(11) 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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_orm_schedule` (`infusion_schedule_id`, `del_flag`),
+  KEY `idx_orm_tenant` (`tenant_no`, `del_flag`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库放行-出库物料准备主表';
+
+CREATE TABLE IF NOT EXISTS `biz_outbound_release_cell` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID',
+  `material_id` varchar(64) NOT NULL COMMENT '关联 biz_outbound_release_material.id',
+  `infusion_schedule_id` varchar(64) NOT NULL COMMENT '输注计划ID',
+  `cell_product_id` varchar(64) NOT NULL COMMENT '细胞产品ID',
+  `product_code` varchar(128) DEFAULT NULL COMMENT '产品编码快照',
+  `product_name` varchar(200) DEFAULT NULL COMMENT '产品名称快照',
+  `product_type_name` varchar(100) DEFAULT NULL COMMENT '产品类型快照',
+  `spec_name` varchar(100) DEFAULT NULL COMMENT '规格快照',
+  `passage` varchar(32) DEFAULT NULL COMMENT '代次快照',
+  `batch_no` varchar(64) DEFAULT NULL COMMENT '批次号快照',
+  `storage_slot_name` varchar(200) DEFAULT NULL COMMENT '储物格快照',
+  `product_status` varchar(32) DEFAULT NULL COMMENT '保存时状态快照',
+  `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
+  `order_num` int(11) 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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_orc_schedule_product` (`infusion_schedule_id`, `cell_product_id`, `del_flag`),
+  KEY `idx_orc_product` (`cell_product_id`, `del_flag`),
+  KEY `idx_orc_material` (`material_id`, `del_flag`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库放行-出库细胞(台账)';
+
+CREATE TABLE IF NOT EXISTS `biz_outbound_release_qc_material` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID',
+  `material_id` varchar(64) NOT NULL COMMENT '关联 biz_outbound_release_material.id',
+  `infusion_schedule_id` varchar(64) NOT NULL COMMENT '输注计划ID',
+  `qc_material_id` varchar(64) NOT NULL COMMENT '质检物料ID',
+  `qty` decimal(18,4) NOT NULL COMMENT '计划用量(台账)',
+  `material_code` varchar(64) DEFAULT NULL COMMENT '物料编码快照',
+  `material_name` varchar(200) DEFAULT NULL COMMENT '物料名称快照',
+  `spec_model` varchar(200) DEFAULT NULL COMMENT '规格型号快照',
+  `unit` varchar(32) DEFAULT NULL COMMENT '单位快照',
+  `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
+  `order_num` int(11) 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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_orqm_schedule_material` (`infusion_schedule_id`, `qc_material_id`, `del_flag`),
+  KEY `idx_orqm_material` (`material_id`, `del_flag`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库放行-质检物料(台账)';

+ 22 - 0
docs/sql/outbound_release_paper_material.sql

@@ -0,0 +1,22 @@
+-- 出库放行审核 · 其他纸质材料子表(逐条:名称、份数、附件)
+
+CREATE TABLE IF NOT EXISTS `biz_outbound_release_paper` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID',
+  `material_id` varchar(64) NOT NULL COMMENT '关联 biz_outbound_release_material.id',
+  `infusion_schedule_id` varchar(64) NOT NULL COMMENT '输注计划ID',
+  `paper_name` varchar(200) NOT NULL COMMENT '材料名称',
+  `copies` int(11) NOT NULL DEFAULT 1 COMMENT '份数',
+  `attachment_url` varchar(2000) DEFAULT NULL COMMENT '附件URL',
+  `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
+  `order_num` int(11) 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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
+  PRIMARY KEY (`id`),
+  KEY `idx_orp_schedule` (`infusion_schedule_id`, `del_flag`),
+  KEY `idx_orp_material` (`material_id`, `del_flag`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库放行-其他纸质材料(台账)';

+ 4 - 0
docs/sql/outbound_release_review_add_fields.sql

@@ -0,0 +1,4 @@
+-- 出库放行审核表新增审核附件和审核人姓名字段
+ALTER TABLE biz_outbound_release_review
+    ADD COLUMN review_attachments VARCHAR(4000) NULL COMMENT '审核附件(逗号分隔URL)' AFTER review_remark,
+    ADD COLUMN review_by_name VARCHAR(64) NULL COMMENT '审核人姓名' AFTER reviewer_user_id;

+ 349 - 0
docs/superpowers/specs/2026-05-27-infusion-schedule-outbound-handover-design.md

@@ -0,0 +1,349 @@
+# 输注计划管理 · 出库交接设计
+
+> 日期: 2026-05-27  
+> 状态: 已实现  
+> 父模块: 中试服务管理 → 输注计划管理  
+> 关联: `2026-05-27-outbound-release-material-design.md`(出库物料台账)、`outbound-release-review`  
+> **库存策略(已确认)**: **台账模式 — 与出库物料准备一致,保存时不联动库存**
+
+## 1. 背景与目标
+
+### 现状
+
+- **出库放行审核**页已实现「出库物料准备」:按输注计划登记细胞 / 质检物料 / 纸质材料(台账,不扣库存)。
+- **输注计划管理**页仅有编辑、确认、完成、取消、查看,无出库相关操作。
+- 全库尚无独立的「出库交接」记录表与 API。
+
+### 目标
+
+在**输注计划管理**列表操作列新增「**出库交接**」按钮。点击后打开弹窗:
+
+| 区块 | 行为 |
+|------|------|
+| 出库物料 | 展示并可编辑(与「出库物料准备」相同三区 + 物料备注) |
+| 交接信息 | 填写交接人、交接时间、交接地点、接收人、备注 |
+| 保存 | **一次提交**,同时保存物料台账 + 交接记录 |
+
+### 非目标
+
+- 不改动细胞产品状态、不扣质检物料库存(沿用台账策略)。
+- v1 **不自动修改**输注计划 `schedule_status` / `infusion_status`(避免与「完成」结算逻辑冲突)。
+- 不与中试申请单「交付方式 / 交接温度」合并。
+
+---
+
+## 2. 已确认的产品规则
+
+| 项 | 决策 |
+|----|------|
+| **交接人** | 默认当前登录用户;`el-select` 远程搜索 `sys_user`(参考 `dept/index.vue` 负责人选择器);存 `handoverUserId` + 姓名快照 `handoverPerson` |
+| **接收人** | 自由文本 `el-input`,存 `receiverPerson` |
+| **交接时间** | 必填;`el-date-picker` type=`datetime`;**首次打开且无历史记录时默认此刻**(客户端 `new Date()`,格式 `YYYY-MM-DD HH:mm:ss`) |
+| **交接地点** | 可选文本 |
+| **备注** | 可选文本(交接区块独立备注,与物料 remark 分开) |
+| **再次编辑** | **已交接后仍可再次打开「出库交接」编辑并 upsert**(非只读、非一次性锁定) |
+| **查看弹窗** | **输注计划「查看」弹窗须只读展示交接信息**(见 §6.5) |
+| **按钮可见性** | `infusionStatus !== 'yqx'`(已取消计划不显示) |
+| **保存后状态** | v1 不改输注/生产状态 |
+
+---
+
+## 3. 架构决策
+
+| 项 | 决策 |
+|----|------|
+| 方案 | **方案 A**:独立交接表 + 组合弹窗 + 单事务保存 |
+| UI 模式 | **P22** 审核/子表编辑弹窗 + 表单区(参考 `OutboundReleaseMaterialDialog` + `ScheduleEditDialog`) |
+| 物料数据 | 复用 `biz_outbound_release_material` 及 cells / qc / paper 子表(与放行审核共用) |
+| 交接数据 | 新建 `biz_outbound_handover`(1:1 输注计划) |
+| API 归属 | 挂在 **输注计划管理** 路由 `/web/admin/pilot-infusion-schedule-mgmt/` |
+| 组件复用 | 从 `OutboundReleaseMaterialDialog.vue` 抽取 `OutboundMaterialFormSections.vue` 公共组件 |
+
+### 与出库物料准备的关系
+
+```
+出库放行审核 ──「出库物料」──┐
+                            ├──► biz_outbound_release_material (+ 子表)
+输注计划管理 ──「出库交接」──┘              │
+                            └──► biz_outbound_handover(仅交接页写入)
+```
+
+两处编辑**同一份物料台账**;交接页额外写入交接元数据。
+
+---
+
+## 4. 数据库设计
+
+SQL 文件:`docs/sql/outbound_handover.sql`
+
+### 4.1 `biz_outbound_handover`(交接主表,1:1 输注计划)
+
+```sql
+CREATE TABLE IF NOT EXISTS `biz_outbound_handover` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID',
+  `infusion_schedule_id` varchar(64) NOT NULL COMMENT '输注计划ID',
+  `handover_user_id` varchar(64) DEFAULT NULL COMMENT '交接人用户ID',
+  `handover_person` varchar(64) DEFAULT NULL COMMENT '交接人姓名快照',
+  `handover_time` datetime DEFAULT NULL COMMENT '交接时间',
+  `handover_location` varchar(200) DEFAULT NULL COMMENT '交接地点',
+  `receiver_person` varchar(200) DEFAULT NULL COMMENT '接收人(文本)',
+  `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
+  `order_num` int(11) 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 DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_oh_schedule` (`infusion_schedule_id`, `del_flag`),
+  KEY `idx_oh_tenant` (`tenant_no`, `del_flag`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='输注计划-出库交接';
+```
+
+> 物料表结构不变,见 `docs/sql/outbound_release_material.sql`、`docs/sql/outbound_release_paper_material.sql`。
+
+---
+
+## 5. API 设计
+
+前缀:`/web/admin/pilot-infusion-schedule-mgmt`
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/{scheduleId}/outbound-handover` | 获取交接信息 + 出库物料(编辑弹窗用) |
+| PUT | `/{scheduleId}/outbound-handover` | 保存交接 + 出库物料(单事务) |
+| GET | `/detail?scheduleId=` | **扩展**:响应增加 `outboundHandover`(查看弹窗用,见 §5.4) |
+
+### 5.1 GET 响应
+
+```json
+{
+  "scheduleSummary": {
+    "applicationNo": "SQ20260527001",
+    "patientName": "张三",
+    "infusionTime": "2026-05-28 10:00:00"
+  },
+  "outboundMaterial": {
+    "cells": [],
+    "qcMaterials": [],
+    "paperMaterials": [],
+    "remark": ""
+  },
+  "handover": {
+    "handoverUserId": "user-uuid",
+    "handoverPerson": "李四",
+    "handoverTime": "2026-05-27 14:30:00",
+    "handoverLocation": "中试库房",
+    "receiverPerson": "王五(渠道)",
+    "remark": ""
+  }
+}
+```
+
+- 若无交接记录:`handover` 各字段为空;编辑弹窗前端默认 **当前用户 + 此刻**。
+- `outboundMaterial` 结构同 `GET .../outbound-materials`。
+
+### 5.2 PUT Body
+
+```json
+{
+  "outboundMaterial": {
+    "remark": "",
+    "cells": [{ "cellProductId": "..." }],
+    "qcMaterials": [{ "materialId": "...", "qty": 1.5 }],
+    "paperMaterials": [{ "paperName": "知情同意书", "copies": 2, "attachmentUrl": "..." }]
+  },
+  "handover": {
+    "handoverUserId": "user-uuid",
+    "handoverTime": "2026-05-27 14:30:00",
+    "handoverLocation": "中试库房",
+    "receiverPerson": "王五",
+    "remark": ""
+  }
+}
+```
+
+- `handoverPerson` **不由前端提交**,后端根据 `handoverUserId` 查 `sys_user.nick_name` 写入快照。
+- 校验:`handoverUserId`、`handoverTime`、`receiverPerson` 必填;物料校验复用 `save_outbound_material` 规则。
+
+### 5.3 保存流程(单事务)
+
+```
+save_outbound_handover(schedule_id, payload):
+  1. 校验输注计划存在 + 租户 + 数据权限
+  2. 校验 handover 必填字段;handoverUserId 对应用户存在
+  3. 调用 save_outbound_material(...) — 全量替换物料台账
+  4. upsert biz_outbound_handover(写 handover_person 快照)
+  5. db.commit()
+```
+
+### 5.4 扩展输注计划 Detail(查看弹窗)
+
+现有 `GET /web/admin/pilot-infusion-schedule-mgmt/detail` 响应增加:
+
+```json
+{
+  "...": "原有 schedule / application / patient / products 字段",
+  "outboundHandover": {
+    "handoverPerson": "李四",
+    "handoverTime": "2026-05-27 14:30:00",
+    "handoverLocation": "中试库房",
+    "receiverPerson": "王五",
+    "remark": ""
+  }
+}
+```
+
+- 无交接记录时:`outboundHandover` 为 `null` 或各字段空;查看弹窗 Section 仍展示,字段为 `—`。
+- 查看弹窗**仅展示交接五字段**,不在此版扩展出库物料只读区(物料仍通过「出库交接」编辑弹窗维护)。
+
+---
+
+## 6. 前端设计
+
+### 6.1 页面改动
+
+| 文件 | 改动 |
+|------|------|
+| `infusion-schedule-management/index.vue` | 操作列「出库交接」;`min-width` 调至 ≥ 500px |
+| `InfusionScheduleHandoverDialog.vue` | **新建**组合弹窗 |
+| `ScheduleViewDialog.vue` | 新增「出库交接」只读 Section |
+| `components/business/outbound-material-form-sections/index.vue` | **新建**(从 MaterialDialog 抽取) |
+| `OutboundReleaseMaterialDialog.vue` | 改为引用公共组件(可选,建议同期重构) |
+| `api/admin.ts` 或 `api/infusion-schedule-handover.ts` | fetch/save API |
+
+### 6.2 弹窗结构
+
+```
+标题:出库交接
+宽度:1120px(与出库物料准备一致)
+class:dialog-form-layout
+
+┌─ 摘要条 ─────────────────────────────────────┐
+│ 申请单号 / 患者姓名 / 输注时间                  │
+├─ OutboundMaterialFormSections ──────────────┤
+│ 出库细胞 | 使用的物料 | 其他纸质材料 | 物料备注  │
+├─ 交接信息 (form-grid-2, label-position=top) ─┤
+│ 交接人 [用户搜索 select,默认当前用户]          │
+│ 交接时间 [datetime picker,默认 now]           │
+│ 交接地点 [text]                                │
+│ 接收人   [text,必填]                          │
+│ 备注     [textarea,通栏]                      │
+├─ footer: 取消 / 保存 ─────────────────────────┤
+```
+
+### 6.3 交接人选择器
+
+- 复用 `fetchUserList({ keyword, status: '1' })` 远程搜索。
+- 打开弹窗时:
+  - 若 GET 返回已有 `handoverUserId` → 回填;
+  - 否则默认 `useUserStore().info.userId` / `nickName`,并写入 options 首项。
+- 选项 label:`nickName · userNo · phoneNo`(与部门负责人选择器一致)。
+- `@change` 时更新本地 `handoverUserId`;展示名由后端快照为准。
+
+### 6.4 交互规则
+
+- 保存成功:`ElMessage.success('出库交接已保存')`。
+- 物料区校验、附件上传、细胞选择逻辑与 `OutboundReleaseMaterialDialog` 完全一致。
+- 取消关闭不保存;destroy-on-close 重置状态。
+- **已保存过的计划**:再次点「出库交接」正常打开并回填;保存为 upsert,不提示「已完成不可改」。
+
+### 6.5 查看弹窗(`ScheduleViewDialog.vue`)
+
+在现有 `SystemViewDialog` sections 中,于「产品列表」与「其他信息」之间插入:
+
+```text
+Section 标题:出库交接
+fields(只读 ViewField):
+  交接人     handoverPerson
+  交接时间   handoverTime
+  交接地点   handoverLocation
+  接收人     receiverPerson
+  备注       remark(span: true)
+```
+
+- 数据来源:`fetchInfusionScheduleMgmtDetail` 返回的 `outboundHandover`(后端 detail 聚合查询 `biz_outbound_handover`)。
+- 无记录:该 Section 仍展示,各字段值为 `—`,或在 Section 内用一行 muted 文案「暂无出库交接记录」(与 `SystemViewDialog` 空值展示习惯一致,实现时二选一,推荐字段 `—` + section 始终可见)。
+- 布局:`grid-columns` 保持与现有一致(3 列);备注 `span: true`。
+
+---
+
+## 7. 后端模块划分
+
+| 文件 | 职责 |
+|------|------|
+| `app/crud/outbound_handover.py` | GET/PUT 聚合、upsert 交接、按 schedule 查询交接(供 detail 用) |
+| `app/crud/pilot_infusion_schedule_mgmt.py` | detail 聚合 `outboundHandover` |
+| `app/schemas/outbound_handover.py` | Pydantic |
+| `app/api/pilot_infusion_schedule_mgmt.py` | outbound-handover 两个端点 + detail 扩展 |
+| `app/models/models.py` | `BizOutboundHandover` ORM |
+
+**不修改:** `cell_product`、`qc_material_usage` 库存逻辑。
+
+---
+
+## 8. 权限
+
+- 建议菜单/按钮权限:`pilot-service:infusion-schedule-management:handover`
+- v1 可与列表 edit 权限共用(`require_admin_or_super_admin`),上线前按产品确认。
+
+---
+
+## 9. 错误处理
+
+| 场景 | HTTP | 说明 |
+|------|------|------|
+| 输注计划不存在 | 404 | — |
+| 交接人用户不存在 | 400 | handoverUserId 无效 |
+| 接收人为空 | 400 | — |
+| 交接时间为空 | 400 | — |
+| 物料校验失败 | 400 | 复用现有物料错误文案 |
+
+---
+
+## 10. 测试要点
+
+1. 首次保存:物料 + 交接均写入;交接人快照与所选用户 nickName 一致。
+2. 再次打开:物料与交接均正确回填;交接人 select 显示已选用户。
+3. 新建弹窗默认:交接人 = 当前用户,交接时间 = **打开弹窗此刻**。
+4. 已交接计划:再次打开编辑弹窗可修改并保存;查看弹窗展示最新交接信息。
+5. 查看弹窗:有交接记录时五字段正确展示;无记录时 Section 可见且为空态。
+6. 放行审核页改动物料后,交接页打开应看到最新物料。
+7. 交接页改动物料后,放行审核页「出库物料」应看到同步结果。
+8. 已取消计划(`yqx`)不显示「出库交接」按钮。
+9. 保存后:`biz_cell_product` / `biz_qc_material.stock_qty` **不变**。
+
+---
+
+## 11. OpenSpec 增量(建议)
+
+- `infusion-schedule-outbound-handover-api`: GET/PUT outbound-handover
+- `infusion-schedule-outbound-handover-ui`: 列表按钮 + 组合弹窗 + 物料组件抽取
+
+---
+
+## 12. 工作量估算
+
+| 项 | 人天 |
+|----|------|
+| SQL + Model + CRUD + API | 0.5 |
+| 抽取物料公共组件 + 交接弹窗 | 1.0 |
+| 查看弹窗 Section + detail 聚合 | 0.25 |
+| 列表入口 + 联调 | 0.5 |
+| **合计** | **~2.25** |
+
+---
+
+## 附录:决策记录
+
+| 日期 | 议题 | 决策 |
+|------|------|------|
+| 2026-05-27 | 交接人 / 接收人 | 交接人:默认当前用户 + 用户表搜索;接收人:文本 |
+| 2026-05-27 | 数据模型 | 独立 `biz_outbound_handover`,物料共用现有表 |
+| 2026-05-27 | 保存语义 | 单事务:物料 + 交接一并保存 |
+| 2026-05-27 | 状态联动 | v1 不改 schedule/infusion 状态 |
+| 2026-05-27 | 交接时间 | 首次打开默认此刻 |
+| 2026-05-27 | 编辑策略 | 已交接后可再次编辑(upsert) |
+| 2026-05-27 | 查看弹窗 | 输注计划 ScheduleViewDialog 展示交接五字段 |

+ 284 - 0
docs/superpowers/specs/2026-05-27-outbound-release-material-design.md

@@ -0,0 +1,284 @@
+# 出库放行审核 · 出库物料准备(台账)设计
+
+> 日期: 2026-05-27  
+> 状态: 待审阅  
+> 父模块: 中试服务管理 → 出库放行审核  
+> 关联: `openspec/changes/outbound-release-review`、方案 A(独立弹窗 + 子表)  
+> **库存策略(已确认)**: **台账模式 — 出库物料准备界面仅记录,不联动库存**
+
+## 1. 背景与目标
+
+### 现状
+
+- 出库放行审核列表/查看/审核已实现,审核记录存于 `biz_outbound_release_review`。
+- 每条输注计划尚无「出库物料准备」维护入口。
+- 细胞产品库、质检物料库已有独立库存/状态管理(生产任务、质检任务等场景会真实扣减)。
+
+### 目标
+
+针对**每条输注计划**,提供「**出库物料准备**」界面,用于登记拟出库内容(台账),**保存时不改变细胞产品状态、不扣质检物料库存**:
+
+| 区块 | 数据来源 | 保存行为 |
+|------|----------|----------|
+| 出库细胞 | 细胞产品库勾选 | 写入关联 ID + 字段快照,**不改** `biz_cell_product` |
+| 使用的物料 | 质检物料库勾选 + 用量 | 写入关联 ID + 用量 + 快照,**不扣** `stock_qty` |
+| 其他纸质材料 | 附件上传 | 仅存 URL |
+
+### 非目标
+
+- **不在本界面做真实出库**(不改细胞状态、不释库位、不写 `biz_qc_material_usage`)。
+- 不与「交付物料清单」(`biz_pilot_delivery_material`)合并或自动同步。
+- 批量审核弹窗不涉及出库物料(仍按条维护)。
+- 真实出库若后续需要,另开能力/节点实现(本 spec 不覆盖)。
+
+## 2. 架构决策
+
+| 项 | 决策 |
+|----|------|
+| UI 定位 | 「出库物料**准备**」— 台账登记,非库存事务 |
+| UI | 列表操作列「出库物料」→ `OutboundReleaseMaterialDialog.vue` |
+| 数据 | 1 主表 + 2 子表;纸质附件放主表 |
+| 保存语义 | **全量替换**:软删旧子表行 → 写入新行 + 主数据快照 |
+| 库存 | **零联动**;前端可展示当前库存/状态作参考,保存时不校验库存是否足够 |
+| 复杂度 | 无 revert/apply、无 `qc_material_usage` 扩展、无库位占用变更 |
+
+## 3. 数据库设计
+
+SQL 文件:`docs/sql/outbound_release_material.sql`
+
+### 3.1 `biz_outbound_release_material`(主表,1:1 输注计划)
+
+```sql
+CREATE TABLE `biz_outbound_release_material` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID',
+  `infusion_schedule_id` varchar(64) NOT NULL COMMENT '输注计划ID',
+  `paper_attachments` varchar(4000) DEFAULT NULL COMMENT '其他纸质材料附件(逗号分隔URL)',
+  `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw',
+  `order_num` int(11) DEFAULT NULL,
+  `remark` varchar(512) DEFAULT NULL,
+  `create_by` varchar(64) DEFAULT NULL,
+  `create_dept_id` varchar(64) NOT NULL DEFAULT '',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_by` varchar(64) DEFAULT NULL,
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `del_flag` tinyint(4) NOT NULL DEFAULT 0,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_orm_schedule` (`infusion_schedule_id`, `del_flag`),
+  KEY `idx_orm_tenant` (`tenant_no`, `del_flag`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库放行-出库物料准备主表';
+```
+
+### 3.2 `biz_outbound_release_cell`(出库细胞行)
+
+```sql
+CREATE TABLE `biz_outbound_release_cell` (
+  `id` varchar(64) NOT NULL,
+  `material_id` varchar(64) NOT NULL COMMENT '关联 biz_outbound_release_material.id',
+  `infusion_schedule_id` varchar(64) NOT NULL COMMENT '冗余,便于查询',
+  `cell_product_id` varchar(64) NOT NULL COMMENT '细胞产品ID',
+  `product_code` varchar(128) DEFAULT NULL COMMENT '保存时快照',
+  `product_name` varchar(200) DEFAULT NULL,
+  `product_type_name` varchar(100) DEFAULT NULL,
+  `spec_name` varchar(100) DEFAULT NULL,
+  `passage` varchar(32) DEFAULT NULL,
+  `batch_no` varchar(64) DEFAULT NULL,
+  `storage_slot_name` varchar(200) DEFAULT NULL,
+  `product_status` varchar(32) DEFAULT NULL COMMENT '保存时状态快照(只读展示)',
+  `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw',
+  `order_num` int(11) DEFAULT NULL,
+  `remark` varchar(512) DEFAULT NULL,
+  `create_by` varchar(64) DEFAULT NULL,
+  `create_dept_id` varchar(64) NOT NULL DEFAULT '',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_by` varchar(64) DEFAULT NULL,
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `del_flag` tinyint(4) NOT NULL DEFAULT 0,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_orc_schedule_product` (`infusion_schedule_id`, `cell_product_id`, `del_flag`),
+  KEY `idx_orc_product` (`cell_product_id`, `del_flag`),
+  KEY `idx_orc_material` (`material_id`, `del_flag`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库放行-出库细胞(台账)';
+```
+
+> 不需要 `product_status_before` / `storage_slot_id` 回滚字段。
+
+### 3.3 `biz_outbound_release_qc_material`(质检物料行)
+
+```sql
+CREATE TABLE `biz_outbound_release_qc_material` (
+  `id` varchar(64) NOT NULL,
+  `material_id` varchar(64) NOT NULL COMMENT '关联 biz_outbound_release_material.id',
+  `infusion_schedule_id` varchar(64) NOT NULL,
+  `qc_material_id` varchar(64) NOT NULL,
+  `qty` decimal(18,4) NOT NULL COMMENT '计划用量(台账)',
+  `material_code` varchar(64) DEFAULT NULL,
+  `material_name` varchar(200) DEFAULT NULL,
+  `spec_model` varchar(200) DEFAULT NULL,
+  `unit` varchar(32) DEFAULT NULL,
+  `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw',
+  `order_num` int(11) DEFAULT NULL,
+  `remark` varchar(512) DEFAULT NULL,
+  `create_by` varchar(64) DEFAULT NULL,
+  `create_dept_id` varchar(64) NOT NULL DEFAULT '',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_by` varchar(64) DEFAULT NULL,
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `del_flag` tinyint(4) NOT NULL DEFAULT 0,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_orqm_schedule_material` (`infusion_schedule_id`, `qc_material_id`, `del_flag`),
+  KEY `idx_orqm_material` (`material_id`, `del_flag`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库放行-质检物料(台账)';
+```
+
+> **不**关联 `biz_qc_material_usage`;**不**扩展 `source_type`。
+
+## 4. 核心业务流程
+
+### 4.1 保存出库物料准备(单事务)
+
+```
+save_outbound_release_material(schedule_id, payload):
+  1. 校验输注计划存在 + 租户 + 数据权限
+  2. 校验 payload:
+       - cells 内 cell_product_id 不重复
+       - qcMaterials 内 material_id 不重复
+       - qty > 0
+       - 关联主数据存在且未软删(产品/物料)
+  3. upsert 主表(paper_attachments、remark)
+  4. 软删本 schedule 下旧 cell / qc_material 子行
+  5. 对每个 cell_product_id:读主数据 → 写快照 → insert 子行
+  6. 对每个 qc_material + qty:读主数据 → 写快照 → insert 子行
+  7. db.commit()
+```
+
+**明确不做:**
+
+- 不调用 `cell_product.update` / 不改 `product_status`
+- 不调用 `occupancy_svc.remove_by_item`
+- 不调用 `qc_material_usage.deduct_*`
+- 不校验质检库存是否 ≥ 用量(用量仅作计划登记)
+
+### 4.2 编辑
+
+- 再次保存 = 全量替换子表(软删旧行 + 插入新行)。
+- 无库存回滚逻辑。
+
+### 4.3 业务校验(台账级)
+
+| 规则 | 说明 |
+|------|------|
+| 同计划细胞不重复 | 同一 `infusion_schedule_id` 下 `cell_product_id` 唯一 |
+| 同计划物料不重复 | 同一 schedule 下 `qc_material_id` 唯一 |
+| 跨计划可重复引用 | 不同输注计划可登记同一细胞/物料(台账互不排斥) |
+| 主数据存在性 | 保存时产品/物料须存在;已软删的主数据不允许新增引用 |
+| 细胞状态 | **不限制**可选状态;选择器展示状态供人判断,保存时不拦截 |
+
+### 4.4 细胞产品选择范围
+
+- 从细胞产品库分页/搜索勾选(复用 `TaskInputEnhancedTable` 选择弹窗模式)。
+- 列表展示编码、名称、类型、代次、储物格、**当前状态**(参考信息)。
+- **可选增强(v1.1)**:默认筛选与本计划出库生产工单产出相关的产品,仍允许从全库补选。
+
+## 5. API 设计
+
+前缀:`/web/admin/pilot-release-reviews`
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/{scheduleId}/outbound-materials` | 获取出库物料准备(主表 + 细胞行 + 质检行) |
+| PUT | `/{scheduleId}/outbound-materials` | 全量保存(仅写台账) |
+
+**PUT Body:**
+
+```json
+{
+  "paperAttachments": "url1,url2",
+  "remark": "",
+  "cells": [{ "cellProductId": "..." }],
+  "qcMaterials": [{ "materialId": "...", "qty": 1.5, "remark": "" }]
+}
+```
+
+- `cells` / `qcMaterials` 可为空数组。
+- `GET .../detail` 响应增加 `outboundMaterial` 域(查看弹窗只读)。
+
+## 6. 前端设计
+
+### 6.1 模式
+
+- **P22** 审核列表 + 子表编辑弹窗。
+- 弹窗标题建议:**「出库物料准备」**(与「真实出库」区分)。
+- 参考:
+  - 细胞选择:`TaskInputEnhancedTable.vue`
+  - 质检物料:`qc-task/index.vue` 物料使用区(**去掉库存不足拦截**)
+  - 纸质附件:`OutboundReleaseReviewDialog.vue` 卡片上传
+
+### 6.2 页面改动
+
+| 文件 | 改动 |
+|------|------|
+| `outbound-release-review/index.vue` | 操作列「出库物料」 |
+| `OutboundReleaseMaterialDialog.vue` | **新建** |
+| `OutboundReleaseViewDialog.vue` | Section「出库物料准备」只读 |
+| `api/outbound-release-review.ts` | fetch/save API |
+
+### 6.3 交互规则
+
+- 保存成功:「出库物料准备已保存」(**不提**库存更新)。
+- 质检物料表可展示「当前库存」列作参考,**不**因库存不足阻止保存。
+- 删除行:前端移除,点保存后生效。
+- 操作列宽度:约 260px(查看 / 审核 / 出库物料)。
+
+## 7. 后端模块划分
+
+| 文件 | 职责 |
+|------|------|
+| `app/crud/outbound_release_material.py` | 查询、全量 save、主数据快照 |
+| `app/schemas/outbound_release_material.py` | Pydantic |
+| `app/api/outbound_release_review.py` | 两个端点 |
+| `app/models/models.py` | 三个 ORM 模型 |
+
+**不修改:** `qc_material_usage.py`、`cell_product.py` 库存/状态逻辑。
+
+## 8. 错误处理
+
+| 场景 | HTTP | 说明 |
+|------|------|------|
+| 细胞/物料不存在 | 400 | ID 无效或已删除 |
+| 同计划重复勾选 | 400 | 同一产品/物料不可重复 |
+| qty ≤ 0 | 400 | 用量须大于 0 |
+| 输注计划不存在 | 404 | — |
+
+## 9. 测试要点
+
+1. 保存后:`biz_cell_product` 状态/库位**不变**;`biz_qc_material.stock_qty` **不变**。
+2. 编辑替换细胞/物料行:仅子表与快照更新。
+3. 两计划登记同一细胞:均允许保存(台账)。
+4. 查看弹窗正确展示快照与附件。
+5. 主数据后续变更:已保存台账仍显示保存时快照。
+
+## 10. OpenSpec 增量
+
+- `outbound-release-material-api`: GET/PUT outbound-materials(台账 CRUD)
+- `outbound-release-material-ui`: 出库物料准备弹窗 + 查看 Section
+
+## 11. 工作量估算
+
+| 项 | 人天 |
+|----|------|
+| SQL + Model + CRUD | 1.0 |
+| API + 详情聚合 | 0.5 |
+| 前端弹窗 + 查看 | 1.5 |
+| 联调 | 0.5 |
+| **合计** | **~3.5** |
+
+---
+
+## 附录:策略变更记录
+
+| 版本 | 策略 | 说明 |
+|------|------|------|
+| v0(讨论稿) | B 真实出库 | 保存时改状态 + 扣库存 + 回滚 |
+| **v1(当前)** | **台账模式** | 出库物料**准备**界面仅记录;与交付清单类似,不联动库存 |
+
+若后续需要在「审核通过」或独立「确认出库」节点做真实出库,应新建 spec,与本台账表通过 `infusion_schedule_id` 衔接,避免在本界面混入库存事务。

+ 2 - 0
openspec/changes/outbound-release-review/.openspec.yaml

@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-05-26

+ 54 - 0
openspec/changes/outbound-release-review/design.md

@@ -0,0 +1,54 @@
+## Context
+
+中试服务管理已有完整的出库生产+质检闭环:中试申请单 → 输注方案 → 输注计划 → 出库生产工单/任务 → 出库质检工单/任务。已存在 `biz_outbound_release_review` 表和基础 CRUD(4个端点、149行),但功能简陋:创建时未关联生产/质检工单,无聚合查询,无前端页面。
+
+入库放行审核(`storage-release-review`)已有完整实现(后端757行 CRUD + 3个 Vue 文件 + 独立 API 文件),可参照其模式设计出库放行审核。
+
+后端已有可复用函数 `build_outbound_pilot_context_by_schedule(db, schedule_id, tenant_no)`,一次性查出申请单/患者/输注方案/计划/产品信息。
+
+## Goals / Non-Goals
+
+**Goals:**
+- 实现完整的出库放行审核模块(后端聚合查询 + 前端列表/查看/审核界面)
+- 每条输注计划独立审核,查看其关联的生产工单/任务和质检工单/任务结果
+- 审核通过/不通过 + 备注 + 附件,仅记录结论不自动改状态
+- 参照入库放行审核模式,保持代码风格和架构一致性
+
+**Non-Goals:**
+- 不实现自动联动状态更新(审核通过后不自动改输注计划状态)
+- 不实现工作流引擎(审核为单步操作,非多级审批)
+- 不修改已有入库放行审核模块
+- 不修改生产/质检工单的已有逻辑
+
+## Decisions
+
+### D1: 复用已有 `biz_outbound_release_review` 表 vs 新建表
+**选择**: 复用已有表,补充缺失字段。
+**理由**: 表已存在且与 `infusion_schedule_id` 关联,只需补充 `review_attachments`、`review_by_name`、`create_by_name` 等快照字段。
+**备选**: 新建表——会增加迁移成本且无额外收益。
+
+### D2: 审核列表页数据来源
+**选择**: 基于 `biz_pilot_infusion_schedule` 构建,左连接 `biz_outbound_release_review` 获取审核状态。
+**理由**: 审核对象是输注计划,而非审核记录本身。输注计划创建后,只有生产和质检都完成才需要审核。列表页展示所有排程(附带审核状态),而非只展示已创建审核记录的排程。
+**备选**: 只展示已创建审核记录的排程——会导致"待审核"状态的排程无法展示。
+
+### D3: 前端页面放在 pilot-service 目录 vs quality-inspection 目录
+**选择**: 放在 `pilot-service/outbound-release-review/`。
+**理由**: 用户决策放在中试服务管理菜单下,与输注计划管理同级。
+**备选**: 放在 `quality-inspection/` 下——会与入库放行审核混在一起,不符合业务分组。
+
+### D4: 详情聚合查询方式
+**选择**: 后端一次性聚合查询,复用 `build_outbound_pilot_context_by_schedule()`。
+**理由**: 入库放行审核的详情也是后端聚合(6个信息域)。复用已有函数减少重复代码,且一次请求减少前端多次调用。
+**备选**: 前端多次调用已有 API 组装——增加请求次数,前端逻辑复杂。
+
+### D5: API 路径前缀
+**选择**: 保持已有的 `/web/admin/pilot-release-review` 路径前缀。
+**理由**: 后端 API 已注册在此路径下,保持一致性。
+
+## Risks / Trade-offs
+
+- **[风险] `biz_outbound_release_review` 表字段不完整** → 补充字段即可,已有数据不受影响(新字段为 NULL 安全)
+- **[风险] 聚合查询性能** → 按 `infusion_schedule_id` 查询(有索引),且单次排程关联数据量有限,性能可控
+- **[取舍] 审核不联动状态** → 简化实现,但用户需手动操作后续步骤。如需联动可在后续版本补充
+- **[取舍] 列表基于排程而非审核记录** → 列表数据更全面但查询稍复杂(需 LEFT JOIN)

+ 29 - 0
openspec/changes/outbound-release-review/proposal.md

@@ -0,0 +1,29 @@
+## Why
+
+中试服务管理流程已实现申请单、体检、输注计划、出库生产工单/任务、出库质检工单/任务的完整闭环,但缺少最终的**出库放行审核**环节——即对每条输注计划关联的生产和质检结果进行审核判定后才能放行出库。目前已有的 `biz_outbound_release_review` 表和基础 CRUD 仅是骨架,未实现聚合查询和前端审核界面。
+
+## What Changes
+
+- **后端重构**:重构 `outbound_release_review.py` CRUD(~149行→~500行),增加按 `infusion_schedule_id` 聚合查询申请单/患者/生产工单+任务/质检工单+任务详情
+- **后端 API 扩展**:从 4 个基础 CRUD 端点扩展为完整审核 API(分页列表、聚合详情、单条审核、批量审核)
+- **前端新增页面**:在中试服务管理模块下新建出库放行审核页面(列表页 + 查看弹窗 + 审核弹窗),参照已有入库放行审核模式
+- **路由注册**:`sqxp.ts` 新增路由,菜单放在中试服务管理下与输注计划管理同级
+- **状态标签注册**:`status-tag-registry.ts` 注册出库审核结论标签
+
+## Capabilities
+
+### New Capabilities
+- `outbound-release-review-api`: 后端出库放行审核 API——聚合查询(输注计划关联的生产工单/任务/质检工单/任务/结果)、审核操作(单条/批量)、分页列表
+- `outbound-release-review-ui`: 前端出库放行审核页面——列表页(筛选+批量操作)、查看弹窗(6域聚合展示)、审核弹窗(结论+备注+附件)
+
+### Modified Capabilities
+(无已有 spec 需要修改)
+
+## Impact
+
+- **后端文件**:`backend/app/api/outbound_release_review.py`、`backend/app/crud/outbound_release_review.py`、`backend/app/schemas/outbound_release_review.py`(可能新建)
+- **前端文件**:`web-admin/src/views/pilot-service/outbound-release-review/`(新建目录 + 3 个 Vue 文件)、`web-admin/src/api/admin.ts`(补充 API 函数)
+- **路由**:`web-admin/src/router/modules/sqxp.ts`
+- **状态标签**:`web-admin/src/utils/status-tag-registry.ts`
+- **数据库**:`biz_outbound_release_review` 表可能需要补充字段(如 `review_attachments`)
+- **无破坏性变更**:所有改动为新增功能,不影响已有模块

+ 71 - 0
openspec/changes/outbound-release-review/specs/outbound-release-review-api/spec.md

@@ -0,0 +1,71 @@
+## ADDED Requirements
+
+### Requirement: 出库放行审核列表 API
+系统 SHALL 提供 `GET /web/admin/pilot-release-reviews` 端点,返回分页的输注计划列表(附带出库放行审核状态)。列表 SHALL 基于 `biz_pilot_infusion_schedule` 构建,LEFT JOIN `biz_outbound_release_review` 获取审核结论。每个列表项 SHALL 包含:申请单号、患者姓名、输注计划编号、细胞类型、规格、输注时间、生产工单状态、质检工单状态、审核结论、审核时间。
+
+#### Scenario: 查询全部输注计划审核状态
+- **WHEN** 管理员请求 `GET /web/admin/pilot-release-reviews?page=1&page_size=20`
+- **THEN** 系统返回分页列表,包含所有未删除的输注计划及其对应的审核状态
+
+#### Scenario: 按审核结论筛选
+- **WHEN** 管理员请求 `GET /web/admin/pilot-release-reviews?review_conclusion=tg`
+- **THEN** 系统只返回审核结论为"通过"的输注计划
+
+#### Scenario: 按关键词搜索
+- **WHEN** 管理员请求 `GET /web/admin/pilot-release-reviews?keyword=ZSSQ202605260005`
+- **THEN** 系统返回匹配申请单号或患者姓名的输注计划
+
+### Requirement: 出库放行审核详情聚合 API
+系统 SHALL 提供 `GET /web/admin/pilot-release-reviews/{schedule_id}/detail` 端点,返回指定输注计划的完整审核上下文。详情 SHALL 聚合以下 6 个信息域:
+1. **申请单信息**:申请单号、申请单名称、渠道名称、状态
+2. **患者信息**:姓名、性别、年龄、联系电话
+3. **输注计划信息**:细胞类型、规格、输注时间、地点、方式、生产状态、质检状态、输注状态
+4. **生产工单+任务信息**:工单号、状态、每个任务的投入/产出/工艺步骤及结论
+5. **质检工单+任务信息**:工单号、状态、实验室/第三方项目、每个任务的检测结果及结论
+6. **审核记录信息**:审核结论、备注、附件、审核人、审核时间
+
+#### Scenario: 查看完整审核详情
+- **WHEN** 管理员请求 `GET /web/admin/pilot-release-reviews/{schedule_id}/detail`
+- **THEN** 系统返回包含上述 6 个信息域的完整聚合数据
+
+#### Scenario: 输注计划无生产工单
+- **WHEN** 输注计划尚未创建出库生产工单
+- **THEN** 生产工单+任务信息域返回空数组
+
+#### Scenario: 输注计划无质检工单
+- **WHEN** 输注计划尚未创建出库质检工单
+- **THEN** 质检工单+任务信息域返回空数组
+
+### Requirement: 出库放行审核操作 API
+系统 SHALL 提供 `PUT /web/admin/pilot-release-reviews/{schedule_id}/review` 端点用于单条审核,以及 `PUT /web/admin/pilot-release-reviews/batch-review` 用于批量审核。审核操作 SHALL 创建或更新 `biz_outbound_release_review` 记录,设置 `review_conclusion`(tg/btg)、`review_remark`、`review_attachments`、`reviewer_user_id`、`review_time`。审核时 SHALL 自动查找并填充 `production_work_order_id` 和 `qc_work_order_id`。
+
+#### Scenario: 单条审核通过
+- **WHEN** 管理员提交审核 `PUT /pilot-release-reviews/{schedule_id}/review` body=`{"review_conclusion":"tg","review_remark":"合格"}`
+- **THEN** 系统创建或更新审核记录,设置结论为"通过"、备注、审核人、审核时间
+
+#### Scenario: 单条审核不通过
+- **WHEN** 管理员提交审核结论为"btg"
+- **THEN** 系统记录"不通过"结论
+
+#### Scenario: 批量审核
+- **WHEN** 管理员提交 `PUT /pilot-release-reviews/batch-review` body=`{"schedule_ids":["id1","id2"],"review_conclusion":"tg","review_remark":"批量通过"}`
+- **THEN** 系统为所有指定排程创建/更新审核记录
+
+#### Scenario: 重复审核覆盖
+- **WHEN** 输注计划已有审核记录,再次提交审核
+- **THEN** 系统更新已有记录(覆盖审核结论、备注、附件、审核人、时间)
+
+### Requirement: 审核记录关联工单
+创建审核记录时,系统 SHALL 根据 `infusion_schedule_id` 自动查询关联的出库生产工单(`biz_production_work_order WHERE infusion_schedule_id=? AND work_order_type='outbound'`)和出库质检工单(`biz_qc_work_order WHERE infusion_schedule_id=? AND qc_biz_type='outbound'`),并将工单 ID 填充到 `production_work_order_id` 和 `qc_work_order_id` 字段。
+
+#### Scenario: 自动关联生产工单
+- **WHEN** 为某输注计划创建审核记录,且该计划存在 `work_order_type='outbound'` 的生产工单
+- **THEN** 审核记录的 `production_work_order_id` 自动填充为该生产工单的 ID
+
+#### Scenario: 自动关联质检工单
+- **WHEN** 为某输注计划创建审核记录,且该计划存在 `qc_biz_type='outbound'` 的质检工单
+- **THEN** 审核记录的 `qc_work_order_id` 自动填充为该质检工单的 ID
+
+#### Scenario: 无关联工单
+- **WHEN** 输注计划尚无出库生产或质检工单
+- **THEN** 对应工单 ID 字段为空字符串

+ 68 - 0
openspec/changes/outbound-release-review/specs/outbound-release-review-ui/spec.md

@@ -0,0 +1,68 @@
+## ADDED Requirements
+
+### Requirement: 出库放行审核列表页
+系统 SHALL 在中试服务管理菜单下新增"出库放行审核"页面,路径为 `pilot-service/outbound-release-review`。列表页 SHALL 展示所有输注计划的审核状态,参照 `storage-release-review/index.vue` 模式。列表 SHALL 支持筛选:关键词搜索、审核结论、生产状态、质检状态。列表操作栏 SHALL 包含:查看、审核(单条)。支持批量选择和批量审核。
+
+#### Scenario: 列表页加载
+- **WHEN** 用户进入出库放行审核页面
+- **THEN** 系统展示分页列表,每行展示:申请单号、患者姓名、细胞类型、规格、输注时间、生产状态(el-tag)、质检状态(el-tag)、审核结论(el-tag)、审核时间
+
+#### Scenario: 筛选审核状态
+- **WHEN** 用户选择审核结论筛选为"待审核"
+- **THEN** 列表只展示未审核的输注计划
+
+#### Scenario: 批量审核操作
+- **WHEN** 用户勾选多条记录后点击"批量审核"按钮
+- **THEN** 弹出批量审核对话框,提交后所有选中记录更新审核结论
+
+### Requirement: 出库放行审核查看弹窗
+系统 SHALL 提供查看弹窗(`OutboundReleaseViewDialog.vue`),使用 `SystemViewDialog` 组件,展示 6 个信息域的聚合数据:
+1. 申请单基础信息(申请单号、名称、渠道、状态)
+2. 患者信息(姓名、性别、年龄、电话)
+3. 输注计划信息(细胞类型、规格、时间、地点、方式、各状态)
+4. 生产工单+任务信息(工单号、状态、任务列表含结论)
+5. 质检工单+任务信息(工单号、状态、任务列表含结论和检测结果)
+6. 审核记录信息(结论、备注、附件、审核人、时间)
+
+#### Scenario: 查看完整审核详情
+- **WHEN** 用户点击某条记录的"查看"按钮
+- **THEN** 弹窗展示上述 6 个 section 的完整聚合数据
+
+#### Scenario: 查看审核附件
+- **WHEN** 审核记录包含附件
+- **THEN** 附件区域展示可点击下载的附件列表
+
+### Requirement: 出库放行审核操作弹窗
+系统 SHALL 提供审核弹窗(`OutboundReleaseReviewDialog.vue`),参照 `StorageReleaseReviewDialog.vue` 模式。弹窗 SHALL 包含:审核结论(单选:通过/不通过)、审核备注(textarea)、审核附件(卡片化上传,参照附件上传卡片化布局规范)。支持单条模式和批量模式。
+
+#### Scenario: 单条审核
+- **WHEN** 用户点击某条记录的"审核"按钮
+- **THEN** 弹出审核弹窗,用户选择结论、填写备注、上传附件后提交
+
+#### Scenario: 批量审核
+- **WHEN** 用户勾选多条记录后点击"批量审核"
+- **THEN** 弹出批量审核弹窗(不显示具体记录详情),填写统一结论/备注/附件后提交
+
+#### Scenario: 审核通过提交
+- **WHEN** 用户选择"通过"并提交
+- **THEN** 系统更新审核记录,列表刷新后该记录审核结论显示为"通过"(success el-tag)
+
+#### Scenario: 审核不通过提交
+- **WHEN** 用户选择"不通过"并提交
+- **THEN** 系统更新审核记录,列表刷新后该记录审核结论显示为"不通过"(danger el-tag)
+
+### Requirement: 状态标签注册
+`status-tag-registry.ts` SHALL 注册出库放行审核相关的状态标签映射:
+- 审核结论:`tg`=通过(success)、`btg`=不通过(danger)、null=待审核(info)
+- 按钮 SHALL 携带 `el-icon` 图标(参照按钮须携带图标规范)
+
+#### Scenario: 审核结论标签展示
+- **WHEN** 列表中某条记录审核结论为"tg"
+- **THEN** 审核结论列显示绿色 `el-tag`(type="success",text="通过")
+
+### Requirement: 路由注册
+`sqxp.ts` SHALL 在中试服务管理下新增"出库放行审核"路由,路径 `outbound-release-review`,组件指向 `pilot-service/outbound-release-review/index.vue`。
+
+#### Scenario: 菜单导航
+- **WHEN** 用户点击中试服务管理下的"出库放行审核"菜单
+- **THEN** 系统跳转到出库放行审核列表页

+ 43 - 0
openspec/changes/outbound-release-review/tasks.md

@@ -0,0 +1,43 @@
+## 1. 数据库变更
+
+- [ ] 1.1 检查并补充 `biz_outbound_release_review` 表字段(如缺少 `review_attachments`、`review_by_name`、`create_by_name` 等),生成增量 SQL 到 `docs/sql/`
+- [ ] 1.2 更新 `backend/app/models/models.py` 中 `BizOutboundReleaseReview` 模型,与表结构对齐
+
+## 2. 后端 Schema 与 CRUD
+
+- [ ] 2.1 新建 `backend/app/schemas/outbound_release_review.py`,定义请求/响应 Pydantic 模型(列表项、详情聚合、审核提交、批量审核),遵循项目 Schema 命名规范
+- [ ] 2.2 重构 `backend/app/crud/outbound_release_review.py`:实现分页列表查询(基于 infusion_schedule LEFT JOIN 审核记录 + 生产工单 + 质检工单状态)
+- [ ] 2.3 实现聚合详情查询 `get_review_detail(schedule_id)`:复用 `build_outbound_pilot_context_by_schedule()` + 查生产工单任务 + 查质检工单任务结果,返回 6 域聚合数据
+- [ ] 2.4 实现审核操作 `review_schedule(schedule_id, conclusion, remark, attachments, user)`:创建或更新审核记录,自动填充 production_work_order_id 和 qc_work_order_id
+- [ ] 2.5 实现批量审核 `batch_review(schedule_ids, conclusion, remark, attachments, user)`
+
+## 3. 后端 API
+
+- [ ] 3.1 重构 `backend/app/api/outbound_release_review.py`:新增 `GET /pilot-release-reviews` 分页列表端点
+- [ ] 3.2 新增 `GET /pilot-release-reviews/{schedule_id}/detail` 详情聚合端点
+- [ ] 3.3 新增 `PUT /pilot-release-reviews/{schedule_id}/review` 单条审核端点
+- [ ] 3.4 新增 `PUT /pilot-release-reviews/batch-review` 批量审核端点
+- [ ] 3.5 确认路由已在 `app/api/__init__.py` 和 `app/main.py` 正确注册
+
+## 4. 前端 API 层
+
+- [ ] 4.1 在 `web-admin/src/api/admin.ts` 中补充/重构出库放行审核相关 API 函数(列表、详情、单条审核、批量审核),定义 TypeScript 类型
+
+## 5. 前端列表页
+
+- [ ] 5.1 新建 `web-admin/src/views/pilot-service/outbound-release-review/index.vue` 列表页,参照 `storage-release-review/index.vue` 模式:搜索栏(关键词、审核结论、生产状态、质检状态)+ 表格(申请单号、患者、细胞类型、规格、输注时间、生产/质检/审核状态列)+ 操作栏(查看、审核)
+- [ ] 5.2 实现批量选择和批量审核按钮交互
+
+## 6. 前端查看弹窗
+
+- [ ] 6.1 新建 `OutboundReleaseViewDialog.vue`,使用 `SystemViewDialog` 组件,展示 6 个 section(申请单信息、患者信息、输注计划、生产工单+任务、质检工单+任务、审核记录)
+- [ ] 6.2 生产/质检信息域复用 `SampleQcInfoCards` / `SampleProductionInfoCards` 组件(如有)或参照入库放行审核的 `StorageReleaseViewDialog.vue` 模式自定义展示
+
+## 7. 前端审核弹窗
+
+- [ ] 7.1 新建 `OutboundReleaseReviewDialog.vue`,参照 `StorageReleaseReviewDialog.vue`:审核结论(el-radio-group)、备注(el-input textarea)、附件上传(卡片化布局),支持单条和批量模式
+
+## 8. 路由与状态标签
+
+- [ ] 8.1 在 `web-admin/src/router/modules/sqxp.ts` 中新增出库放行审核路由(路径 `outbound-release-review`,放在中试服务管理下)
+- [ ] 8.2 在 `web-admin/src/utils/status-tag-registry.ts` 中注册出库审核结论标签映射(tg=通过/success、btg=不通过/danger、null=待审核/info)

ファイルの差分が大きいため隠しています
+ 419 - 554
web-admin/graphify-out/.graphify_analysis.json


ファイルの差分が大きいため隠しています
+ 474 - 143
web-admin/graphify-out/.graphify_extract.json


+ 117 - 99
web-admin/graphify-out/GRAPH_REPORT.md

@@ -1,11 +1,11 @@
-# Graph Report - src  (2026-05-26)
+# Graph Report - src  (2026-05-27)
 
 ## Corpus Check
-- Large corpus: 632 files · ~390,279 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
+- Large corpus: 641 files · ~402,707 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
 
 ## Summary
-- 1816 nodes · 2360 edges · 37 communities detected
-- Extraction: 77% EXTRACTED · 23% INFERRED · 0% AMBIGUOUS · INFERRED: 538 edges (avg confidence: 0.8)
+- 1844 nodes · 2394 edges · 41 communities detected
+- Extraction: 77% EXTRACTED · 23% INFERRED · 0% AMBIGUOUS · INFERRED: 550 edges (avg confidence: 0.8)
 - Token cost: 0 input · 0 output
 
 ## Community Hubs (Navigation)
@@ -33,39 +33,43 @@
 - [[_COMMUNITY_Community 21|Community 21]]
 - [[_COMMUNITY_Community 22|Community 22]]
 - [[_COMMUNITY_Community 23|Community 23]]
+- [[_COMMUNITY_Community 24|Community 24]]
 - [[_COMMUNITY_Community 25|Community 25]]
 - [[_COMMUNITY_Community 26|Community 26]]
 - [[_COMMUNITY_Community 27|Community 27]]
 - [[_COMMUNITY_Community 29|Community 29]]
-- [[_COMMUNITY_Community 30|Community 30]]
 - [[_COMMUNITY_Community 31|Community 31]]
 - [[_COMMUNITY_Community 32|Community 32]]
+- [[_COMMUNITY_Community 33|Community 33]]
+- [[_COMMUNITY_Community 34|Community 34]]
+- [[_COMMUNITY_Community 35|Community 35]]
 - [[_COMMUNITY_Community 36|Community 36]]
-- [[_COMMUNITY_Community 37|Community 37]]
-- [[_COMMUNITY_Community 38|Community 38]]
 - [[_COMMUNITY_Community 39|Community 39]]
 - [[_COMMUNITY_Community 41|Community 41]]
+- [[_COMMUNITY_Community 42|Community 42]]
 - [[_COMMUNITY_Community 43|Community 43]]
+- [[_COMMUNITY_Community 45|Community 45]]
+- [[_COMMUNITY_Community 47|Community 47]]
 
 ## God Nodes (most connected - your core abstractions)
-1. `get()` - 193 edges
-2. `push()` - 33 edges
+1. `get()` - 196 edges
+2. `push()` - 34 edges
 3. `WebSocketClient` - 26 edges
-4. `withFullscreenLoading()` - 16 edges
-5. `MenuProcessor` - 16 edges
-6. `VersionManager` - 15 edges
-7. `emit()` - 15 edges
+4. `withFullscreenLoading()` - 17 edges
+5. `emit()` - 16 edges
+6. `MenuProcessor` - 16 edges
+7. `VersionManager` - 15 edges
 8. `handleDynamicRoutes()` - 15 edges
 9. `TableCache` - 14 edges
 10. `buildOutboundPilotContextSections()` - 12 edges
 
 ## Surprising Connections (you probably didn't know these)
-- `handleBatchDelete()` --calls--> `withFullscreenLoading()`  [INFERRED]
-  views/system/site/index.vue → utils/fullscreen-loading.ts
-- `handleBatchDelete()` --calls--> `withFullscreenLoading()`  [INFERRED]
-  views/basic-info/customer/index.vue → utils/fullscreen-loading.ts
 - `handleBatchDelete()` --calls--> `withFullscreenLoading()`  [INFERRED]
   views/basic-info/collection-region/index.vue → utils/fullscreen-loading.ts
+- `handleAttachmentDownload()` --calls--> `downloadAttachmentFile()`  [INFERRED]
+  views/cell-storage/refund-confirm/index.vue → utils/download-attachment.ts
+- `handleAttachmentDownload()` --calls--> `downloadAttachmentFile()`  [INFERRED]
+  views/cell-storage/payment-confirm/index.vue → utils/download-attachment.ts
 - `subscribeTokenRefresh()` --calls--> `push()`  [INFERRED]
   utils/http/index.ts → views/operations/storage-facility/index.vue
 - `handleUnauthorizedResponse()` --calls--> `fetchRefreshToken()`  [INFERRED]
@@ -75,190 +79,204 @@
 
 ### Community 0 - "Community 0"
 Cohesion: 0.01
-Nodes (143): fetchAccessLogList(), fetchActiveRegions(), fetchCategoryList(), fetchCategoryParameters(), fetchCellProductPriceList(), fetchCellProductSpecList(), fetchCellProductTypeList(), fetchCellSampleTypeList() (+135 more)
+Nodes (102): fetchAccessLogList(), fetchActiveRegions(), fetchCategoryList(), fetchCategoryParameters(), fetchCellProductPriceList(), fetchCellProductSpecList(), fetchCellProductTypeList(), fetchCellSampleTypeList() (+94 more)
 
 ### Community 1 - "Community 1"
-Cohesion: 0.01
-Nodes (10): fetchDeleteProjectSystemItem(), fetchDeptTree(), fetchGetRoleList(), fetchNotDeletedRegions(), fetchPostBoundUserIds(), fetchRegionList(), loadRoleList(), handleView() (+2 more)
+Cohesion: 0.02
+Nodes (99): batchSavePilotCheckup(), deletePilotCheckupActualItem(), fetchCheckupInstitutionList(), fetchCheckupInstitutionPackageDetail(), fetchCheckupInstitutionPackages(), fetchCheckupInstitutionProjects(), fetchPilotCheckupDetail(), markPilotCheckupChecked() (+91 more)
 
 ### Community 2 - "Community 2"
 Cohesion: 0.02
-Nodes (71): fetchBatchGenerateProtocolNos(), fetchBatchGenerateSampleCodes(), fetchBatchIssueProtocolNos(), fetchBatchIssueSampleCodes(), fetchCollectionProtocolNoList(), fetchCreateCollectionProtocolNo(), fetchCreateMenu(), fetchCreateSampleCode() (+63 more)
+Nodes (66): fetchBatchGenerateProtocolNos(), fetchBatchGenerateSampleCodes(), fetchBatchIssueProtocolNos(), fetchBatchIssueSampleCodes(), fetchCollectionProtocolNoList(), fetchCreateCollectionProtocolNo(), fetchCreateSampleCode(), fetchDeleteCollectionProtocolNo() (+58 more)
 
 ### Community 3 - "Community 3"
 Cohesion: 0.03
-Nodes (82): batchSavePilotCheckup(), deletePilotCheckupActualItem(), fetchCheckupInstitutionList(), fetchCheckupInstitutionPackageDetail(), fetchCheckupInstitutionPackages(), fetchCheckupInstitutionProjects(), fetchPilotCheckupActualItems(), fetchPilotCheckupDetail() (+74 more)
+Nodes (40): fetchGetMenuList(), fetchGetMenuCodes(), fetchGetUserInfo(), fetchLogin(), fetchRefreshToken(), clickTab(), handleTouchEnd(), setTransition() (+32 more)
 
 ### Community 4 - "Community 4"
 Cohesion: 0.03
-Nodes (40): fetchGetMenuList(), fetchGetMenuCodes(), fetchGetUserInfo(), fetchLogin(), fetchRefreshToken(), clickTab(), handleTouchEnd(), setTransition() (+32 more)
+Nodes (22): fetchAllConsumablePacks(), fetchAllConsumables(), fetchAllConsumableStockLogs(), fetchAllOutputTypes(), fetchAllProcessCategories(), fetchAllProcesses(), fetchAllReagents(), fetchAllReagentStockLogs() (+14 more)
 
 ### Community 5 - "Community 5"
-Cohesion: 0.05
-Nodes (34): fetchServiceOrderTxnList(), fetchCollectionProtocolAuditList(), fetchCollectionProtocolList(), fetchPaymentDetailList(), fetchReceiptConfirmedList(), fetchReceiptConfirmList(), fetchReceiptConfirmSubmit(), fetchRefundConfirmedList() (+26 more)
+Cohesion: 0.03
+Nodes (22): fetchLeaveDetail(), fetchLeaveList(), fetchMaterialList(), fetchMaterialOptions(), fetchMaterialStockLogList(), fetchProductionWorkOrderDetail(), fetchProductionWorkOrderList(), fetchQcMaterialList() (+14 more)
 
 ### Community 6 - "Community 6"
-Cohesion: 0.06
-Nodes (11): fetchGetLatestUpgradeLog(), getDefaultLanguage(), checkStorageCompatibility(), StorageConfig, getSystemStorage(), getSysVersion(), StorageKeyManager, StorageCompatibilityManager (+3 more)
+Cohesion: 0.05
+Nodes (36): fetchServiceOrderTxnList(), fetchCollectionProtocolAuditList(), fetchCollectionProtocolList(), fetchPaymentDetailList(), fetchReceiptConfirmedList(), fetchReceiptConfirmList(), fetchReceiptConfirmSubmit(), fetchRefundConfirmedList() (+28 more)
 
 ### Community 7 - "Community 7"
-Cohesion: 0.06
-Nodes (39): completePilotApplication(), deletePilotApplication(), fetchFullmoonTodoList(), fetchPilotApplicationList(), fetchUpdateFullmoonTodoInsurance(), startPilotApplication(), voidPilotApplication(), fetchCollectionProtocolDetail() (+31 more)
+Cohesion: 0.07
+Nodes (8): updateInfusionScheduleStatus(), handleSubmit(), handleReset(), handleSearch(), WebSocketClient, TableCache, emit(), handleSubmit()
 
 ### Community 8 - "Community 8"
-Cohesion: 0.06
-Nodes (36): disableDevTools(), handleLock(), handleUnlock(), isMobile(), toLogin(), base64ToBytes(), bytesToBase64(), decryptJson() (+28 more)
+Cohesion: 0.04
+Nodes (28): fetchAttachmentDownloadUrl(), fetchOrderAdjusts(), fetchOrderDetail(), fetchOrderItems(), fetchOrderList(), fetchPriceVersionDetail(), fetchSystemConfig(), handleDownloadAttachment() (+20 more)
 
 ### Community 9 - "Community 9"
-Cohesion: 0.06
-Nodes (38): fetchCreateSite(), fetchCreateUser(), fetchDeleteSite(), fetchDeleteUser(), fetchDictDataOptions(), fetchResetPassword(), fetchRoleOptions(), fetchSiteList() (+30 more)
+Cohesion: 0.07
+Nodes (21): auditorDisplay(), dash(), expressName(), formatYm(), formatYmd(), sampleTypeName(), siteName(), unitName() (+13 more)
 
 ### Community 10 - "Community 10"
-Cohesion: 0.06
-Nodes (26): for(), set(), walk(), annotateForkEdges(), chainApiToElements(), nextAncestorLink(), if(), ensureCategoryPathDataLoaded() (+18 more)
+Cohesion: 0.08
+Nodes (32): toLogin(), base64ToBytes(), bytesToBase64(), decryptJson(), deriveKey(), encryptJson(), fromBase64Url(), hexToBytes() (+24 more)
 
 ### Community 11 - "Community 11"
-Cohesion: 0.1
-Nodes (2): WebSocketClient, TableCache
+Cohesion: 0.07
+Nodes (22): handleBlur(), handleFocus(), handleInput(), fetchEthnicList(), addSample(), applyDonationBundleToForm(), confirmBatchTransportApply(), createEmptyTransport() (+14 more)
 
 ### Community 12 - "Community 12"
-Cohesion: 0.07
-Nodes (21): auditorDisplay(), dash(), expressName(), formatYm(), formatYmd(), sampleTypeName(), siteName(), unitName() (+13 more)
+Cohesion: 0.09
+Nodes (8): getDefaultLanguage(), checkStorageCompatibility(), StorageConfig, getSystemStorage(), getSysVersion(), StorageKeyManager, StorageCompatibilityManager, validateStorageData()
 
 ### Community 13 - "Community 13"
 Cohesion: 0.07
-Nodes (22): handleBlur(), handleFocus(), handleInput(), fetchEthnicList(), addSample(), applyDonationBundleToForm(), confirmBatchTransportApply(), createEmptyTransport() (+14 more)
+Nodes (17): fetchCreateMenu(), fetchDeleteMenu(), fetchMenuTree(), fetchUpdateMenu(), collectIds(), postorder(), preorder(), applyTreeExpand() (+9 more)
 
 ### Community 14 - "Community 14"
-Cohesion: 0.07
-Nodes (20): fetchAttachmentDownloadUrl(), handleDownloadAttachment(), handleAttachmentDownload(), handleReceptionAttachmentDownload(), attachmentFullUrl(), handleAttachmentDownload(), handleAttachmentPreview(), formatDetectMethods() (+12 more)
+Cohesion: 0.08
+Nodes (11): maskIdCard(), buildOutboundPilotContextSections(), applicationStatusLabel(), applicationStatusTagType(), infusionMethodLabel(), infusionStatusLabel(), infusionStatusTagType(), qcStatusLabel() (+3 more)
 
 ### Community 15 - "Community 15"
 Cohesion: 0.09
-Nodes (11): maskIdCard(), buildOutboundPilotContextSections(), applicationStatusLabel(), applicationStatusTagType(), infusionMethodLabel(), infusionStatusLabel(), infusionStatusTagType(), qcStatusLabel() (+3 more)
+Nodes (9): fetchGetAllUpgradeLogs(), fetchGetChinaMapData(), fetchGetComments(), fetchGetCurrentVersion(), fetchGetLatestUpgradeLog(), fetchGetSystemConfig(), fetchGetUpgradeLogs(), systemUpgrade() (+1 more)
 
 ### Community 16 - "Community 16"
+Cohesion: 0.09
+Nodes (20): fetchCreateSite(), fetchDeleteSite(), fetchSiteList(), fetchUpdateSite(), fetchUserList(), formatHandoverUserLabel(), formatNowDatetime(), remoteSearchHandoverUsers() (+12 more)
+
+### Community 17 - "Community 17"
+Cohesion: 0.11
+Nodes (21): fetchFullmoonTodoList(), fetchUpdateFullmoonTodoInsurance(), fetchCollectionProtocolDetail(), buildInsuranceAttachmentsValue(), buildInsurancePayload(), formatYmdFromAny(), fullFileUrl(), handleBatchEdit() (+13 more)
+
+### Community 18 - "Community 18"
 Cohesion: 0.1
 Nodes (19): fetchCollectionRegionList(), fetchCreateCollectionRegion(), fetchCreateHospital(), fetchDeleteCollectionRegion(), fetchDeleteHospital(), fetchHospitalList(), fetchUpdateCollectionRegion(), fetchUpdateHospital() (+11 more)
 
-### Community 17 - "Community 17"
+### Community 19 - "Community 19"
+Cohesion: 0.13
+Nodes (18): completePilotApplication(), deletePilotApplication(), fetchPilotApplicationList(), startPilotApplication(), voidPilotApplication(), clearTableSelection(), handleBatchComplete(), handleBatchStart() (+10 more)
+
+### Community 20 - "Community 20"
 Cohesion: 0.12
 Nodes (15): addCopyButton(), highlightCode(), insertLineNumbers(), isBlockProcessed(), markBlockAsProcessed(), processBlock(), setupHighlightDirective(), setupRippleDirective() (+7 more)
 
-### Community 18 - "Community 18"
+### Community 21 - "Community 21"
 Cohesion: 0.1
 Nodes (6): fetchProductionTaskDetail(), fetchProductionTaskList(), fetchUpdateProductionTask(), handleClose(), handleSubmit(), loadTaskDetail()
 
-### Community 19 - "Community 19"
+### Community 22 - "Community 22"
+Cohesion: 0.14
+Nodes (17): fetchCreateUser(), fetchDeleteUser(), fetchResetPassword(), fetchUpdateUser(), fetchUserDetail(), formatUserDeptDisplay(), handleAdd(), handleDelete() (+9 more)
+
+### Community 23 - "Community 23"
 Cohesion: 0.16
 Nodes (10): useTable(), useTableImpl(), getColumnChecks(), useTableColumns(), createErrorHandler(), createSmartDebounce(), defaultResponseAdapter(), extractPagination() (+2 more)
 
-### Community 20 - "Community 20"
-Cohesion: 0.15
-Nodes (12): fetchCreateCustomer(), fetchCustomerList(), fetchDeleteCustomer(), fetchUpdateCustomer(), handleAdd(), handleBatchDelete(), handleDelete(), handleReset() (+4 more)
-
-### Community 21 - "Community 21"
+### Community 24 - "Community 24"
 Cohesion: 0.2
 Nodes (2): ComponentLoader, RouteTransformer
 
-### Community 22 - "Community 22"
+### Community 25 - "Community 25"
 Cohesion: 0.27
 Nodes (14): useChart(), useChartComponent(), useChartOps(), colourBlend(), getCssVar(), getDarkColor(), getLightColor(), handleElementThemeColor() (+6 more)
 
-### Community 23 - "Community 23"
+### Community 26 - "Community 26"
+Cohesion: 0.17
+Nodes (11): fetchCreateCustomer(), fetchCustomerList(), fetchDeleteCustomer(), fetchUpdateCustomer(), handleAdd(), handleDelete(), handleReset(), handleSearch() (+3 more)
+
+### Community 27 - "Community 27"
 Cohesion: 0.14
 Nodes (8): useSettingsHandlers(), useSettingsPanel(), useSettingsState(), useCeremony(), useTheme(), themeAnimation(), toggleTheme(), toggleTransition()
 
-### Community 25 - "Community 25"
+### Community 29 - "Community 29"
 Cohesion: 0.17
 Nodes (3): fetchResearchDonationAuditList(), fetchResearchDonationDetail(), fetchResearchDonationList()
 
-### Community 26 - "Community 26"
-Cohesion: 0.2
-Nodes (2): fetchQcMaterialList(), fetchQcMaterialOptions()
-
-### Community 27 - "Community 27"
-Cohesion: 0.28
-Nodes (1): FireworkSystem
-
-### Community 29 - "Community 29"
+### Community 31 - "Community 31"
 Cohesion: 0.39
 Nodes (6): isIgnorableScriptError(), normalizeErrorMessage(), registerPromiseErrorHandler(), registerResourceErrorHandler(), scriptErrorHandler(), setupErrorHandle()
 
-### Community 30 - "Community 30"
+### Community 32 - "Community 32"
 Cohesion: 0.36
 Nodes (1): TableHeightCalculator
 
-### Community 31 - "Community 31"
+### Community 33 - "Community 33"
+Cohesion: 0.25
+Nodes (2): fetchQcTaskDetail(), fetchQcTaskList()
+
+### Community 34 - "Community 34"
+Cohesion: 0.32
+Nodes (3): annotateForkEdges(), chainApiToElements(), nextAncestorLink()
+
+### Community 35 - "Community 35"
 Cohesion: 0.33
 Nodes (2): logApiCryptoBootInfo(), maskSecret()
 
-### Community 32 - "Community 32"
+### Community 36 - "Community 36"
 Cohesion: 0.47
 Nodes (3): isPaymentDueWithinWarnWindow(), parsePaymentDueYmd(), startOfLocalDay()
 
-### Community 36 - "Community 36"
-Cohesion: 0.5
-Nodes (3): reload(), handleMenuJump(), openExternalLink()
+### Community 39 - "Community 39"
+Cohesion: 0.33
+Nodes (2): fetchQcLabTestItemList(), fetchQcLabTestItemsActive()
 
-### Community 37 - "Community 37"
+### Community 41 - "Community 41"
 Cohesion: 0.5
 Nodes (2): focusInput(), handleKeydown()
 
-### Community 38 - "Community 38"
+### Community 42 - "Community 42"
 Cohesion: 0.67
 Nodes (2): if(), logSidebarMenu()
 
-### Community 39 - "Community 39"
+### Community 43 - "Community 43"
 Cohesion: 0.5
-Nodes (1): fetchSampleSmsNotificationList()
+Nodes (1): fetchStorageEntrySmsList()
 
-### Community 41 - "Community 41"
+### Community 45 - "Community 45"
 Cohesion: 1.0
 Nodes (2): getSettingDefaults(), resetToDefaults()
 
-### Community 43 - "Community 43"
+### Community 47 - "Community 47"
 Cohesion: 1.0
 Nodes (2): defaultAvatarPathByRole(), resolveUserAvatarUrl()
 
 ## Knowledge Gaps
-- **Thin community `Community 11`** (42 nodes): `WebSocketClient`, `.calculateReconnectDelay()`, `.clearAllTimers()`, `.clearConnectionTimers()`, `.clearTimer()`, `.close()`, `.closeCurrentSocketForReconnect()`, `.connect()`, `.connectionStatusText()`, `.constructor()`, `.destroyInstance()`, `.flushMessageQueue()`, `.getInstance()`, `.getReadyStateText()`, `.handleClose()`, `.handleConnectionTimeout()`, `.handleError()`, `.handleMessage()`, `.handleOpen()`, `.init()`, `.isWebSocketConnected()`, `.reconnect()`, `.resetHeartbeat()`, `.send()`, `.startHeartbeat()`, `.startPing()`, `TableCache`, `.cleanupExpired()`, `.clear()`, `.clearByTags()`, `.clearCurrentSearch()`, `.clearPagination()`, `.constructor()`, `.evictLRU()`, `.generateKey()`, `.generateTags()`, `.get()`, `.getStats()`, `.log()`, `.set()`, `index.ts`, `tableCache.ts`
-  Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 21`** (17 nodes): `ComponentLoader`, `.constructor()`, `.createEmptyComponent()`, `.createErrorComponent()`, `.load()`, `.loadIframe()`, `.loadLayout()`, `.load()`, `RouteTransformer`, `.extractFirstSegment()`, `.handleFirstLevelRoute()`, `.handleIframeRoute()`, `.handleNormalRoute()`, `.isFirstLevelRoute()`, `.transform()`, `ComponentLoader.ts`, `RouteTransformer.ts`
+- **Thin community `Community 24`** (17 nodes): `ComponentLoader`, `.constructor()`, `.createEmptyComponent()`, `.createErrorComponent()`, `.load()`, `.loadIframe()`, `.loadLayout()`, `.load()`, `RouteTransformer`, `.extractFirstSegment()`, `.handleFirstLevelRoute()`, `.handleIframeRoute()`, `.handleNormalRoute()`, `.isFirstLevelRoute()`, `.transform()`, `ComponentLoader.ts`, `RouteTransformer.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 26`** (10 nodes): `fetchBatchStockInQcMaterial()`, `fetchBatchStockOutQcMaterial()`, `fetchCreateQcMaterial()`, `fetchDeleteQcMaterial()`, `fetchQcMaterialList()`, `fetchQcMaterialOptions()`, `fetchStockInQcMaterial()`, `fetchStockOutQcMaterial()`, `fetchUpdateQcMaterial()`, `qc-material.ts`
+- **Thin community `Community 32`** (8 nodes): `TableHeightCalculator`, `.calculate()`, `.calculateOffset()`, `.calculatePaginationOffset()`, `.constructor()`, `.getHeaderHeight()`, `useTableHeight()`, `useTableHeight.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 27`** (9 nodes): `FireworkSystem`, `.constructor()`, `.createFirework()`, `.createParticle()`, `.getAvailableParticle()`, `.initializePool()`, `.preloadAllImages()`, `.preloadImage()`, `index.vue`
+- **Thin community `Community 33`** (8 nodes): `fetchCompleteQcTask()`, `fetchCreateQcTask()`, `fetchDeleteQcTask()`, `fetchQcTaskDetail()`, `fetchQcTaskList()`, `fetchStartQcTask()`, `fetchUpdateQcTaskAttachments()`, `qc-task.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 30`** (8 nodes): `TableHeightCalculator`, `.calculate()`, `.calculateOffset()`, `.calculatePaginationOffset()`, `.constructor()`, `.getHeaderHeight()`, `useTableHeight()`, `useTableHeight.ts`
+- **Thin community `Community 35`** (7 nodes): `App.vue`, `main.ts`, `refreshButtonAutoIcons()`, `refreshPanelTitleIcons()`, `logApiCryptoBootInfo()`, `maskSecret()`, `console.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 31`** (7 nodes): `App.vue`, `main.ts`, `refreshButtonAutoIcons()`, `refreshPanelTitleIcons()`, `logApiCryptoBootInfo()`, `maskSecret()`, `console.ts`
+- **Thin community `Community 39`** (6 nodes): `fetchCreateQcLabTestItem()`, `fetchDeleteQcLabTestItem()`, `fetchQcLabTestItemList()`, `fetchQcLabTestItemsActive()`, `fetchUpdateQcLabTestItem()`, `qc-lab-test-item.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 37`** (5 nodes): `focusInput()`, `handleKeydown()`, `if()`, `search()`, `index.vue`
+- **Thin community `Community 41`** (5 nodes): `focusInput()`, `handleKeydown()`, `if()`, `search()`, `index.vue`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 38`** (4 nodes): `if()`, `logSidebarMenu()`, `index.vue`, `SidebarSubmenu.vue`
+- **Thin community `Community 42`** (4 nodes): `if()`, `logSidebarMenu()`, `index.vue`, `SidebarSubmenu.vue`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 39`** (4 nodes): `fetchGenerateSms()`, `fetchSampleSmsNotificationList()`, `fetchToggleSmsNotified()`, `sample-sms-notification.ts`
+- **Thin community `Community 43`** (4 nodes): `fetchGenerateStorageEntrySms()`, `fetchStorageEntrySmsList()`, `fetchToggleStorageEntrySmsNotified()`, `storage-entry-sms-notification.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 41`** (3 nodes): `getSettingDefaults()`, `resetToDefaults()`, `setting.ts`
+- **Thin community `Community 45`** (3 nodes): `getSettingDefaults()`, `resetToDefaults()`, `setting.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 43`** (3 nodes): `defaultAvatarPathByRole()`, `resolveUserAvatarUrl()`, `user-avatar.ts`
+- **Thin community `Community 47`** (3 nodes): `defaultAvatarPathByRole()`, `resolveUserAvatarUrl()`, `user-avatar.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
 
 ## Suggested Questions
 _Questions this graph is uniquely positioned to answer:_
 
-- **Why does `get()` connect `Community 0` to `Community 1`, `Community 2`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 39`, `Community 9`, `Community 10`, `Community 13`, `Community 16`, `Community 18`, `Community 20`, `Community 25`, `Community 26`?**
-  _High betweenness centrality (0.284) - this node is a cross-community bridge._
-- **Why does `push()` connect `Community 3` to `Community 1`, `Community 36`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 13`, `Community 14`, `Community 15`, `Community 27`?**
-  _High betweenness centrality (0.142) - this node is a cross-community bridge._
-- **Are the 192 inferred relationships involving `get()` (e.g. with `collectLeafFacilityNodesInTreeOrder()` and `buildTreePathLabelFromFlat()`) actually correct?**
-  _`get()` has 192 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 32 inferred relationships involving `push()` (e.g. with `collectLeafFacilityNodesInTreeOrder()` and `fetchAllPagedRecords()`) actually correct?**
-  _`push()` has 32 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 15 inferred relationships involving `withFullscreenLoading()` (e.g. with `.close()` and `handleSubmit()`) actually correct?**
-  _`withFullscreenLoading()` has 15 INFERRED edges - model-reasoned connections that need verification._
+- **Why does `get()` connect `Community 0` to `Community 1`, `Community 2`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 8`, `Community 9`, `Community 11`, `Community 13`, `Community 15`, `Community 16`, `Community 17`, `Community 18`, `Community 19`, `Community 21`, `Community 22`, `Community 26`, `Community 29`, `Community 33`, `Community 34`, `Community 39`, `Community 43`?**
+  _High betweenness centrality (0.283) - this node is a cross-community bridge._
+- **Why does `push()` connect `Community 1` to `Community 0`, `Community 7`, `Community 8`, `Community 10`, `Community 11`, `Community 14`, `Community 15`, `Community 16`, `Community 17`?**
+  _High betweenness centrality (0.122) - this node is a cross-community bridge._
+- **Are the 195 inferred relationships involving `get()` (e.g. with `collectLeafFacilityNodesInTreeOrder()` and `buildTreePathLabelFromFlat()`) actually correct?**
+  _`get()` has 195 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 33 inferred relationships involving `push()` (e.g. with `collectLeafFacilityNodesInTreeOrder()` and `fetchAllPagedRecords()`) actually correct?**
+  _`push()` has 33 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 16 inferred relationships involving `withFullscreenLoading()` (e.g. with `.close()` and `handleSubmit()`) actually correct?**
+  _`withFullscreenLoading()` has 16 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 15 inferred relationships involving `emit()` (e.g. with `handleSubmit()` and `handleBlur()`) actually correct?**
+  _`emit()` has 15 INFERRED edges - model-reasoned connections that need verification._
 - **Should `Community 0` be split into smaller, more focused modules?**
-  _Cohesion score 0.01 - nodes in this community are weakly interconnected._
-- **Should `Community 1` be split into smaller, more focused modules?**
   _Cohesion score 0.01 - nodes in this community are weakly interconnected._

ファイルの差分が大きいため隠しています
+ 1 - 1
web-admin/graphify-out/graph.html


+ 28 - 19
web-admin/graphify-out/manifest.json

@@ -17,8 +17,8 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/common/index.ts": 1778582068.0254738,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/directive/directive.d.ts": 1778582068.026978,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/api/api.d.ts": 1778582068.0246184,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts": 1779802025.7709928,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1779800759.9701834,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts": 1779830492.5032978,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1779829952.0242388,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/store/index.ts": 1778582068.0293276,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/router/index.ts": 1778775532.895603,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/locales/index.ts": 1778582068.004279,
@@ -44,7 +44,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/fullscreen-loading.ts": 1779117454.818312,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/router.ts": 1778582068.0393815,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/protocol-payment-labels.ts": 1779199125.4894068,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/status-tag-registry.ts": 1779800687.4427748,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/status-tag-registry.ts": 1779826490.6921933,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/fetch-paged-list.ts": 1778775532.895782,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/sort-order.ts": 1779790341.2952042,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/outbound-qc-pilot-view-sections.ts": 1779801033.9457984,
@@ -174,16 +174,18 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/media/art-cutter-img/index.vue": 1779525781.6051006,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/protocol-finance-confirm-fields/index.vue": 1779790325.7480125,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/protocol-finance-confirm-fields/finance-attachments.ts": 1778987672.644368,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/qc-task-view-dialog/index.vue": 1779790326.0876904,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/qc-task-view-dialog/index.vue": 1779824466.9487405,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/dialog-form-body/index.vue": 1779790325.0384986,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/sample-qc-info-cards/index.vue": 1779790326.2389948,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/sample-qc-info-cards/index.vue": 1779824462.0648055,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/order-operate-local-dialog/index.vue": 1779790325.101185,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/product-view-dialog/index.vue": 1779790325.4147334,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/sample-production-info-cards/index.vue": 1779790326.1909053,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/sample-storage-release-info/index.vue": 1779542542.2434525,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/infusion-schedule-outbound-chain-view/index.vue": 1779830492.0922873,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/work-order-remark-dialog/index.vue": 1779790326.4187233,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/outbound-material-form-sections/index.vue": 1779830388.1680186,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/protocol-payment-detail-readonly/index.vue": 1779525781.6098895,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/system-view-dialog/index.vue": 1779790326.3887281,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/system-view-dialog/index.vue": 1779811081.0609167,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/production-task-view-dialog/index.vue": 1779790325.572335,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/order-operate-dialog-bridge/index.vue": 1779790325.0697935,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/common/amount-input/index.vue": 1779790326.44512,
@@ -203,6 +205,8 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/leave.ts": 1778666177.2933538,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/sample-reception-check-item.ts": 1779007909.2933023,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/production.ts": 1779790323.3656075,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/infusion-schedule-handover.ts": 1779828287.9085703,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/outbound-release-review.ts": 1779827106.6848028,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-third-party-item.ts": 1779017836.101254,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/storage-release-review.ts": 1779615813.3431153,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/workflow.ts": 1779790323.5709383,
@@ -213,7 +217,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/chinaRegion.ts": 1778582067.8620915,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/storage-entry-sms-notification.ts": 1779691739.8547683,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/business.ts": 1779159587.6033742,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/admin.ts": 1779790323.0007687,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/admin.ts": 1779809235.6101413,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/ethnic.ts": 1778929297.3919344,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-lab-test-item.ts": 1779017838.8231559,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/cell-product.ts": 1779594447.3049195,
@@ -241,7 +245,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/payment-info/index.vue": 1779790344.50928,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/storage-cert/index.vue": 1779790347.9803133,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol-audit/index.vue": 1779790344.752764,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/material/index.vue": 1779790344.1219785,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/material/index.vue": 1779805190.0284903,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/fullmoon-todo/index.vue": 1779790343.5693362,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/storage-entry-info/index.vue": 1779790348.068867,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/payment-confirm/index.vue": 1779790344.275864,
@@ -294,8 +298,8 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/cell-product/source-chain/chainToFlow.ts": 1779790358.1472504,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/process-route/index.vue": 1779790359.1428778,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/sop/index.vue": 1779790360.846551,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-task/index.vue": 1779801736.748366,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/reagent/index.vue": 1779790360.6636097,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-task/index.vue": 1779805190.0299485,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/reagent/index.vue": 1779805190.0304468,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/index.vue": 1779801416.9937282,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/useTaskStepEditor.ts": 1779790360.26504,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/StepDetailPanel.vue": 1779790359.9189978,
@@ -307,30 +311,31 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/DispatchTaskDialog.vue": 1779801620.1553593,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/TaskOutputProductDialog.vue": 1779701250.4415438,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/consumable-pack/index.vue": 1779790358.448969,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/consumable/index.vue": 1779790358.8545897,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/consumable/index.vue": 1779805190.029252,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/process/index.vue": 1779790359.3889246,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/process-category/index.vue": 1779790358.9345891,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/index/index.vue": 1779525781.6949234,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-work-order/index.vue": 1779799317.640114,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-work-order/index.vue": 1779829827.0884113,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-work-order/OutboundQcViewDialog.vue": 1779801046.9472926,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/sample-reception-check-item/index.vue": 1779790362.415862,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/lab-test-item/index.vue": 1779790361.1549423,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-material/index.vue": 1779790361.5001903,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-material/index.vue": 1779805190.0313084,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/sample-reception/index.vue": 1779790362.5893314,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-task/index.vue": 1779801145.0078545,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/storage-release-review/index.vue": 1779790362.6423476,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/storage-release-review/StorageReleaseReviewDialog.vue": 1779701230.688804,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/storage-release-review/StorageReleaseViewDialog.vue": 1779790362.6831455,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-material-usage/index.vue": 1779790361.21638,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/third-party-company/index.vue": 1779790362.7538998,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/third-party-company/index.vue": 1779805190.032101,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/exception/500/index.vue": 1779546148.0553653,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/exception/403/index.vue": 1779546144.3909206,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/exception/404/index.vue": 1779546146.242008,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/cell-product-type/index.vue": 1779790350.9383118,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/checkup-institution/index.vue": 1779790354.3513076,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/checkup-institution/CheckupPackagePanel.vue": 1779790354.1964018,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/infusion-schedule-management/index.vue": 1779790354.895791,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/infusion-schedule-management/ScheduleViewDialog.vue": 1779791979.0257256,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/infusion-schedule-management/index.vue": 1779828418.9739575,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/infusion-schedule-management/InfusionScheduleHandoverDialog.vue": 1779830388.4782345,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/infusion-schedule-management/ScheduleViewDialog.vue": 1779830147.6915238,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/infusion-schedule-management/ScheduleCancelDialog.vue": 1779785733.3155978,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/infusion-schedule-management/ScheduleEditDialog.vue": 1779790355.024449,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/index.vue": 1779792910.3544722,
@@ -338,9 +343,13 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue": 1779792403.5058112,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel/index.vue": 1779790353.7514098,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel/ChannelViewDialog.vue": 1779790352.7457726,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/pilot-application/index.vue": 1779786942.5154335,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/outbound-release-review/index.vue": 1779825987.213009,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/outbound-release-review/OutboundReleaseViewDialog.vue": 1779830031.9999688,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/outbound-release-review/OutboundReleaseReviewDialog.vue": 1779824973.037717,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/outbound-release-review/OutboundReleaseMaterialDialog.vue": 1779829323.2023485,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/pilot-application/index.vue": 1779805793.0933356,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/pilot-application/ApplicationDetailDialog.vue": 1779790355.5310535,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/pilot-application/ApplicationViewDialog.vue": 1779790356.8604274,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/pilot-application/ApplicationViewDialog.vue": 1779830501.0475526,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/pilot-application/ApplicationFormDialog.vue": 1779791712.2092516,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/cell-product-price/index.vue": 1779790350.411278,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/checkup-management/index.vue": 1779790354.8558528,
@@ -377,7 +386,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/RoutePermissionValidator.ts": 1778582068.0127926,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/index.ts": 1778582068.0138035,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/IframeRouteManager.ts": 1778582068.0121944,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/sqxp.ts": 1779772106.7115505,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/sqxp.ts": 1779809233.3957787,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/index.ts": 1778598510.6043763,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/exception.ts": 1779546186.993006,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/routes/staticRoutes.ts": 1779546226.1088154,

+ 0 - 13
web-admin/src/api/admin.ts

@@ -2217,19 +2217,6 @@ export const createOutboundQcWorkOrder = (data: any) =>
     params: data
   })
 
-// ========== 中试服务 - 放行审核 ==========
-export const createReleaseReview = (data: any) =>
-  request.post({ url: '/web/admin/pilot-release-review/create', params: data })
-
-export const fetchReleaseReviewList = (params: any) =>
-  request.get({ url: '/web/admin/pilot-release-review/list', params })
-
-export const updateReleaseReview = (data: any) =>
-  request.put({ url: '/web/admin/pilot-release-review/update', params: data })
-
-export const deleteReleaseReview = (data: any) =>
-  request.del({ url: '/web/admin/pilot-release-review/delete', data })
-
 // ========== 中试服务 - 应用确认 ==========
 export const createInfusionConfirm = (data: any) =>
   request.post({ url: '/web/admin/pilot-infusion-confirm/create', params: data })

+ 60 - 0
web-admin/src/api/infusion-schedule-handover.ts

@@ -0,0 +1,60 @@
+import request from '@/utils/http'
+
+export type OutboundHandoverInfo = {
+  handoverUserId?: string
+  handoverPerson?: string
+  handoverTime?: string
+  handoverLocation?: string
+  receiverPerson?: string
+  remark?: string
+}
+
+export type OutboundHandoverDetail = {
+  scheduleSummary?: {
+    applicationNo?: string
+    patientName?: string
+    infusionTime?: string
+  }
+  outboundMaterial?: {
+    remark?: string
+    cells?: Array<{ cellProductId?: string; remark?: string }>
+    qcMaterials?: Array<{ qcMaterialId?: string; qty?: number; remark?: string }>
+    paperMaterials?: Array<{
+      paperName?: string
+      copies?: number
+      attachmentUrl?: string
+      remark?: string
+    }>
+  }
+  handover?: OutboundHandoverInfo
+}
+
+export function fetchInfusionScheduleOutboundHandover(scheduleId: string) {
+  return request.get<OutboundHandoverDetail>({
+    url: `/web/admin/pilot-infusion-schedule-mgmt/${scheduleId}/outbound-handover`
+  })
+}
+
+export function saveInfusionScheduleOutboundHandover(
+  scheduleId: string,
+  data: {
+    outboundMaterial: {
+      remark?: string
+      cells: { cellProductId: string; remark?: string }[]
+      qcMaterials: { materialId: string; qty: number; remark?: string }[]
+      paperMaterials: { paperName: string; copies: number; attachmentUrl: string; remark?: string }[]
+    }
+    handover: {
+      handoverUserId: string
+      handoverTime: string
+      handoverLocation?: string
+      receiverPerson: string
+      remark?: string
+    }
+  }
+) {
+  return request.put<OutboundHandoverDetail>({
+    url: `/web/admin/pilot-infusion-schedule-mgmt/${scheduleId}/outbound-handover`,
+    data
+  })
+}

+ 219 - 0
web-admin/src/api/outbound-release-review.ts

@@ -0,0 +1,219 @@
+import request from '@/utils/http'
+import type {
+  OutboundQcDetailApplication,
+  OutboundQcDetailPatient,
+  OutboundQcDetailInfusionPlan,
+  OutboundQcDetailInfusionSchedule,
+  OutboundQcDetailProduct,
+} from '@/api/qc-work-order'
+
+type PageList<T> = {
+  list: T[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages: number
+}
+
+export type OutboundReleaseReview = {
+  id: string
+  scheduleId?: string
+  /** pilot_service=中试服务 clinical_application=临床应用 */
+  bizType?: string
+  bizTypeLabel?: string
+  applicationNo?: string
+  patientName?: string
+  cellType?: string
+  specText?: string
+  infusionTime?: string
+  scheduleStatus?: string
+  scheduleStatusLabel?: string
+  qcStatus?: string
+  qcStatusLabel?: string
+  reviewConclusion?: string
+  reviewConclusionLabel?: string
+  reviewRemark?: string
+  reviewAttachments?: string
+  reviewByName?: string
+  reviewTime?: string
+  createTime?: string
+  updateTime?: string
+}
+
+export type {
+  OutboundQcDetailApplication,
+  OutboundQcDetailPatient,
+  OutboundQcDetailInfusionPlan,
+  OutboundQcDetailInfusionSchedule,
+  OutboundQcDetailProduct,
+}
+
+export type OutboundReviewInfo = {
+  id?: string
+  scheduleId?: string
+  reviewConclusion?: string
+  reviewRemark?: string
+  reviewAttachments?: string
+  reviewByName?: string
+  reviewTime?: string
+}
+
+export type OutboundProductionTask = {
+  id?: string
+  taskNo?: string
+  taskName?: string
+  taskStatus?: string
+  taskStatusLabel?: string
+  conclusion?: string
+  conclusionLabel?: string
+  startedTime?: string
+  completedTime?: string
+}
+
+export type OutboundProductionInfo = {
+  workOrderNo?: string
+  workOrderStatus?: string
+  workOrderStatusLabel?: string
+  tasks?: OutboundProductionTask[]
+}
+
+export type OutboundQcTask = {
+  id?: string
+  taskType?: string
+  taskTypeLabel?: string
+  taskStatus?: string
+  taskStatusLabel?: string
+  conclusion?: string
+  conclusionLabel?: string
+}
+
+export type OutboundQcInfo = {
+  workOrderNo?: string
+  workOrderStatus?: string
+  workOrderStatusLabel?: string
+  tasks?: OutboundQcTask[]
+}
+
+export type OutboundReleaseReviewDetail = {
+  application?: OutboundQcDetailApplication
+  patient?: OutboundQcDetailPatient
+  infusionPlan?: OutboundQcDetailInfusionPlan
+  infusionSchedule?: OutboundQcDetailInfusionSchedule
+  products?: OutboundQcDetailProduct[]
+  productionInfo?: OutboundProductionInfo
+  qcInfo?: OutboundQcInfo
+  reviewInfo?: OutboundReviewInfo
+  outboundMaterial?: OutboundReleaseMaterialDetail
+}
+
+export type OutboundReleaseCellRow = {
+  id?: string
+  cellProductId?: string
+  productCode?: string
+  productName?: string
+  productTypeName?: string
+  specName?: string
+  passage?: string
+  batchNo?: string
+  storageSlotName?: string
+  productStatus?: string
+  remark?: string
+}
+
+export type OutboundReleaseQcMaterialRow = {
+  id?: string
+  qcMaterialId?: string
+  qty?: number
+  materialCode?: string
+  materialName?: string
+  specModel?: string
+  unit?: string
+  remark?: string
+}
+
+export type OutboundReleasePaperRow = {
+  id?: string
+  paperName?: string
+  copies?: number
+  attachmentUrl?: string
+  remark?: string
+}
+
+export type OutboundReleaseMaterialDetail = {
+  id?: string
+  infusionScheduleId?: string
+  paperAttachments?: string
+  paperMaterials?: OutboundReleasePaperRow[]
+  remark?: string
+  cells?: OutboundReleaseCellRow[]
+  qcMaterials?: OutboundReleaseQcMaterialRow[]
+  createTime?: string
+  updateTime?: string
+}
+
+export function fetchOutboundReleaseReviewList(params?: {
+  page?: number
+  pageSize?: number
+  scheduleStatus?: string
+  reviewConclusion?: string
+  qcStatus?: string
+  keyword?: string
+}) {
+  return request.get<PageList<OutboundReleaseReview>>({
+    url: '/web/admin/pilot-release-reviews',
+    params,
+  })
+}
+
+export function fetchOutboundReleaseReviewDetail(scheduleId: string) {
+  return request.get<OutboundReleaseReviewDetail>({
+    url: `/web/admin/pilot-release-reviews/${scheduleId}/detail`,
+  })
+}
+
+export function submitOutboundReleaseReview(
+  scheduleId: string,
+  data: {
+    reviewConclusion: string
+    reviewRemark?: string
+    reviewAttachments?: string
+  }
+) {
+  return request.put<OutboundReleaseReview>({
+    url: `/web/admin/pilot-release-reviews/${scheduleId}/review`,
+    data,
+  })
+}
+
+export function batchReviewOutboundRelease(data: {
+  scheduleIds: string[]
+  reviewConclusion: string
+  reviewRemark?: string
+  reviewAttachments?: string
+}) {
+  return request.put<void>({
+    url: '/web/admin/pilot-release-reviews/batch-review',
+    data,
+  })
+}
+
+export function fetchOutboundReleaseMaterial(scheduleId: string) {
+  return request.get<OutboundReleaseMaterialDetail>({
+    url: `/web/admin/pilot-release-reviews/${scheduleId}/outbound-materials`,
+  })
+}
+
+export function saveOutboundReleaseMaterial(
+  scheduleId: string,
+  data: {
+    paperMaterials?: { paperName: string; copies: number; attachmentUrl: string; remark?: string }[]
+    remark?: string
+    cells: { cellProductId: string; remark?: string }[]
+    qcMaterials: { materialId: string; qty: number; remark?: string }[]
+  }
+) {
+  return request.put<OutboundReleaseMaterialDetail>({
+    url: `/web/admin/pilot-release-reviews/${scheduleId}/outbound-materials`,
+    data,
+  })
+}

+ 682 - 0
web-admin/src/components/business/infusion-schedule-outbound-chain-view/index.vue

@@ -0,0 +1,682 @@
+<!-- 输注计划出库链路只读区(生产/质检/物料/放行/交接/应用确认) -->
+<template>
+  <div v-loading="loading" class="infusion-schedule-outbound-chain">
+    <template v-if="detail?.id || scheduleId">
+      <div class="outbound-chain-section">
+        <div class="section-subtitle">出库生产工单 + 任务</div>
+        <SampleProductionInfoCards :production-info="detail?.productionInfo" />
+      </div>
+
+      <div class="outbound-chain-section">
+        <div class="section-subtitle">出库质检工单 + 任务</div>
+        <SampleQcInfoCards :qc-info="detail?.qcInfo" hide-sample-context />
+      </div>
+
+      <div class="outbound-chain-section">
+        <div class="section-subtitle">出库物料准备</div>
+        <div class="outbound-material-view">
+          <div v-if="!hasOutboundMaterial" class="outbound-material-empty">暂无出库物料准备记录</div>
+          <template v-else>
+            <div v-if="outboundMaterialCells.length" class="outbound-material-block">
+              <h4 class="outbound-material-subtitle">出库细胞</h4>
+              <el-table
+                :data="outboundMaterialCells"
+                class="list-table inner-table"
+                stripe
+                size="small"
+              >
+                <el-table-column type="index" label="序号" min-width="56" align="center" />
+                <el-table-column
+                  prop="productCode"
+                  label="产品编码"
+                  min-width="150"
+                  show-overflow-tooltip
+                />
+                <el-table-column
+                  prop="productName"
+                  label="产品名称"
+                  min-width="120"
+                  show-overflow-tooltip
+                />
+                <el-table-column prop="productTypeName" label="类型" min-width="90" />
+                <el-table-column prop="specName" label="规格" min-width="90" />
+                <el-table-column
+                  prop="storageSlotName"
+                  label="储物格"
+                  min-width="130"
+                  show-overflow-tooltip
+                />
+                <el-table-column label="状态" min-width="90" align="center">
+                  <template #default="{ row }">
+                    <el-tag
+                      v-if="row.productStatus"
+                      :type="cellProductStatusTagType(row.productStatus) as any"
+                      size="small"
+                    >
+                      {{ cellProductStatusLabel(row.productStatus) }}
+                    </el-tag>
+                    <span v-else>—</span>
+                  </template>
+                </el-table-column>
+              </el-table>
+            </div>
+            <div v-if="outboundMaterialQcRows.length" class="outbound-material-block">
+              <h4 class="outbound-material-subtitle">使用的物料</h4>
+              <el-table
+                :data="outboundMaterialQcRows"
+                class="list-table inner-table"
+                stripe
+                size="small"
+              >
+                <el-table-column type="index" label="序号" min-width="56" align="center" />
+                <el-table-column
+                  prop="materialName"
+                  label="物料名称"
+                  min-width="140"
+                  show-overflow-tooltip
+                />
+                <el-table-column prop="specModel" label="规格型号" min-width="110" />
+                <el-table-column label="计划用量" min-width="100" align="center">
+                  <template #default="{ row }">{{ row.qty ?? '—' }}</template>
+                </el-table-column>
+                <el-table-column prop="unit" label="单位" min-width="72" align="center" />
+              </el-table>
+            </div>
+            <div v-if="outboundPaperRows.length" class="outbound-material-block">
+              <h4 class="outbound-material-subtitle">其他纸质材料</h4>
+              <el-table
+                :data="outboundPaperRows"
+                class="list-table inner-table"
+                stripe
+                size="small"
+              >
+                <el-table-column type="index" label="序号" min-width="56" align="center" />
+                <el-table-column prop="paperName" label="名称" min-width="140" show-overflow-tooltip />
+                <el-table-column label="份数" min-width="80" align="center">
+                  <template #default="{ row }">{{ row.copies ?? '—' }}</template>
+                </el-table-column>
+                <el-table-column label="附件" min-width="320">
+                  <template #default="{ row }">
+                    <div v-if="parsePaperAttachments(row.attachmentUrl).length" class="paper-att-cards">
+                      <div
+                        v-for="(att, attIdx) in parsePaperAttachments(row.attachmentUrl)"
+                        :key="attIdx"
+                        class="paper-att-card"
+                      >
+                        <el-icon class="paper-att-card__icon"><Document /></el-icon>
+                        <span class="paper-att-card__name" :title="att.fileName">{{
+                          att.fileName || '附件'
+                        }}</span>
+                        <div class="paper-att-card__actions">
+                          <el-icon
+                            v-if="isImageFile(att)"
+                            class="paper-att-card__action"
+                            title="预览"
+                            @click="handlePreviewPaperAttachment(att)"
+                          >
+                            <View />
+                          </el-icon>
+                          <el-icon
+                            class="paper-att-card__action"
+                            title="下载"
+                            @click="handleDownloadPaperAttachment(att)"
+                          >
+                            <Download />
+                          </el-icon>
+                        </div>
+                      </div>
+                    </div>
+                    <span v-else>—</span>
+                  </template>
+                </el-table-column>
+              </el-table>
+            </div>
+            <p v-if="outboundMaterialRemark" class="outbound-material-remark">
+              备注:{{ outboundMaterialRemark }}
+            </p>
+          </template>
+        </div>
+      </div>
+
+      <div class="outbound-chain-section">
+        <div class="section-subtitle">出库放行审核</div>
+        <div class="data-grid data-grid--3">
+          <div class="data-item">
+            <div class="data-label">审核结论</div>
+            <div class="data-value">
+              <el-tag :type="reviewConclusionTag as any" size="small">
+                {{ reviewConclusionDisplay }}
+              </el-tag>
+            </div>
+          </div>
+          <div class="data-item">
+            <div class="data-label">审核人</div>
+            <div class="data-value">{{ dash(review.reviewByName) }}</div>
+          </div>
+          <div class="data-item">
+            <div class="data-label">审核时间</div>
+            <div class="data-value">{{ dash(review.reviewTime) }}</div>
+          </div>
+          <div class="data-item data-item--full">
+            <div class="data-label">审核备注</div>
+            <div class="data-value data-value--muted">{{ dash(review.reviewRemark) }}</div>
+          </div>
+        </div>
+        <div v-if="reviewAttachmentFiles.length" class="attachment-row">
+          <div class="data-label">审核附件</div>
+          <ProtocolAttachmentReadonly
+            :files="reviewAttachmentFiles"
+            @preview="handleAttachmentPreview"
+            @download="handleAttachmentDownload"
+          />
+        </div>
+      </div>
+
+      <div class="outbound-chain-section">
+        <div class="section-subtitle">出库交接</div>
+        <div class="data-grid data-grid--3">
+          <div class="data-item">
+            <div class="data-label">交接人</div>
+            <div class="data-value">{{ dash(outboundHandover.handoverPerson) }}</div>
+          </div>
+          <div class="data-item">
+            <div class="data-label">交接时间</div>
+            <div class="data-value">{{ dash(outboundHandover.handoverTime) }}</div>
+          </div>
+          <div class="data-item">
+            <div class="data-label">交接地点</div>
+            <div class="data-value">{{ dash(outboundHandover.handoverLocation) }}</div>
+          </div>
+          <div class="data-item">
+            <div class="data-label">接收人</div>
+            <div class="data-value">{{ dash(outboundHandover.receiverPerson) }}</div>
+          </div>
+          <div class="data-item data-item--full">
+            <div class="data-label">备注</div>
+            <div class="data-value data-value--muted">{{ dash(outboundHandover.remark) }}</div>
+          </div>
+        </div>
+      </div>
+
+      <div class="outbound-chain-section">
+        <div class="section-subtitle">应用确认</div>
+        <div class="data-grid data-grid--3">
+          <template v-if="hasInfusionConfirm(infusionConfirm)">
+            <div class="data-item">
+              <div class="data-label">确认人</div>
+              <div class="data-value">{{ dash(infusionConfirm.confirmUserName) }}</div>
+            </div>
+            <div class="data-item">
+              <div class="data-label">确认时间</div>
+              <div class="data-value">{{ dash(infusionConfirm.confirmTime) }}</div>
+            </div>
+            <div class="data-item data-item--full">
+              <div class="data-label">确认说明</div>
+              <div class="data-value data-value--muted">{{ dash(infusionConfirm.confirmDesc) }}</div>
+            </div>
+            <div class="data-item data-item--full">
+              <div class="data-label">备注</div>
+              <div class="data-value data-value--muted">{{ dash(infusionConfirm.remark) }}</div>
+            </div>
+          </template>
+          <template v-else-if="isInfusionConfirmed">
+            <div class="data-item">
+              <div class="data-label">确认状态</div>
+              <div class="data-value">
+                <el-tag :type="infusionStatusTagType('yrqr') as any" size="small">
+                  {{ infusionStatusLabel('yrqr') }}
+                </el-tag>
+              </div>
+            </div>
+            <div class="data-item data-item--full">
+              <div class="data-label">说明</div>
+              <div class="data-value data-value--muted">
+                已在输注计划管理中确认,暂无独立应用确认记录
+              </div>
+            </div>
+          </template>
+          <div v-else class="data-item">
+            <div class="data-label">确认状态</div>
+            <div class="data-value">
+              <el-tag type="info" size="small">未确认</el-tag>
+            </div>
+          </div>
+        </div>
+      </div>
+    </template>
+
+    <el-dialog
+      v-model="previewVisible"
+      title="文件预览"
+      width="800px"
+      align-center
+      destroy-on-close
+      append-to-body
+      style="backdrop-filter: none"
+    >
+      <div v-if="previewType === 'image'" class="preview-container">
+        <img
+          :src="previewUrl"
+          alt="预览"
+          style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
+        />
+      </div>
+      <div v-else class="preview-container">
+        <video
+          :src="previewUrl"
+          controls
+          style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
+        />
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, computed, watch } from 'vue'
+  import { Document, Download, View } from '@element-plus/icons-vue'
+  import SampleProductionInfoCards from '@/components/business/sample-production-info-cards/index.vue'
+  import SampleQcInfoCards from '@/components/business/sample-qc-info-cards/index.vue'
+  import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
+  import { fetchInfusionScheduleMgmtDetail } from '@/api/admin'
+  import {
+    storageReleaseConclusionLabel,
+    storageReleaseConclusionTagType,
+    infusionStatusLabel,
+    infusionStatusTagType,
+    cellProductStatusLabel,
+    cellProductStatusTagType
+  } from '@/utils/status-tag-registry'
+  import { downloadAttachmentFile } from '@/utils/download-attachment'
+
+  defineOptions({ name: 'InfusionScheduleOutboundChainView' })
+
+  const props = withDefaults(
+    defineProps<{
+      scheduleId?: string
+      /** 外部已加载的详情(如 ScheduleViewDialog),传入则不再请求 */
+      detailData?: Record<string, any> | null
+      /** 懒加载:为 false 时不请求(申请单查看折叠态) */
+      active?: boolean
+    }>(),
+    {
+      scheduleId: '',
+      detailData: null,
+      active: true
+    }
+  )
+
+  const loading = ref(false)
+  const fetchedDetail = ref<Record<string, any> | null>(null)
+  const previewVisible = ref(false)
+  const previewUrl = ref('')
+  const previewType = ref<'image' | 'video'>('image')
+
+  type PaperAttachmentView = {
+    fileName: string
+    attachmentUrl: string
+    fileUrl: string
+    fileType?: string
+  }
+
+  const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
+
+  const detail = computed(() => props.detailData || fetchedDetail.value)
+
+  const review = computed(() => detail.value?.reviewInfo || {})
+  const outboundHandover = computed(() => detail.value?.outboundHandover || {})
+  const infusionConfirm = computed(() => detail.value?.infusionConfirm || {})
+  const outboundMaterial = computed(() => detail.value?.outboundMaterial || null)
+
+  const outboundMaterialCells = computed(() => outboundMaterial.value?.cells || [])
+  const outboundMaterialQcRows = computed(() => outboundMaterial.value?.qcMaterials || [])
+  const outboundMaterialRemark = computed(() => outboundMaterial.value?.remark || '')
+
+  const outboundPaperRows = computed(() => {
+    const materials = outboundMaterial.value?.paperMaterials || []
+    if (materials.length) {
+      return materials.map((p: Record<string, any>) => ({
+        paperName: p.paperName || '',
+        copies: p.copies,
+        attachmentUrl: p.attachmentUrl || ''
+      }))
+    }
+    const urls = outboundMaterial.value?.paperAttachments || ''
+    if (!urls.trim()) return []
+    return urls
+      .split(',')
+      .map((u: string) => u.trim())
+      .filter(Boolean)
+      .map((attachmentUrl: string) => ({
+        paperName: paperAttachmentName(attachmentUrl),
+        copies: 1,
+        attachmentUrl
+      }))
+  })
+
+  const hasOutboundMaterial = computed(
+    () =>
+      outboundMaterialCells.value.length > 0 ||
+      outboundMaterialQcRows.value.length > 0 ||
+      outboundPaperRows.value.length > 0 ||
+      !!outboundMaterialRemark.value.trim()
+  )
+
+  const reviewAttachmentFiles = computed(() => {
+    const urls = review.value?.reviewAttachments || ''
+    if (!urls.trim()) return []
+    return urls
+      .split(',')
+      .map((u: string) => u.trim())
+      .filter(Boolean)
+      .map((fileUrl: string, i: number) => ({
+        id: `att_${i}`,
+        fileUrl,
+        fileName: fileUrl.split('/').pop() || ''
+      }))
+  })
+
+  const reviewConclusionValue = computed(() => review.value?.reviewConclusion || '')
+  const reviewConclusionDisplay = computed(() =>
+    reviewConclusionValue.value
+      ? storageReleaseConclusionLabel(reviewConclusionValue.value)
+      : '待审核'
+  )
+  const reviewConclusionTag = computed(() =>
+    reviewConclusionValue.value
+      ? storageReleaseConclusionTagType(reviewConclusionValue.value)
+      : 'info'
+  )
+
+  const isInfusionConfirmed = computed(() => {
+    const d = detail.value || {}
+    const sched = d.infusionSchedule || {}
+    return sched.infusionStatus === 'yrqr' || d.infusionStatus === 'yrqr'
+  })
+
+  function dash(v: unknown) {
+    if (v == null || v === '') return '—'
+    return String(v)
+  }
+
+  function hasInfusionConfirm(confirm: Record<string, any>) {
+    return !!(confirm?.id || confirm?.confirmTime || confirm?.confirmUserName)
+  }
+
+  function paperAttachmentName(url: string) {
+    return url.split('/').pop() || '附件'
+  }
+
+  function splitUrls(str: string): string[] {
+    if (!str?.trim()) return []
+    return str
+      .split(',')
+      .map((u) => u.trim())
+      .filter(Boolean)
+  }
+
+  function parsePaperAttachments(urlStr: string): PaperAttachmentView[] {
+    return splitUrls(urlStr).map((url) => ({
+      fileName: paperAttachmentName(url),
+      attachmentUrl: url,
+      fileUrl: url
+    }))
+  }
+
+  function isImageFile(file: PaperAttachmentView) {
+    const url = (file.fileUrl || file.attachmentUrl || '').toLowerCase()
+    const name = (file.fileName || '').toLowerCase()
+    return (
+      /\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
+      /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
+      (file.fileType || '').startsWith('image/')
+    )
+  }
+
+  function isVideoFile(file: PaperAttachmentView) {
+    const url = (file.fileUrl || file.attachmentUrl || '').toLowerCase()
+    const name = (file.fileName || '').toLowerCase()
+    return (
+      /\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
+      /\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
+      (file.fileType || '').startsWith('video/')
+    )
+  }
+
+  function attachmentFullUrl(url: string) {
+    const u = String(url || '').trim()
+    if (!u) return ''
+    if (/^https?:\/\//i.test(u)) return u
+    return minioBaseUrl ? `${minioBaseUrl}/${u.replace(/^\//, '')}` : u
+  }
+
+  function handlePreviewPaperAttachment(att: PaperAttachmentView) {
+    if (!att.attachmentUrl) return
+    previewUrl.value = attachmentFullUrl(att.attachmentUrl)
+    previewType.value = isVideoFile(att) ? 'video' : 'image'
+    previewVisible.value = true
+  }
+
+  function handleDownloadPaperAttachment(att: PaperAttachmentView) {
+    if (!att.attachmentUrl) return
+    downloadAttachmentFile(
+      {
+        fileName: att.fileName,
+        fileUrl: att.attachmentUrl,
+        fileType: att.fileType || ''
+      },
+      { fullFileUrl: attachmentFullUrl }
+    )
+  }
+
+  function handleAttachmentPreview(file: Record<string, unknown>) {
+    const url = attachmentFullUrl(String(file.fileUrl || ''))
+    if (url) window.open(url, '_blank')
+  }
+
+  function handleAttachmentDownload(file: Record<string, unknown>) {
+    downloadAttachmentFile(file, { fullFileUrl: attachmentFullUrl })
+  }
+
+  async function loadDetail() {
+    const id = (props.scheduleId || '').trim()
+    if (!id || props.detailData) return
+    loading.value = true
+    try {
+      const res: any = await fetchInfusionScheduleMgmtDetail({ scheduleId: id })
+      fetchedDetail.value =
+        res && typeof res === 'object' && !Array.isArray(res)
+          ? (res as Record<string, any>)
+          : ((res as any)?.data ?? null)
+    } catch {
+      fetchedDetail.value = null
+    } finally {
+      loading.value = false
+    }
+  }
+
+  watch(
+    () => [props.scheduleId, props.active, props.detailData] as const,
+    ([id, active, external]) => {
+      if (external) {
+        fetchedDetail.value = null
+        return
+      }
+      if (!id || !active) return
+      loadDetail()
+    },
+    { immediate: true }
+  )
+</script>
+
+<style lang="scss" scoped>
+  .infusion-schedule-outbound-chain {
+    margin-top: 16px;
+    padding-top: 16px;
+    border-top: 1px dashed #e2e8f0;
+  }
+
+  .outbound-chain-section {
+    margin-bottom: 20px;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  .section-subtitle {
+    margin: 0 0 10px;
+    font-size: 13px;
+    font-weight: 600;
+    color: #475569;
+  }
+
+  .outbound-material-view {
+    width: 100%;
+  }
+
+  .outbound-material-empty {
+    font-size: 13px;
+    color: #94a3b8;
+  }
+
+  .outbound-material-block {
+    margin-bottom: 16px;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  .outbound-material-subtitle {
+    margin: 0 0 8px;
+    font-size: 12px;
+    font-weight: 600;
+    color: #64748b;
+  }
+
+  .outbound-material-remark {
+    margin: 12px 0 0;
+    font-size: 13px;
+    color: #64748b;
+  }
+
+  .attachment-row {
+    margin-top: 12px;
+  }
+
+  .data-grid {
+    display: grid;
+    gap: 12px 16px;
+
+    &--3 {
+      grid-template-columns: repeat(3, minmax(0, 1fr));
+    }
+  }
+
+  .data-item--full {
+    grid-column: 1 / -1;
+  }
+
+  .data-label {
+    font-size: 11px;
+    color: #64748b;
+    margin-bottom: 4px;
+  }
+
+  .data-value {
+    font-size: 14px;
+    color: #0f172a;
+    font-weight: 500;
+    word-break: break-word;
+
+    &--muted {
+      color: #475569;
+      font-weight: 400;
+    }
+  }
+
+  :deep(.sample-card__section-label) {
+    display: none;
+  }
+
+  :deep(.sample-card__section--qc),
+  :deep(.sample-card__section--production) {
+    margin-top: 0;
+    padding-top: 0;
+    border-top: none;
+  }
+
+  .paper-att-cards {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+  }
+
+  .paper-att-card {
+    width: 100px;
+    height: 72px;
+    border: 1px solid #e2e8f0;
+    border-radius: 6px;
+    background: #f8fafc;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    padding: 6px 4px;
+    position: relative;
+    overflow: hidden;
+  }
+
+  .paper-att-card__icon {
+    color: #0284c7;
+    font-size: 18px;
+    margin-bottom: 2px;
+  }
+
+  .paper-att-card__name {
+    width: 100%;
+    font-size: 11px;
+    color: #334155;
+    text-align: center;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    line-height: 1.2;
+  }
+
+  .paper-att-card__actions {
+    position: absolute;
+    top: 2px;
+    right: 2px;
+    display: flex;
+    gap: 2px;
+    opacity: 0;
+    transition: opacity 0.15s;
+  }
+
+  .paper-att-card:hover .paper-att-card__actions {
+    opacity: 1;
+  }
+
+  .paper-att-card__action {
+    width: 18px;
+    height: 18px;
+    border-radius: 3px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 10px;
+    color: #fff;
+    background: rgba(0, 0, 0, 0.35);
+    cursor: pointer;
+
+    &:hover {
+      background: #10b981;
+    }
+  }
+
+  .preview-container {
+    text-align: center;
+  }
+</style>

+ 977 - 0
web-admin/src/components/business/outbound-material-form-sections/index.vue

@@ -0,0 +1,977 @@
+<!-- 出库物料编辑区(细胞 / 质检物料 / 纸质材料) -->
+<!-- eslint-disable vue/no-v-model-argument -->
+<template>
+  <div class="outbound-material-form-sections">
+  <div class="material-sections">
+      <!-- 出库细胞 -->
+      <section class="section-card">
+        <div class="section-head">
+          <h3 class="section-title section-title--mint-bar">出库细胞</h3>
+          <el-button type="primary" size="small" class="btn-main" @click="openCellSelect">
+            <el-icon><Plus /></el-icon>
+            添加细胞产品
+          </el-button>
+        </div>
+        <div class="list-body list-wrap inner-table-wrap">
+          <el-table
+            :data="cellRows"
+            class="list-table inner-table"
+            stripe
+            max-height="280"
+            empty-text="请从细胞产品库勾选"
+          >
+            <el-table-column type="index" label="序号" min-width="56" align="center" />
+            <el-table-column
+              prop="productCode"
+              label="产品编码"
+              min-width="160"
+              show-overflow-tooltip
+            />
+            <el-table-column
+              prop="productName"
+              label="产品名称"
+              min-width="120"
+              show-overflow-tooltip
+            />
+            <el-table-column prop="productTypeName" label="产品类型" min-width="100" />
+            <el-table-column prop="specName" label="规格" min-width="90" />
+            <el-table-column prop="passage" label="代次" min-width="70" align="center" />
+            <el-table-column
+              prop="storageSlotName"
+              label="储物格"
+              min-width="140"
+              show-overflow-tooltip
+            />
+            <el-table-column label="状态" min-width="90" align="center">
+              <template #default="{ row }">
+                <el-tag
+                  v-if="row.productStatus"
+                  :type="cellProductStatusTagType(row.productStatus) as any"
+                  size="small"
+                >
+                  {{ cellProductStatusLabel(row.productStatus) }}
+                </el-tag>
+                <span v-else>—</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" min-width="80" align="center" fixed="right">
+              <template #default="{ $index }">
+                <el-button type="danger" link size="small" @click="removeCellRow($index)">
+                  <el-icon><Delete /></el-icon>
+                  删除
+                </el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+        </div>
+      </section>
+
+      <!-- 使用的物料 -->
+      <section class="section-card">
+        <div class="section-head">
+          <h3 class="section-title section-title--mint-bar">使用的物料</h3>
+          <el-button type="primary" size="small" class="btn-main" @click="addMaterialRow">
+            <el-icon><Plus /></el-icon>
+            添加物料
+          </el-button>
+        </div>
+        <el-table
+          v-if="materialRows.length"
+          :data="materialRows"
+          :row-key="(row) => row._rowKey"
+          stripe
+          class="list-table inner-table"
+          max-height="280"
+        >
+          <el-table-column type="index" label="序号" min-width="56" align="center" />
+          <el-table-column label="物料名称" min-width="180">
+            <template #default="{ row, $index }">
+              <el-select
+                v-model="row.materialId"
+                filterable
+                placeholder="搜索并选择物料"
+                class="inner-cell-input"
+                @change="(v) => onMaterialSelect($index, v)"
+              >
+                <el-option
+                  v-for="opt in materialOptions"
+                  :key="opt.id"
+                  :label="opt.label"
+                  :value="opt.id"
+                  :disabled="isMaterialOptionDisabled(opt.id, $index)"
+                />
+              </el-select>
+            </template>
+          </el-table-column>
+          <el-table-column label="规格型号" min-width="120">
+            <template #default="{ row }">{{ row.specModel || '—' }}</template>
+          </el-table-column>
+          <el-table-column label="计划用量" min-width="130">
+            <template #default="{ row }">
+              <el-input-number
+                v-model="row.qty"
+                :min="0.0001"
+                :precision="4"
+                :step="1"
+                controls-position="right"
+                class="inner-cell-input"
+              />
+            </template>
+          </el-table-column>
+          <el-table-column label="单位" min-width="72" align="center">
+            <template #default="{ row }">{{ row.unit || '—' }}</template>
+          </el-table-column>
+          <el-table-column label="当前库存" min-width="100" align="center">
+            <template #default="{ row }">
+              {{ stockQtyDisplay(row.materialId) }}
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" min-width="80" align="center" fixed="right">
+            <template #default="{ $index }">
+              <el-button type="danger" link size="small" @click="removeMaterialRow($index)">
+                <el-icon><Delete /></el-icon>
+                删除
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        <p v-else class="section-empty-hint">点击「添加物料」从质检物料库勾选并填写计划用量</p>
+      </section>
+
+      <!-- 其他纸质材料 -->
+      <section class="section-card">
+        <div class="section-head">
+          <h3 class="section-title section-title--mint-bar">其他纸质材料</h3>
+          <el-button type="primary" size="small" class="btn-main" @click="addPaperRow">
+            <el-icon><Plus /></el-icon>
+            添加纸质材料
+          </el-button>
+        </div>
+        <el-table
+          v-if="paperRows.length"
+          :data="paperRows"
+          :row-key="(row) => row._rowKey"
+          stripe
+          class="list-table inner-table"
+          max-height="280"
+        >
+          <el-table-column type="index" label="序号" min-width="56" align="center" />
+          <el-table-column label="名称" min-width="180">
+            <template #default="{ row }">
+              <el-input
+                v-model="row.paperName"
+                placeholder="请输入材料名称"
+                maxlength="200"
+                class="inner-cell-input"
+              />
+            </template>
+          </el-table-column>
+          <el-table-column label="份数" min-width="120">
+            <template #default="{ row }">
+              <el-input-number
+                v-model="row.copies"
+                :min="1"
+                :precision="0"
+                :step="1"
+                controls-position="right"
+                class="inner-cell-input"
+              />
+            </template>
+          </el-table-column>
+          <el-table-column label="附件" min-width="320">
+            <template #default="{ row, $index }">
+              <div class="paper-att-cards">
+                <div
+                  v-for="(att, attIdx) in row.attachments"
+                  :key="attIdx"
+                  class="paper-att-card"
+                >
+                  <el-icon class="paper-att-card__icon"><Document /></el-icon>
+                  <span class="paper-att-card__name" :title="att.fileName">{{
+                    att.fileName || '附件'
+                  }}</span>
+                  <div class="paper-att-card__actions">
+                    <el-icon
+                      v-if="isImageFile(att)"
+                      class="paper-att-card__action"
+                      title="预览"
+                      @click="handlePreviewPaperAttachment(att)"
+                    >
+                      <View />
+                    </el-icon>
+                    <el-icon
+                      class="paper-att-card__action"
+                      title="下载"
+                      @click="handleDownloadPaperAttachment(att)"
+                    >
+                      <Download />
+                    </el-icon>
+                    <el-icon
+                      class="paper-att-card__action paper-att-card__action--danger"
+                      title="删除"
+                      @click="removePaperAttachment($index, attIdx)"
+                    >
+                      <Delete />
+                    </el-icon>
+                  </div>
+                </div>
+                <el-upload
+                  :auto-upload="false"
+                  :show-file-list="false"
+                  accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
+                  @change="(file) => handlePaperAttachmentChange($index, file)"
+                >
+                  <div
+                    class="paper-att-card paper-att-card--trigger"
+                    :class="{ 'is-loading': row._uploading }"
+                  >
+                    <el-icon class="paper-att-card__trigger-icon"><Plus /></el-icon>
+                    <span>{{ row._uploading ? '上传中' : '上传' }}</span>
+                  </div>
+                </el-upload>
+              </div>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" min-width="80" align="center" fixed="right">
+            <template #default="{ $index }">
+              <el-button type="danger" link size="small" @click="removePaperRow($index)">
+                <el-icon><Delete /></el-icon>
+                删除
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        <p v-else class="section-empty-hint">点击「添加纸质材料」逐条填写名称、份数并上传附件;仅作台账留存,不扣库存</p>
+      </section>
+
+      <el-form label-position="top" class="biz-dialog-form">
+        <el-form-item label="备注">
+          <el-input
+            v-model="formRemark"
+            type="textarea"
+            :rows="2"
+            placeholder="可选"
+            maxlength="512"
+            show-word-limit
+          />
+        </el-form-item>
+      </el-form>
+  </div>
+
+  <!-- 细胞产品选择 -->
+  <el-dialog
+    v-model="cellSelectVisible"
+    title="选择细胞产品"
+    width="1200px"
+    class="dialog-form-layout"
+    align-center
+    destroy-on-close
+    append-to-body
+    :close-on-click-modal="false"
+    style="backdrop-filter: none"
+  >
+    <el-input
+      v-model="cellSearchKeyword"
+      placeholder="搜索产品编码或名称"
+      clearable
+      style="margin-bottom: 12px"
+      @input="loadCellProductOptions"
+    >
+      <template #prefix>
+        <el-icon><Search /></el-icon>
+      </template>
+    </el-input>
+    <div class="list-body list-wrap">
+      <el-table
+        ref="cellSelectTableRef"
+        class="list-table"
+        :data="cellProductOptions"
+        stripe
+        row-key="id"
+        height="380"
+        v-loading="cellProductLoading"
+        @selection-change="onCellSelectionChange"
+      >
+        <el-table-column
+          type="selection"
+          min-width="45"
+          align="center"
+          :selectable="isCellRowSelectable"
+        />
+        <el-table-column prop="productCode" label="产品编码" min-width="180" show-overflow-tooltip />
+        <el-table-column prop="productName" label="产品名称" min-width="120" show-overflow-tooltip />
+        <el-table-column prop="productTypeName" label="产品类型" min-width="90" />
+        <el-table-column prop="passage" label="代次" min-width="70" align="center" />
+        <el-table-column prop="storageSlotName" label="储物格" min-width="140" show-overflow-tooltip />
+        <el-table-column label="状态" min-width="90" align="center">
+          <template #default="{ row }">
+            <el-tag :type="cellProductStatusTagType(row.productStatus) as any" size="small">
+              {{ cellProductStatusLabel(row.productStatus) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="batchNo" label="批次号" min-width="110" show-overflow-tooltip />
+        <el-table-column prop="specName" label="规格" min-width="90" show-overflow-tooltip />
+      </el-table>
+    </div>
+    <template #footer>
+      <el-button @click="cellSelectVisible = false">取消</el-button>
+      <el-button type="primary" :disabled="!cellSelectedRows.length" @click="confirmCellSelect">
+        确定(已选 {{ cellSelectedRows.length }} 项)
+      </el-button>
+    </template>
+  </el-dialog>
+
+  <el-dialog
+    v-model="previewVisible"
+    title="文件预览"
+    width="800px"
+    align-center
+    destroy-on-close
+    append-to-body
+    style="backdrop-filter: none"
+  >
+    <div v-if="previewType === 'image'" class="preview-container">
+      <img
+        :src="previewUrl"
+        alt="预览"
+        style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
+      />
+    </div>
+    <div v-else class="preview-container">
+      <video
+        :src="previewUrl"
+        controls
+        style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
+      />
+    </div>
+  </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ElMessage } from 'element-plus'
+  import {
+    Plus,
+    Delete,
+    Search,
+    Document,
+    Download,
+    View
+  } from '@element-plus/icons-vue'
+  import { fetchUploadAttachment } from '@/api/business'
+  import { fetchCellProductList } from '@/api/cell-product'
+  import { fetchQcMaterialOptions, type QcMaterialOption } from '@/api/qc-material'
+  import type { OutboundReleaseCellRow } from '@/api/outbound-release-review'
+  import { downloadAttachmentFile } from '@/utils/download-attachment'
+  import { cellProductStatusLabel, cellProductStatusTagType } from '@/utils/status-tag-registry'
+
+  const props = withDefaults(
+    defineProps<{
+      /** 出库交接等需扣库存场景:仅允许选择已冻存细胞,且已选行保留可选 */
+      requireFrozenCellProduct?: boolean
+    }>(),
+    {
+      requireFrozenCellProduct: false
+    }
+  )
+
+  type AttachmentLike = {
+    fileName?: string
+    fileUrl?: string
+    attachmentUrl?: string
+    fileType?: string
+  }
+
+  type PaperAttachment = {
+    attachmentUrl: string
+    fileName: string
+    fileType?: string
+  }
+
+  type PaperRow = {
+    _rowKey: string
+    paperName: string
+    copies?: number
+    attachments: PaperAttachment[]
+    _uploading?: boolean
+  }
+
+  type MaterialRow = {
+    _rowKey: string
+    materialId: string
+    materialName: string
+    specModel: string
+    unit: string
+    qty?: number
+    remark?: string
+  }
+
+  defineOptions({ name: 'OutboundMaterialFormSections' })
+
+  const loading = ref(false)
+  const formRemark = ref('')
+  const cellRows = ref<OutboundReleaseCellRow[]>([])
+  const materialRows = ref<MaterialRow[]>([])
+  const paperRows = ref<PaperRow[]>([])
+  const materialOptions = ref<QcMaterialOption[]>([])
+
+  const cellSelectVisible = ref(false)
+  const cellSearchKeyword = ref('')
+  const cellProductOptions = ref<Record<string, any>[]>([])
+  const cellProductLoading = ref(false)
+  const cellSelectedRows = ref<Record<string, any>[]>([])
+  const cellSelectTableRef = ref()
+
+  const previewVisible = ref(false)
+  const previewUrl = ref('')
+  const previewType = ref<'image' | 'video'>('image')
+
+  const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
+
+  let materialRowSeq = 0
+  let paperRowSeq = 0
+  function nextMaterialRowKey() {
+    materialRowSeq += 1
+    return `mr_${Date.now()}_${materialRowSeq}`
+  }
+  function nextPaperRowKey() {
+    paperRowSeq += 1
+    return `pr_${Date.now()}_${paperRowSeq}`
+  }
+
+  function fullFileUrl(url: string) {
+    if (!url) return ''
+    if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/')) {
+      return url
+    }
+    return minioBaseUrl ? `${minioBaseUrl}/${url.replace(/^\//, '')}` : url
+  }
+
+  function isImageFile(file: AttachmentLike) {
+    const url = (file.fileUrl || file.attachmentUrl || '').toLowerCase()
+    const name = (file.fileName || '').toLowerCase()
+    return (
+      /\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
+      /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
+      (file.fileType || '').startsWith('image/')
+    )
+  }
+
+  function isVideoFile(file: AttachmentLike) {
+    const url = (file.fileUrl || file.attachmentUrl || '').toLowerCase()
+    const name = (file.fileName || '').toLowerCase()
+    return (
+      /\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
+      /\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
+      (file.fileType || '').startsWith('video/')
+    )
+  }
+
+  function stockQtyDisplay(materialId: string) {
+    const opt = materialOptions.value.find((o) => o.id === materialId)
+    if (!opt || opt.stockQty == null) return '—'
+    return String(opt.stockQty)
+  }
+
+  function isMaterialOptionDisabled(materialId: string, currentIndex: number) {
+    return materialRows.value.some((r, i) => i !== currentIndex && r.materialId === materialId)
+  }
+
+  function onMaterialSelect(index: number, id: string) {
+    const row = materialRows.value[index]
+    if (!row) return
+    if (!id) {
+      row.materialId = ''
+      row.materialName = ''
+      row.specModel = ''
+      row.unit = ''
+      return
+    }
+    if (isMaterialOptionDisabled(id, index)) {
+      ElMessage.warning('该物料已在列表中,请勿重复添加')
+      row.materialId = ''
+      return
+    }
+    const opt = materialOptions.value.find((o) => o.id === id)
+    row.materialId = id
+    row.materialName = opt?.materialName || ''
+    row.specModel = opt?.specModel || ''
+    row.unit = opt?.unit || ''
+  }
+
+  function addMaterialRow() {
+    materialRows.value.push({
+      _rowKey: nextMaterialRowKey(),
+      materialId: '',
+      materialName: '',
+      specModel: '',
+      unit: '',
+      qty: undefined
+    })
+  }
+
+  function removeMaterialRow(index: number) {
+    materialRows.value.splice(index, 1)
+  }
+
+  function addPaperRow() {
+    paperRows.value.push({
+      _rowKey: nextPaperRowKey(),
+      paperName: '',
+      copies: 1,
+      attachments: []
+    })
+  }
+
+  function removePaperRow(index: number) {
+    paperRows.value.splice(index, 1)
+  }
+
+  function removePaperAttachment(rowIndex: number, attachmentIndex: number) {
+    const row = paperRows.value[rowIndex]
+    if (!row) return
+    row.attachments.splice(attachmentIndex, 1)
+  }
+
+  function attachmentFileName(url: string) {
+    return url.split('/').pop() || '附件'
+  }
+
+  function parseAttachmentList(urlStr: string): PaperAttachment[] {
+    if (!urlStr?.trim()) return []
+    return urlStr
+      .split(',')
+      .map((u) => u.trim())
+      .filter(Boolean)
+      .map((url) => ({
+        attachmentUrl: url,
+        fileName: attachmentFileName(url)
+      }))
+  }
+
+  function mapPaperRowsFromApi(
+    paperMaterials: Array<{
+      paperName?: string
+      copies?: number
+      attachmentUrl?: string
+    }> = [],
+    legacyUrls = ''
+  ) {
+    if (paperMaterials.length) {
+      return paperMaterials.map((p) => ({
+        _rowKey: nextPaperRowKey(),
+        paperName: p.paperName || '',
+        copies: p.copies && p.copies > 0 ? p.copies : 1,
+        attachments: parseAttachmentList(p.attachmentUrl || '')
+      }))
+    }
+    if (!legacyUrls.trim()) return []
+    return legacyUrls
+      .split(',')
+      .map((u) => u.trim())
+      .filter(Boolean)
+      .map((attachmentUrl) => ({
+        _rowKey: nextPaperRowKey(),
+        paperName: attachmentFileName(attachmentUrl),
+        copies: 1,
+        attachments: [{ attachmentUrl, fileName: attachmentFileName(attachmentUrl) }]
+      }))
+  }
+
+  function removeCellRow(index: number) {
+    cellRows.value.splice(index, 1)
+  }
+
+  function isCellRowSelectable(row: Record<string, any>) {
+    const ids = new Set(cellRows.value.map((c) => c.cellProductId))
+    if (ids.has(row.id)) return false
+    if (props.requireFrozenCellProduct && row.productStatus && row.productStatus !== 'frozen') {
+      return false
+    }
+    return true
+  }
+
+  function onCellSelectionChange(rows: Record<string, any>[]) {
+    cellSelectedRows.value = rows
+  }
+
+  function openCellSelect() {
+    cellSearchKeyword.value = ''
+    cellSelectedRows.value = []
+    cellSelectVisible.value = true
+    loadCellProductOptions()
+  }
+
+  async function loadCellProductOptions() {
+    cellProductLoading.value = true
+    try {
+      const res = await fetchCellProductList({
+        page: 1,
+        pageSize: 100,
+        keyword: cellSearchKeyword.value?.trim() || undefined,
+        productStatus: props.requireFrozenCellProduct ? 'frozen' : undefined
+      })
+      cellProductOptions.value = res.list || []
+    } finally {
+      cellProductLoading.value = false
+    }
+  }
+
+  function confirmCellSelect() {
+    if (!cellSelectedRows.value.length) return
+    const existing = new Set(cellRows.value.map((c) => c.cellProductId))
+    let added = 0
+    for (const row of cellSelectedRows.value) {
+      if (existing.has(row.id)) continue
+      cellRows.value.push({
+        cellProductId: row.id,
+        productCode: row.productCode || '',
+        productName: row.productName || '',
+        productTypeName: row.productTypeName || '',
+        specName: row.specName || '',
+        passage: row.passage || '',
+        batchNo: row.batchNo || '',
+        storageSlotName: row.storageSlotName || '',
+        productStatus: row.productStatus || ''
+      })
+      existing.add(row.id)
+      added += 1
+    }
+    if (added) {
+      cellSelectVisible.value = false
+    }
+  }
+
+  async function loadFromApiData(data: {
+    remark?: string
+    cells?: OutboundReleaseCellRow[]
+    qcMaterials?: Array<{
+      qcMaterialId?: string
+      materialName?: string
+      specModel?: string
+      unit?: string
+      qty?: number
+      remark?: string
+    }>
+    paperMaterials?: Array<{
+      paperName?: string
+      copies?: number
+      attachmentUrl?: string
+    }>
+    paperAttachments?: string
+  }) {
+    formRemark.value = data.remark || ''
+    cellRows.value = (data.cells || []).map((c) => ({ ...c }))
+    materialRows.value = (data.qcMaterials || []).map((m) => ({
+      _rowKey: nextMaterialRowKey(),
+      materialId: m.qcMaterialId || '',
+      materialName: m.materialName || '',
+      specModel: m.specModel || '',
+      unit: m.unit || '',
+      qty: m.qty,
+      remark: m.remark || ''
+    }))
+    paperRows.value = mapPaperRowsFromApi(data.paperMaterials, data.paperAttachments)
+  }
+
+  async function ensureMaterialOptions() {
+    if (materialOptions.value.length) return
+    try {
+      materialOptions.value = (await fetchQcMaterialOptions()) || []
+    } catch {
+      materialOptions.value = []
+    }
+  }
+
+  function validateMaterial() {
+    if (paperRows.value.some((r) => r._uploading)) {
+      ElMessage.warning('纸质材料附件上传中,请稍候')
+      return false
+    }
+    for (const row of materialRows.value) {
+      if (!row.materialId) {
+        ElMessage.warning('请为每条物料选择名称')
+        return false
+      }
+      const qty = row.qty
+      if (qty == null || !Number.isFinite(Number(qty)) || Number(qty) <= 0) {
+        ElMessage.warning('物料计划用量须大于 0')
+        return false
+      }
+    }
+    for (const row of paperRows.value) {
+      if (!row.paperName?.trim()) {
+        ElMessage.warning('请填写每条纸质材料的名称')
+        return false
+      }
+      const copies = row.copies
+      if (copies == null || !Number.isFinite(Number(copies)) || Number(copies) <= 0) {
+        ElMessage.warning('纸质材料份数须大于 0')
+        return false
+      }
+      if (!row.attachments.length) {
+        ElMessage.warning('请为每条纸质材料上传附件')
+        return false
+      }
+    }
+    return true
+  }
+
+  function buildSavePayload() {
+    return {
+      remark: formRemark.value.trim() || undefined,
+      paperMaterials: paperRows.value.map((p) => ({
+        paperName: p.paperName.trim(),
+        copies: Number(p.copies),
+        attachmentUrl: p.attachments.map((a) => a.attachmentUrl).join(',')
+      })),
+      cells: cellRows.value.map((c) => ({
+        cellProductId: String(c.cellProductId || ''),
+        remark: c.remark
+      })),
+      qcMaterials: materialRows.value.map((m) => ({
+        materialId: m.materialId,
+        qty: Number(m.qty),
+        remark: m.remark
+      }))
+    }
+  }
+
+  onMounted(() => {
+    ensureMaterialOptions()
+  })
+
+  function resetState() {
+    cellRows.value = []
+    materialRows.value = []
+    paperRows.value = []
+    formRemark.value = ''
+    cellSelectVisible.value = false
+  }
+
+  function handlePreviewPaperAttachment(att: PaperAttachment) {
+    if (!att.attachmentUrl) return
+    previewUrl.value = fullFileUrl(att.attachmentUrl)
+    previewType.value = isVideoFile(att) ? 'video' : 'image'
+    previewVisible.value = true
+  }
+
+  function handleDownloadPaperAttachment(att: PaperAttachment) {
+    if (!att.attachmentUrl) return
+    downloadAttachmentFile(
+      {
+        fileName: att.fileName,
+        fileUrl: att.attachmentUrl,
+        fileType: att.fileType || ''
+      },
+      { fullFileUrl }
+    )
+  }
+
+  async function handlePaperAttachmentChange(
+    index: number,
+    uploadFile: { raw?: File }
+  ) {
+    const file = uploadFile.raw
+    const row = paperRows.value[index]
+    if (!file || !row || row._uploading) return
+    row._uploading = true
+    try {
+      const fd = new FormData()
+      fd.append('folder', 'outbound-release-material')
+      fd.append('file', file)
+      const res = await fetchUploadAttachment(fd)
+      row.attachments.push({
+        attachmentUrl: res.fileUrl,
+        fileName: file.name || res.fileName || attachmentFileName(res.fileUrl),
+        fileType: res.fileType || file.type || ''
+      })
+    } catch {
+      ElMessage.error('文件上传失败')
+    } finally {
+      row._uploading = false
+    }
+  }
+
+  defineExpose({
+    loading,
+    loadFromApiData,
+    resetState,
+    validateMaterial,
+    buildSavePayload,
+    ensureMaterialOptions
+  })
+</script>
+
+<style scoped lang="scss">
+  .material-summary {
+    margin-bottom: 16px;
+    padding: 12px 16px;
+    background: #f8fafc;
+    border-radius: 8px;
+    border: 1px solid #e2e8f0;
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    gap: 8px 20px;
+  }
+
+  .material-summary-row {
+    display: flex;
+    gap: 8px;
+    font-size: 13px;
+  }
+
+  .material-summary-label {
+    color: #64748b;
+    flex-shrink: 0;
+  }
+
+  .material-summary-value {
+    color: #1e293b;
+    font-weight: 500;
+  }
+
+  .material-sections {
+    display: flex;
+    flex-direction: column;
+    gap: 20px;
+  }
+
+  .section-card {
+    border: 1px solid #e2e8f0;
+    border-radius: 8px;
+    padding: 12px 16px 16px;
+    background: #fff;
+  }
+
+  .section-head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 12px;
+    gap: 12px;
+  }
+
+  .section-title {
+    margin: 0;
+    font-size: 14px;
+    font-weight: 600;
+    color: #1e293b;
+  }
+
+  .section-empty-hint {
+    margin: 0;
+    font-size: 13px;
+    color: #94a3b8;
+  }
+
+  .inner-cell-input {
+    width: 100%;
+  }
+
+  .paper-att-cards {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+  }
+
+  .paper-att-card {
+    width: 100px;
+    height: 72px;
+    border: 1px solid #e2e8f0;
+    border-radius: 6px;
+    background: #f8fafc;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    padding: 6px 4px;
+    position: relative;
+    overflow: hidden;
+  }
+
+  .paper-att-card__icon {
+    color: #0284c7;
+    font-size: 18px;
+    margin-bottom: 2px;
+  }
+
+  .paper-att-card__name {
+    width: 100%;
+    font-size: 11px;
+    color: #334155;
+    text-align: center;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    line-height: 1.2;
+  }
+
+  .paper-att-card__actions {
+    position: absolute;
+    top: 2px;
+    right: 2px;
+    display: flex;
+    gap: 2px;
+    opacity: 0;
+    transition: opacity 0.15s;
+  }
+
+  .paper-att-card:hover .paper-att-card__actions {
+    opacity: 1;
+  }
+
+  .paper-att-card__action {
+    width: 18px;
+    height: 18px;
+    border-radius: 3px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 10px;
+    color: #fff;
+    background: rgba(0, 0, 0, 0.35);
+    cursor: pointer;
+
+    &:hover {
+      background: #10b981;
+    }
+
+    &--danger:hover {
+      background: #ef4444;
+    }
+  }
+
+  .paper-att-card--trigger {
+    border: 1px dashed #a7f3d0;
+    background: transparent;
+    cursor: pointer;
+    transition: border-color 0.2s, background 0.2s;
+
+    &:hover {
+      border-color: #10b981;
+      background: #ecfdf5;
+    }
+
+    &.is-loading {
+      pointer-events: none;
+      opacity: 0.6;
+    }
+  }
+
+  .paper-att-card__trigger-icon {
+    font-size: 16px;
+    color: #10b981;
+    margin-bottom: 2px;
+  }
+
+  .paper-att-card--trigger span {
+    font-size: 11px;
+    color: #64748b;
+  }
+
+  .preview-container {
+    text-align: center;
+  }
+</style>

+ 6 - 3
web-admin/src/components/business/qc-task-view-dialog/index.vue

@@ -103,6 +103,8 @@
   const props = defineProps<{
     visible: boolean
     taskId?: string
+    /** 不展示样本信息、运输信息、样本接收(出库放行审核等) */
+    hideSampleContext?: boolean
   }>()
 
   const emit = defineEmits<{
@@ -265,9 +267,10 @@
           }
         ]
       : []
-    const sampleSections: ViewSection[] = isTempQc
-      ? []
-      : [
+    const sampleSections: ViewSection[] =
+      props.hideSampleContext || isTempQc
+        ? []
+        : [
           {
             title: '样本信息',
             fields: [

+ 7 - 1
web-admin/src/components/business/sample-qc-info-cards/index.vue

@@ -84,7 +84,11 @@
     </template>
 
     <Teleport to="body">
-      <QcTaskViewDialog v-model:visible="taskViewVisible" :task-id="taskViewId" />
+      <QcTaskViewDialog
+        v-model:visible="taskViewVisible"
+        :task-id="taskViewId"
+        :hide-sample-context="hideSampleContext"
+      />
     </Teleport>
   </div>
 </template>
@@ -144,6 +148,8 @@
   const props = defineProps<{
     qcInfo?: SampleQcInfo | null
     sampleReception?: SampleReceptionBrief | null
+    /** 任务详情弹窗不展示样本信息、样本接收(出库放行等场景) */
+    hideSampleContext?: boolean
   }>()
 
   const taskViewVisible = ref(false)

+ 9 - 2
web-admin/src/components/business/system-view-dialog/index.vue

@@ -45,12 +45,12 @@
             <dt>{{ field.label }}</dt>
             <dd>
               <el-tag v-if="field.tagType" :type="field.tagType" size="small">{{
-                field.value ?? '—'
+                formatViewValue(field.value)
               }}</el-tag>
               <ul v-else-if="field.items?.length" class="view-plain-list">
                 <li v-for="(item, idx) in field.items" :key="idx">{{ item }}</li>
               </ul>
-              <template v-else>{{ field.value ?? '—' }}</template>
+              <template v-else>{{ formatViewValue(field.value) }}</template>
             </dd>
           </div>
         </dl>
@@ -84,6 +84,13 @@
     return val != null && (typeof val === 'object' || typeof val === 'function')
   }
 
+  /** 查看弹窗字段展示:null/undefined/空字符串 → —;布尔转是/否 */
+  function formatViewValue(val: unknown): string {
+    if (val === null || val === undefined || val === '') return '—'
+    if (typeof val === 'boolean') return val ? '是' : '否'
+    return String(val)
+  }
+
   export type TagType = 'success' | 'warning' | 'danger' | 'info' | '' | 'primary'
 
   export interface ViewField {

ファイルの差分が大きいため隠しています
+ 0 - 0
web-admin/src/graphify-out/cache/ast/0edd9b6b6b1cec54db798cae4810f2b31cbef01029a6c5003e9ffa249d786ef2.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/13cd88a45016eeced320861b0fe9d858da3cd6ab84c57a16bd7de07f0d4f4808.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_components_business_outbound_material_form_sections_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/outbound-material-form-sections/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

ファイルの差分が大きいため隠しています
+ 0 - 0
web-admin/src/graphify-out/cache/ast/2588828b1cfdc37a1853a55ef18845e88c224e3c4a4a7be211ef6af2dcb3e750.json


ファイルの差分が大きいため隠しています
+ 0 - 0
web-admin/src/graphify-out/cache/ast/3625267039d2410cddccecf83814ece9a0127351267bd142de4a223fab91d0ca.json


ファイルの差分が大きいため隠しています
+ 0 - 0
web-admin/src/graphify-out/cache/ast/3d7d8ea1a7392766f77531d4f9b51064ed8b397f3381c862516cf35b7bd92b41.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/45e753a089a9efe57113d34da758451e33c25645912fcb67e93081c69cb9e77e.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_components_business_sample_qc_info_cards_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/sample-qc-info-cards/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

ファイルの差分が大きいため隠しています
+ 0 - 0
web-admin/src/graphify-out/cache/ast/592ffa0a02e1eb34982e64a304455bf8b03188dd5441cb745ce23f0e0de88605.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/66b02e04f2a671a74142e5301c5e90b898bd7d51d0207b3682c5fc3f21049bba.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_components_business_outbound_material_form_sections_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/outbound-material-form-sections/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/7899d5657daccc895a3538887050d2933c855f8d9350e3f2c0c454c929d4aa4b.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_outbound_release_review_outboundreleasematerialdialog_vue", "label": "OutboundReleaseMaterialDialog.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/outbound-release-review/OutboundReleaseMaterialDialog.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/8116c7788caa36899de1a6bc4b7e75723af2a4dfc05c21e233c437010297fb15.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_outbound_release_review_outboundreleaseviewdialog_vue", "label": "OutboundReleaseViewDialog.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/outbound-release-review/OutboundReleaseViewDialog.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/82c7e35f3f542c9c66108690750b032ab2dce4fbb479c315b13e6c9cc9fe75a5.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_infusion_schedule_management_scheduleviewdialog_vue", "label": "ScheduleViewDialog.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/infusion-schedule-management/ScheduleViewDialog.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/9a6ab390efc7b37afa9440f34291e24a681626306c97d9c4b9c1ab31f92e5ffe.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_types_import_components_d_ts", "label": "components.d.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/9f313beb742ef8dc3d5df59f5e69d4e5843b8298a1607d0f0dd11a9e3dadf022.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_router_modules_sqxp_ts", "label": "sqxp.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/sqxp.ts", "source_location": "L1"}], "edges": [{"source": "users_wuzilong_work_00code_zkxw_web_admin_src_router_modules_sqxp_ts", "target": "users_wuzilong_work_00code_zkxw_web_admin_src_types_router", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/sqxp.ts", "source_location": "L1", "weight": 1.0}], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/a42b32dfa1d5d691c120e92c585342c55203d82af0ff97db0d60779b585c27ac.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_outbound_release_review_outboundreleasematerialdialog_vue", "label": "OutboundReleaseMaterialDialog.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/outbound-release-review/OutboundReleaseMaterialDialog.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/aa71e74f935c5d8f33d9ee4d060f1d367d754e3dec8634fcf2f1d487099e981c.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_components_business_infusion_schedule_outbound_chain_view_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/infusion-schedule-outbound-chain-view/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/afdc060cb85e6032e03741e4b993e1b71d64edea4d0313157148f41342875469.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_types_import_components_d_ts", "label": "components.d.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts", "source_location": "L1"}], "edges": [], "raw_calls": []}

ファイルの差分が大きいため隠しています
+ 0 - 0
web-admin/src/graphify-out/cache/ast/b07af941e6127670464402c8c0ef1c02234c698e52a17acf5896c49c79b17152.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/b08866dd4d5a36b03f33dc45bae64a9dbd7da511083f2d92cc9743c6aa9c4748.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_types_import_components_d_ts", "label": "components.d.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts", "source_location": "L1"}], "edges": [], "raw_calls": []}

ファイルの差分が大きいため隠しています
+ 0 - 0
web-admin/src/graphify-out/cache/ast/b42860a3b47456bb11251f3a163297982d551d72826ee4b7a2c931ef7d562b28.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/b844be3aee52e3c7e231da342c98f7620782af72bc80ccabbb205899972966f5.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_components_business_system_view_dialog_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/system-view-dialog/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

ファイルの差分が大きいため隠しています
+ 0 - 0
web-admin/src/graphify-out/cache/ast/bf3b2c12cbb2deffec550e9007e2417a3e7b5271a24313b3c2e0118d443414f3.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/d5e94ea93839553f8e45318241fa5556c97985fb829d4ca122971f7d3c449bcd.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_pilot_application_applicationviewdialog_vue", "label": "ApplicationViewDialog.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/pilot-application/ApplicationViewDialog.vue", "source_location": "L1"}, {"id": "pilot_application_applicationviewdialog_for", "label": "for()", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/pilot-application/ApplicationViewDialog.vue", "source_location": "L951"}], "edges": [{"source": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_pilot_application_applicationviewdialog_vue", "target": "pilot_application_applicationviewdialog_for", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/pilot-application/ApplicationViewDialog.vue", "source_location": "L951", "weight": 1.0}], "raw_calls": [{"caller_nid": "pilot_application_applicationviewdialog_for", "callee": "trim", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/pilot-application/ApplicationViewDialog.vue", "source_location": "L952"}, {"caller_nid": "pilot_application_applicationviewdialog_for", "callee": "Number", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/pilot-application/ApplicationViewDialog.vue", "source_location": "L955"}, {"caller_nid": "pilot_application_applicationviewdialog_for", "callee": "get", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/pilot-application/ApplicationViewDialog.vue", "source_location": "L956"}, {"caller_nid": "pilot_application_applicationviewdialog_for", "callee": "set", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/pilot-application/ApplicationViewDialog.vue", "source_location": "L958"}]}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/dba0a6d9c7b180a231671f2fb6d8006d5013ef805c03fd742121bccbf349fa69.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_outbound_release_review_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/outbound-release-review/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

ファイルの差分が大きいため隠しています
+ 0 - 0
web-admin/src/graphify-out/cache/ast/dca657caf1c62d2355196f294efcd6022bce2cd7837c9850c6a7cec31c4fe679.json


この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません