zlwu 5 mesi fa
parent
commit
b0eb80a2d4
37 ha cambiato i file con 1906 aggiunte e 1472 eliminazioni
  1. 1 1
      backend/app.pid
  2. 18 0
      backend/app/api/production.py
  3. 13 7
      backend/app/crud/process_route.py
  4. 12 12
      backend/app/crud/production_task.py
  5. 0 0
      backend/app/graphify-out/cache/ast/5fa24454ca2b9504fb73e0437bbfebd1f39736002678da5ebacec1f38eca550d.json
  6. 0 0
      backend/app/graphify-out/cache/ast/8175a2e7af3064222f2060c181a4697a20b07a27a338e5b07fae5b5bb535b569.json
  7. 0 0
      backend/app/graphify-out/cache/ast/d159d2efa78362dc7274da3defbeb8fcc4c4b05835df638b1316fef1c9de7563.json
  8. 0 0
      backend/app/graphify-out/cache/ast/d364ca11719ff50a4c15023c86beede463625033cc6c667dab2ff934cb1d96a4.json
  9. 0 0
      backend/app/graphify-out/cache/ast/fb5e64c48abbe0d5aa7b26dc321112c93f135ddc3443734510f02984575271b4.json
  10. 0 0
      backend/app/graphify-out/cache/ast/fb6a4929819b8dc0f338a441e5ae6136b5f4b7e65a774b648ae8806da2def203.json
  11. 3 0
      backend/app/schemas/process_route.py
  12. 580 339
      backend/graphify-out/.graphify_analysis.json
  13. 167 125
      backend/graphify-out/.graphify_extract.json
  14. 109 99
      backend/graphify-out/GRAPH_REPORT.md
  15. 1 1
      backend/graphify-out/graph.html
  16. 112 112
      backend/graphify-out/graph.json
  17. 6 6
      backend/graphify-out/manifest.json
  18. 410 399
      web-admin/graphify-out/.graphify_analysis.json
  19. 149 215
      web-admin/graphify-out/.graphify_extract.json
  20. 45 52
      web-admin/graphify-out/GRAPH_REPORT.md
  21. 1 1
      web-admin/graphify-out/graph.html
  22. 10 7
      web-admin/graphify-out/manifest.json
  23. 4 4
      web-admin/src/api/production-task.ts
  24. 1 0
      web-admin/src/graphify-out/cache/ast/1df4fed3c7a91a30bef30cc441e14eab194c8a9db940047f9a860e5d592e38fb.json
  25. 1 0
      web-admin/src/graphify-out/cache/ast/2cb07643eb5336ad59d67390e53d7cc0e95c2939af7a1ea347bcfa4251f0d261.json
  26. 1 0
      web-admin/src/graphify-out/cache/ast/2f810c0fde9537f4a0240f12bcb2fe016b8ba38b230aa81756ce2f69bdd54897.json
  27. 1 0
      web-admin/src/graphify-out/cache/ast/393904aaa318c9fedb45622135c2ad44e198e79e92095bd079ab2555c8b519b9.json
  28. 0 0
      web-admin/src/graphify-out/cache/ast/5107c337d946ea2cac20cfddf419260605ab9656390377f8770b315020e17029.json
  29. 1 0
      web-admin/src/graphify-out/cache/ast/9cd15a4ee5b6c7ebd3da16b2fef9f90a9d7fb6c8c6a97980fc6e2a5c268b4b40.json
  30. 0 0
      web-admin/src/graphify-out/cache/ast/9f5771c5a73698669f23a0d2eb95df12ebe14723da02dbae1f454e3eb88aa0d1.json
  31. 0 0
      web-admin/src/graphify-out/cache/ast/af018cbe895cb8652c9a71972f1a29052c627557edff959e50401ee4791a1590.json
  32. 1 0
      web-admin/src/graphify-out/cache/ast/d564e26ef51c00113d1860117ccd3d1a0d99300f9ae5cdd36ec54990cbf7f7b9.json
  33. 7 0
      web-admin/src/styles/_route-config.scss
  34. 132 15
      web-admin/src/views/production/process-route/index.vue
  35. 32 31
      web-admin/src/views/production/production-work-order/StepDetailPanel.vue
  36. 1 0
      web-admin/src/views/production/production-work-order/index.vue
  37. 87 46
      web-admin/src/views/production/production-work-order/useTaskStepEditor.ts

+ 1 - 1
backend/app.pid

@@ -1 +1 @@
-81932
+28319

+ 18 - 0
backend/app/api/production.py

@@ -40,6 +40,7 @@ from app.schemas.production_task import (
     TaskStepAdd,
     TaskStepUpdate,
     TaskStepMaterialItem,
+    TaskStepOutputItem,
     TaskStepDataCollectionItem,
     TaskInputItem,
     TaskOutputItem,
@@ -1544,6 +1545,23 @@ async def replace_task_step_data_collections(
         return error_response(400, str(e))
 
 
+@router.put("/production-tasks/{task_id}/steps/{step_id}/outputs")
+async def replace_task_step_outputs(
+    task_id: str,
+    step_id: str,
+    body: List[TaskStepOutputItem],
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        items_data = [it.model_dump(by_alias=False) for it in body]
+        production_task_svc.replace_step_outputs(db, step_id, tenant_no, user.get("user_id"), items_data)
+        return success_response(None, message="更新成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
 @router.post("/production-tasks/{task_id}/inputs")
 async def set_production_task_inputs(
     task_id: str,

+ 13 - 7
backend/app/crud/process_route.py

@@ -71,6 +71,7 @@ def _step_reagent_row_to_dict(r: BizProcessRouteStepReagent) -> Dict[str, Any]:
         "standardUsage": r.standard_usage or "",
         "unit": r.unit or "",
         "isKeyMaterial": r.is_key_material if r.is_key_material is not None else "0",
+        "orderNum": r.order_num if r.order_num is not None else 0,
         "remark": r.remark or "",
     }
 
@@ -85,6 +86,7 @@ def _step_consumable_row_to_dict(c: BizProcessRouteStepConsumable) -> Dict[str,
         "standardUsage": c.standard_usage or "",
         "unit": c.unit or "",
         "isRequired": c.is_required if c.is_required is not None else "1",
+        "orderNum": c.order_num if c.order_num is not None else 0,
         "remark": c.remark or "",
     }
 
@@ -96,6 +98,7 @@ def _step_output_row_to_dict(o: BizProcessRouteStepOutput) -> Dict[str, Any]:
         "outputTypeId": o.output_type_id or "",
         "outputName": o.output_name or "",
         "outputBarcode": o.output_barcode or "",
+        "orderNum": o.order_num if o.order_num is not None else 0,
         "remark": o.remark or "",
     }
 
@@ -174,16 +177,16 @@ def get_route_detail(db: Session, route_id: str) -> Optional[Dict[str, Any]]:
         sd = _step_row_to_dict(s)
         reagents = db.query(BizProcessRouteStepReagent).filter(
             and_(BizProcessRouteStepReagent.step_id == s.id, BizProcessRouteStepReagent.del_flag == 0)
-        ).order_by(BizProcessRouteStepReagent.create_time.asc()).all()
+        ).order_by(BizProcessRouteStepReagent.order_num.asc(), BizProcessRouteStepReagent.create_time.asc()).all()
         consumables = db.query(BizProcessRouteStepConsumable).filter(
             and_(BizProcessRouteStepConsumable.step_id == s.id, BizProcessRouteStepConsumable.del_flag == 0)
-        ).order_by(BizProcessRouteStepConsumable.create_time.asc()).all()
+        ).order_by(BizProcessRouteStepConsumable.order_num.asc(), BizProcessRouteStepConsumable.create_time.asc()).all()
         outputs = db.query(BizProcessRouteStepOutput).filter(
             and_(BizProcessRouteStepOutput.step_id == s.id, BizProcessRouteStepOutput.del_flag == 0)
-        ).order_by(BizProcessRouteStepOutput.create_time.asc()).all()
+        ).order_by(BizProcessRouteStepOutput.order_num.asc(), BizProcessRouteStepOutput.create_time.asc()).all()
         data_collections = db.query(BizProcessRouteStepDataCollection).filter(
             and_(BizProcessRouteStepDataCollection.step_id == s.id, BizProcessRouteStepDataCollection.del_flag == 0)
-        ).order_by(BizProcessRouteStepDataCollection.order_num.asc()).all()
+        ).order_by(BizProcessRouteStepDataCollection.order_num.asc(), BizProcessRouteStepDataCollection.create_time.asc()).all()
 
         sd["reagents"] = [_step_reagent_row_to_dict(r) for r in reagents]
         sd["consumables"] = [_step_consumable_row_to_dict(c) for c in consumables]
@@ -288,7 +291,8 @@ def _create_step_reagent(db, step_id, tenant_no, user_id, now, data):
         unit=(data.get("unit") or "").strip() or None,
         is_key_material=data.get("is_key_material", "0"),
         remark=(data.get("remark") or "").strip() or None,
-        order_num=0, create_by=user_id, create_dept_id="",
+        order_num=data.get("order_num", 0) or 0,
+        create_by=user_id, create_dept_id="",
         create_time=now, update_by=user_id, update_time=now, del_flag=0,
     ))
 
@@ -303,7 +307,8 @@ def _create_step_consumable(db, step_id, tenant_no, user_id, now, data):
         unit=(data.get("unit") or "").strip() or None,
         is_required=data.get("is_required", "1"),
         remark=(data.get("remark") or "").strip() or None,
-        order_num=0, create_by=user_id, create_dept_id="",
+        order_num=data.get("order_num", 0) or 0,
+        create_by=user_id, create_dept_id="",
         create_time=now, update_by=user_id, update_time=now, del_flag=0,
     ))
 
@@ -315,7 +320,8 @@ def _create_step_output(db, step_id, tenant_no, user_id, now, data):
         output_name=(data.get("output_name") or "").strip() or None,
         output_barcode=(data.get("output_barcode") or "").strip() or None,
         remark=(data.get("remark") or "").strip() or None,
-        order_num=0, create_by=user_id, create_dept_id="",
+        order_num=data.get("order_num", 0) or 0,
+        create_by=user_id, create_dept_id="",
         create_time=now, update_by=user_id, update_time=now, del_flag=0,
     ))
 

+ 12 - 12
backend/app/crud/production_task.py

@@ -388,16 +388,16 @@ def get_production_task_detail(db: Session, task_id: str, tenant_no: str) -> Opt
         sd = _step_to_dict(s)
         reagents = db.query(BizProductionTaskStepReagent).filter(
             and_(BizProductionTaskStepReagent.step_id == s.id, BizProductionTaskStepReagent.del_flag == 0)
-        ).order_by(BizProductionTaskStepReagent.create_time.asc()).all()
+        ).order_by(BizProductionTaskStepReagent.order_num.asc(), BizProductionTaskStepReagent.create_time.asc()).all()
         consumables = db.query(BizProductionTaskStepConsumable).filter(
             and_(BizProductionTaskStepConsumable.step_id == s.id, BizProductionTaskStepConsumable.del_flag == 0)
-        ).order_by(BizProductionTaskStepConsumable.create_time.asc()).all()
+        ).order_by(BizProductionTaskStepConsumable.order_num.asc(), BizProductionTaskStepConsumable.create_time.asc()).all()
         outputs = db.query(BizProductionTaskStepOutput).filter(
             and_(BizProductionTaskStepOutput.step_id == s.id, BizProductionTaskStepOutput.del_flag == 0)
-        ).order_by(BizProductionTaskStepOutput.create_time.asc()).all()
+        ).order_by(BizProductionTaskStepOutput.order_num.asc(), BizProductionTaskStepOutput.create_time.asc()).all()
         data_collections = db.query(BizProductionTaskStepDataCollection).filter(
             and_(BizProductionTaskStepDataCollection.step_id == s.id, BizProductionTaskStepDataCollection.del_flag == 0)
-        ).order_by(BizProductionTaskStepDataCollection.order_num.asc()).all()
+        ).order_by(BizProductionTaskStepDataCollection.order_num.asc(), BizProductionTaskStepDataCollection.create_time.asc()).all()
 
         sd["reagents"] = [_step_reagent_to_dict(r) for r in reagents]
         sd["consumables"] = [_step_consumable_to_dict(c) for c in consumables]
@@ -581,7 +581,7 @@ def replace_step_reagents(
 ) -> None:
     now = datetime.now()
     _soft_delete_sub(db, BizProductionTaskStepReagent, "step_id", step_id, now)
-    for it in items:
+    for idx, it in enumerate(items):
         db.add(BizProductionTaskStepReagent(
             id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
             reagent_code=(it.get("reagent_code") or "").strip() or None,
@@ -592,7 +592,7 @@ def replace_step_reagents(
             unit=(it.get("unit") or "").strip() or None,
             is_key_material=it.get("is_key_material", "0"),
             remark=(it.get("remark") or "").strip() or None,
-            order_num=0, create_by=user_id, create_dept_id="",
+            order_num=idx, create_by=user_id, create_dept_id="",
             create_time=now, update_by=user_id, update_time=now, del_flag=0,
         ))
     db.commit()
@@ -604,7 +604,7 @@ def replace_step_consumables(
 ) -> None:
     now = datetime.now()
     _soft_delete_sub(db, BizProductionTaskStepConsumable, "step_id", step_id, now)
-    for it in items:
+    for idx, it in enumerate(items):
         db.add(BizProductionTaskStepConsumable(
             id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
             consumable_code=(it.get("consumable_code") or "").strip() or None,
@@ -615,7 +615,7 @@ def replace_step_consumables(
             unit=(it.get("unit") or "").strip() or None,
             is_required=it.get("is_required", "1"),
             remark=(it.get("remark") or "").strip() or None,
-            order_num=0, create_by=user_id, create_dept_id="",
+            order_num=idx, create_by=user_id, create_dept_id="",
             create_time=now, update_by=user_id, update_time=now, del_flag=0,
         ))
     db.commit()
@@ -627,14 +627,14 @@ def replace_step_outputs(
 ) -> None:
     now = datetime.now()
     _soft_delete_sub(db, BizProductionTaskStepOutput, "step_id", step_id, now)
-    for it in items:
+    for idx, it in enumerate(items):
         db.add(BizProductionTaskStepOutput(
             id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
             output_type_id=(it.get("output_type_id") or "").strip() or None,
             output_name=(it.get("output_name") or "").strip() or None,
             output_barcode=(it.get("output_barcode") or "").strip() or None,
             remark=(it.get("remark") or "").strip() or None,
-            order_num=0, create_by=user_id, create_dept_id="",
+            order_num=idx, create_by=user_id, create_dept_id="",
             create_time=now, update_by=user_id, update_time=now, del_flag=0,
         ))
     db.commit()
@@ -646,14 +646,14 @@ def replace_step_data_collections(
 ) -> None:
     now = datetime.now()
     _soft_delete_sub(db, BizProductionTaskStepDataCollection, "step_id", step_id, now)
-    for it in items:
+    for idx, it in enumerate(items):
         db.add(BizProductionTaskStepDataCollection(
             id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
             collection_name=(it.get("collection_name") or "").strip() or None,
             unit=(it.get("unit") or "").strip() or None,
             collection_value=(it.get("collection_value") or "").strip() or None,
             remark=(it.get("remark") or "").strip() or None,
-            order_num=0, create_by=user_id, create_dept_id="",
+            order_num=idx, create_by=user_id, create_dept_id="",
             create_time=now, update_by=user_id, update_time=now, del_flag=0,
         ))
     db.commit()

File diff suppressed because it is too large
+ 0 - 0
backend/app/graphify-out/cache/ast/5fa24454ca2b9504fb73e0437bbfebd1f39736002678da5ebacec1f38eca550d.json


File diff suppressed because it is too large
+ 0 - 0
backend/app/graphify-out/cache/ast/8175a2e7af3064222f2060c181a4697a20b07a27a338e5b07fae5b5bb535b569.json


File diff suppressed because it is too large
+ 0 - 0
backend/app/graphify-out/cache/ast/d159d2efa78362dc7274da3defbeb8fcc4c4b05835df638b1316fef1c9de7563.json


File diff suppressed because it is too large
+ 0 - 0
backend/app/graphify-out/cache/ast/d364ca11719ff50a4c15023c86beede463625033cc6c667dab2ff934cb1d96a4.json


File diff suppressed because it is too large
+ 0 - 0
backend/app/graphify-out/cache/ast/fb5e64c48abbe0d5aa7b26dc321112c93f135ddc3443734510f02984575271b4.json


File diff suppressed because it is too large
+ 0 - 0
backend/app/graphify-out/cache/ast/fb6a4929819b8dc0f338a441e5ae6136b5f4b7e65a774b648ae8806da2def203.json


+ 3 - 0
backend/app/schemas/process_route.py

@@ -67,6 +67,7 @@ class StepReagentItem(BaseModel):
     standard_usage: Optional[str] = Field(None, alias="standardUsage")
     unit: Optional[str] = None
     is_key_material: Optional[str] = Field("0", alias="isKeyMaterial")
+    order_num: Optional[int] = Field(0, alias="orderNum")
     remark: Optional[str] = None
 
 
@@ -78,6 +79,7 @@ class StepConsumableItem(BaseModel):
     standard_usage: Optional[str] = Field(None, alias="standardUsage")
     unit: Optional[str] = None
     is_required: Optional[str] = Field("1", alias="isRequired")
+    order_num: Optional[int] = Field(0, alias="orderNum")
     remark: Optional[str] = None
 
 
@@ -86,6 +88,7 @@ class StepOutputItem(BaseModel):
     output_type_id: Optional[str] = Field(None, alias="outputTypeId")
     output_name: Optional[str] = Field(None, alias="outputName")
     output_barcode: Optional[str] = Field(None, alias="outputBarcode")
+    order_num: Optional[int] = Field(0, alias="orderNum")
     remark: Optional[str] = None
 
 

File diff suppressed because it is too large
+ 580 - 339
backend/graphify-out/.graphify_analysis.json


File diff suppressed because it is too large
+ 167 - 125
backend/graphify-out/.graphify_extract.json


+ 109 - 99
backend/graphify-out/GRAPH_REPORT.md

@@ -1,12 +1,12 @@
 # Graph Report - app  (2026-05-21)
 
 ## Corpus Check
-- 135 files · ~119,444 words
+- 135 files · ~119,855 words
 - Verdict: corpus is large enough that graph structure adds value.
 
 ## Summary
-- 2404 nodes · 9361 edges · 48 communities detected
-- Extraction: 42% EXTRACTED · 58% INFERRED · 0% AMBIGUOUS · INFERRED: 5416 edges (avg confidence: 0.64)
+- 2410 nodes · 9421 edges · 50 communities detected
+- Extraction: 42% EXTRACTED · 58% INFERRED · 0% AMBIGUOUS · INFERRED: 5467 edges (avg confidence: 0.63)
 - Token cost: 0 input · 0 output
 
 ## Community Hubs (Navigation)
@@ -52,16 +52,18 @@
 - [[_COMMUNITY_Community 39|Community 39]]
 - [[_COMMUNITY_Community 40|Community 40]]
 - [[_COMMUNITY_Community 41|Community 41]]
-- [[_COMMUNITY_Community 44|Community 44]]
-- [[_COMMUNITY_Community 45|Community 45]]
+- [[_COMMUNITY_Community 42|Community 42]]
+- [[_COMMUNITY_Community 43|Community 43]]
 - [[_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]]
 
 ## God Nodes (most connected - your core abstractions)
-1. `success_response()` - 377 edges
-2. `error_response()` - 302 edges
+1. `success_response()` - 379 edges
+2. `error_response()` - 304 edges
 3. `SysUser` - 217 edges
 4. `BizQcMaterialUsage` - 197 edges
 5. `BizMaterialStockLog` - 193 edges
@@ -69,7 +71,7 @@
 7. `BizQcMaterial` - 193 edges
 8. `BizMaterial` - 190 edges
 9. `BizQcItemParam` - 188 edges
-10. `generate_uuid()` - 130 edges
+10. `generate_uuid()` - 131 edges
 
 ## Surprising Connections (you probably didn't know these)
 - `统一记录 API 请求/响应出入参的中间件` --uses--> `SysApiRequestLog`  [INFERRED]
@@ -87,7 +89,7 @@
 
 ### Community 0 - "Community 0"
 Cohesion: 0.01
-Nodes (380): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+372 more)
+Nodes (378): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+370 more)
 
 ### Community 1 - "Community 1"
 Cohesion: 0.02
@@ -95,115 +97,115 @@ Nodes (387): attachment_download_url(), create_dept(), create_dict_data(), creat
 
 ### Community 2 - "Community 2"
 Cohesion: 0.06
-Nodes (258): AuditCreateBody, BatchDetailIdsBody, BatchRefundFinanceSubmitBody, FinanceConfirmBody, FullmoonTodoInsuranceBody, ProtocolCreateBody, ProtocolUpdateBody, 细胞存储 — 采集协议单 / 审核 / 财务确认 / 分期预警 API (+250 more)
+Nodes (216): InsuranceCompanyCreateBody, InsuranceCompanyInvoiceCreateBody, InsuranceCompanyInvoiceUpdateBody, InsuranceCompanyUpdateBody, InsuranceProjectPriceCreateBody, InsuranceProjectPriceUpdateBody, 保险公司及险种价格管理 API(保险公司 + 开票信息 + 险种价格), ProcessDefCreateBody (+208 more)
 
 ### Community 3 - "Community 3"
 Cohesion: 0.02
-Nodes (207): generate_uuid(), delete(), get_multi_with_filters(), create_collection_region(), create_hospital(), get_collection_region(), get_hospital(), _hospital_names_by_region_ids() (+199 more)
+Nodes (181): generate_uuid(), create_consumable_pack(), get_consumable_pack(), get_pack_detail(), _item_row_to_dict(), list_all_packs(), list_consumable_packs(), pack_code_exists() (+173 more)
 
 ### Community 4 - "Community 4"
-Cohesion: 0.04
-Nodes (118): 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() (+110 more)
+Cohesion: 0.06
+Nodes (170): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+162 more)
 
 ### Community 5 - "Community 5"
 Cohesion: 0.03
-Nodes (69): get_designer_data(), ProcessDefCreateBody, ProcessDefUpdateBody, PublishVersionBody, 审批意见:前端传 commentText,兼容 comment。, StartInstanceBody, TaskActionBody, BaseStorage (+61 more)
+Nodes (131): list_cell_sample_types(), list_collection_regions(), list_customers(), list_protocol_nos(), list_sample_codes(), _get_tenant_no(), list_collection_protocol_audits(), list_collection_protocols() (+123 more)
 
 ### Community 6 - "Community 6"
-Cohesion: 0.05
-Nodes (88): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, 运营支撑 API(洁净室、进出记录、仓储设施、储位、展厅预约), StorageFacilityCreateBody (+80 more)
+Cohesion: 0.03
+Nodes (97): list_roles_for_select(), _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)。 (+89 more)
 
 ### Community 7 - "Community 7"
-Cohesion: 0.05
-Nodes (86): _format_attachment_file_size(), urls_to_attachment_list(), _accept_conclusion_label(), _attach_reception_detail(), complete_production_work_order(), create_production_work_order_from_reception(), _enrich_wo_items(), _fmt_dt() (+78 more)
+Cohesion: 0.03
+Nodes (54): get_designer_data(), BaseStorage, convert_heic_to_jpeg(), get_storage(), LocalStorage, MinIOStorage, OSSStorage, process_image_content() (+46 more)
 
 ### Community 8 - "Community 8"
-Cohesion: 0.04
-Nodes (56): ApiRequestLogMiddleware, _claims_from_auth(), _format_body_bytes(), _persist_log(), 统一记录 API 请求/响应出入参的中间件, _redact_obj(), _schedule_persist_log(), _should_skip() (+48 more)
+Cohesion: 0.05
+Nodes (91): _format_attachment_file_size(), urls_to_attachment_list(), _accept_conclusion_label(), _attach_reception_detail(), complete_production_work_order(), create_production_work_order_from_reception(), _enrich_wo_items(), _fmt_dt() (+83 more)
 
 ### Community 9 - "Community 9"
-Cohesion: 0.04
-Nodes (63): list_roles_for_select(), _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)。 (+55 more)
+Cohesion: 0.07
+Nodes (67): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, list_storage_slots(), 运营支撑 API(洁净室、进出记录、仓储设施、储位、展厅预约) (+59 more)
 
 ### Community 10 - "Community 10"
-Cohesion: 0.26
-Nodes (57): ConsumableCreateBody, ConsumablePackCreateBody, ConsumablePackUpdateBody, ConsumableUpdateBody, OutputTypeCreateBody, OutputTypeUpdateBody, PackItemInput, ProcessCategoryCreateBody (+49 more)
+Cohesion: 0.32
+Nodes (51): ConsumableCreateBody, ConsumablePackCreateBody, ConsumablePackUpdateBody, ConsumableUpdateBody, OutputTypeCreateBody, OutputTypeUpdateBody, PackItemInput, ProcessCategoryCreateBody (+43 more)
 
 ### Community 11 - "Community 11"
-Cohesion: 0.09
-Nodes (57): batch_delete_research_donations(), change_donation_status(), convert_from_storage(), convert_to_storage(), create_research_donation(), delete_research_donation(), _get_current_user_id(), get_research_donation_detail() (+49 more)
+Cohesion: 0.08
+Nodes (36): BaseHTTPMiddleware, parse_admin_request_json(), 运营 Web(/web/admin)JSON 请求体解析。  与 ApiCryptoMiddleware 配合:中间件未改写 body 时,在路由内解密 `, 读取 JSON body;dict 时走 resolve_request_payload(明文 / 密文 / data 包装)。      Returns:, ApiCryptoError, _b64url_decode(), _b64url_encode(), decrypt_json() (+28 more)
 
 ### Community 12 - "Community 12"
-Cohesion: 0.16
-Nodes (32): _create_insurance_company(), _create_insurance_company_invoice(), _create_insurance_project_price(), delete_insurance_company(), delete_insurance_company_invoice(), delete_insurance_project_price(), _fmt_dt(), _generate_uuid() (+24 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)
 
-### Community 14 - "Community 14"
-Cohesion: 0.1
-Nodes (26): BaseHTTPMiddleware, parse_admin_request_json(), 运营 Web(/web/admin)JSON 请求体解析。  与 ApiCryptoMiddleware 配合:中间件未改写 body 时,在路由内解密 `, 读取 JSON body;dict 时走 resolve_request_payload(明文 / 密文 / data 包装)。      Returns:, ApiCryptoError, _b64url_decode(), _b64url_encode(), decrypt_json() (+18 more)
-
-### Community 15 - "Community 15"
+### Community 13 - "Community 13"
 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)
 
-### Community 16 - "Community 16"
+### Community 14 - "Community 14"
 Cohesion: 0.17
 Nodes (12): BusinessException, InvalidStatusTransitionException, log_delivery_operation(), log_operation(), log_order_operation(), log_settlement_operation(), log_user_operation(), OrderLockedException (+4 more)
 
+### Community 15 - "Community 15"
+Cohesion: 0.18
+Nodes (18): company_code_exists(), create_company(), get_company(), list_companies(), list_company_options(), _row_to_dict(), soft_delete_company(), update_company() (+10 more)
+
+### Community 16 - "Community 16"
+Cohesion: 0.18
+Nodes (17): get_next_cell_product_code(), _build_facility_path_label(), build_split_product_codes(), create_cell_product(), create_cell_products_batch(), create_cell_products_by_quantity(), delete_cell_product(), _enrich_product_dicts() (+9 more)
+
 ### Community 17 - "Community 17"
-Cohesion: 0.16
-Nodes (15): 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() (+7 more)
+Cohesion: 0.21
+Nodes (15): batch_issue_protocol_nos(), batch_revoke_protocol_nos(), create_protocol_no(), enrich_issued_user_names(), get_protocol_no(), list_protocol_nos(), _parse_issued_time(), protocol_no_exists() (+7 more)
 
 ### Community 18 - "Community 18"
-Cohesion: 0.18
-Nodes (16): 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() (+8 more)
+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)
 
 ### Community 19 - "Community 19"
-Cohesion: 0.2
-Nodes (14): deduct_for_task(), _display_remark(), list_usages(), list_usages_by_task(), _parse_receiver_purpose_from_remark(), 查询质检任务关联的物料使用记录(task_complete)。, 撤销任务此前 task_complete 扣减,便于重新填报物料使用。, 兼容旧数据:领用人/用途曾拼接在 remark 中。 (+6 more)
-
-### Community 20 - "Community 20"
 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 20 - "Community 20"
+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 21 - "Community 21"
-Cohesion: 0.23
-Nodes (12): Config, ProductBase, ProductCategoryBase, ProductCategoryCreate, ProductCategoryResponse, ProductCreate, ProductResponse, ProductUnitBase (+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 22 - "Community 22"
-Cohesion: 0.29
-Nodes (11): batch_issue_sample_codes(), batch_revoke_sample_codes(), create_sample_code(), get_sample_code(), list_sample_codes(), _parse_issued_time(), _resolve_issued_user_display_name(), _row_to_dict() (+3 more)
+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 23 - "Community 23"
 Cohesion: 0.31
-Nodes (11): batch_issue_protocol_nos(), batch_revoke_protocol_nos(), create_protocol_no(), get_protocol_no(), list_protocol_nos(), _parse_issued_time(), protocol_no_exists(), _resolve_issued_user_display_name() (+3 more)
+Nodes (8): consumable_code_exists(), create_consumable(), get_consumable(), list_consumables(), 耗材 CRUD(biz_consumable), _row_to_dict(), soft_delete_consumable(), update_consumable()
 
 ### Community 24 - "Community 24"
-Cohesion: 0.27
-Nodes (11): _build_tree(), _cascade_soft_delete(), _collect_with_ancestors(), create_dept(), get_dept(), list_depts(), list_depts_tree(), 保留匹配节点及其全部上级,供关键词检索后仍能正确组树。 (+3 more)
+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 25 - "Community 25"
-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)
+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 26 - "Community 26"
-Cohesion: 0.31
-Nodes (8): consumable_code_exists(), create_consumable(), get_consumable(), list_consumables(), 耗材 CRUD(biz_consumable), _row_to_dict(), soft_delete_consumable(), update_consumable()
+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 27 - "Community 27"
 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()
+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 28 - "Community 28"
-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)
+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 29 - "Community 29"
 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()
+Nodes (8): create_customer(), get_customer(), id_card_taken(), list_customers(), 客户 CRUD(biz_customer), _row_to_dict(), soft_delete_customer(), update_customer()
 
 ### Community 30 - "Community 30"
 Cohesion: 0.31
@@ -215,108 +217,116 @@ Nodes (8): create_output_type(), get_output_type(), list_output_types(), 工艺
 
 ### 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()
+Nodes (7): create_post(), get_post(), list_posts(), post_code_exists(), _row_to_dict(), soft_delete_post(), update_post()
 
 ### Community 33 - "Community 33"
 Cohesion: 0.36
-Nodes (7): create_reagent(), get_reagent(), list_reagents(), reagent_code_exists(), _row_to_dict(), soft_delete_reagent(), update_reagent()
+Nodes (7): create_tenant(), get_tenant(), list_tenants(), _row_to_dict(), soft_delete_tenant(), tenant_no_exists(), update_tenant()
 
 ### Community 34 - "Community 34"
-Cohesion: 0.29
-Nodes (2): BaseModel, SoftDeleteModel
+Cohesion: 0.36
+Nodes (7): create_reagent(), get_reagent(), list_reagents(), reagent_code_exists(), _row_to_dict(), soft_delete_reagent(), update_reagent()
 
 ### Community 35 - "Community 35"
-Cohesion: 0.46
-Nodes (7): _company_name_map(), _dec(), get_item(), list_active_items(), list_items(), _row_to_dict(), soft_delete_item()
+Cohesion: 0.39
+Nodes (8): 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()
 
 ### Community 36 - "Community 36"
+Cohesion: 0.36
+Nodes (7): create_site(), get_site(), list_sites(), _row_to_dict(), site_code_exists(), soft_delete_site(), update_site()
+
+### Community 37 - "Community 37"
+Cohesion: 0.29
+Nodes (2): BaseModel, SoftDeleteModel
+
+### Community 38 - "Community 38"
 Cohesion: 0.38
 Nodes (6): derive_legacy_status_from_three(), derive_three_status_from_legacy(), 阶段A兼容:从旧 status 推导三字段。     约束:旧 status 本身混合了支付/配送/订单生命周期,因此只能尽力推断;     若业务侧已有更精确, 阶段A兼容:从三字段反推旧 status(用于旧前端/旧筛选参数兼容)。, ThreeStatus, _to_decimal()
 
-### Community 37 - "Community 37"
+### Community 39 - "Community 39"
 Cohesion: 0.5
 Nodes (3): BaseSettings, Config, Settings
 
-### Community 38 - "Community 38"
+### Community 40 - "Community 40"
 Cohesion: 0.5
 Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID
 
-### Community 39 - "Community 39"
+### Community 41 - "Community 41"
 Cohesion: 0.67
 Nodes (2): get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,供后端菜单模式联调)
 
-### Community 40 - "Community 40"
+### Community 42 - "Community 42"
 Cohesion: 1.0
 Nodes (1): 请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传
 
-### Community 41 - "Community 41"
+### Community 43 - "Community 43"
 Cohesion: 1.0
 Nodes (1): API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)
 
-### Community 44 - "Community 44"
+### Community 46 - "Community 46"
 Cohesion: 1.0
 Nodes (1): 发送订单状态通知                  Args:             openid: 用户openid             ord
 
-### Community 45 - "Community 45"
+### Community 47 - "Community 47"
 Cohesion: 1.0
 Nodes (1): 发送支付成功通知                  Args:             openid: 用户openid             ord
 
-### Community 46 - "Community 46"
+### Community 48 - "Community 48"
 Cohesion: 1.0
 Nodes (1): 发送配送通知                  Args:             openid: 配送员openid             task
 
-### Community 47 - "Community 47"
+### Community 49 - "Community 49"
 Cohesion: 1.0
 Nodes (1): 发送配送时效预警                  Args:             openid: 配送员openid             ta
 
-### Community 48 - "Community 48"
+### Community 50 - "Community 50"
 Cohesion: 1.0
 Nodes (1): 发送退款结果通知                  Args:             openid: 用户openid             ord
 
-### Community 49 - "Community 49"
+### Community 51 - "Community 51"
 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 34`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
+- **Thin community `Community 37`** (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 38`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
+- **Thin community `Community 40`** (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 39`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,供后端菜单模式联调)`
+- **Thin community `Community 41`** (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 40`** (2 nodes): `context.py`, `请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
+- **Thin community `Community 42`** (2 nodes): `context.py`, `请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 41`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
+- **Thin community `Community 43`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 44`** (1 nodes): `发送订单状态通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 46`** (1 nodes): `发送订单状态通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 45`** (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 46`** (1 nodes): `发送配送通知                  Args:             openid: 配送员openid             task`
+- **Thin community `Community 48`** (1 nodes): `发送配送通知                  Args:             openid: 配送员openid             task`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 47`** (1 nodes): `发送配送时效预警                  Args:             openid: 配送员openid             ta`
+- **Thin community `Community 49`** (1 nodes): `发送配送时效预警                  Args:             openid: 配送员openid             ta`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 48`** (1 nodes): `发送退款结果通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 50`** (1 nodes): `发送退款结果通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 49`** (1 nodes): `发送催单通知给配送员                  Args:             deliver_openid: 配送员openid`
+- **Thin community `Community 51`** (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 `generate_uuid()` connect `Community 3` to `Community 0`, `Community 1`, `Community 2`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 11`, `Community 15`, `Community 17`, `Community 18`, `Community 19`, `Community 22`, `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.096) - this node is a cross-community bridge._
-- **Why does `SysUser` connect `Community 0` to `Community 2`, `Community 4`, `Community 5`, `Community 6`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 19`?**
-  _High betweenness centrality (0.086) - this node is a cross-community bridge._
-- **Why does `success_response()` connect `Community 1` to `Community 0`, `Community 4`, `Community 5`, `Community 6`, `Community 39`, `Community 9`, `Community 11`, `Community 12`?**
-  _High betweenness centrality (0.081) - this node is a cross-community bridge._
-- **Are the 376 inferred relationships involving `success_response()` (e.g. with `web_login_rsa_public_key()` and `web_login()`) actually correct?**
-  _`success_response()` has 376 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 348 inferred relationships involving `str` (e.g. with `extract_login_credentials()` and `._make_request()`) actually correct?**
-  _`str` has 348 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 301 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `web_login()`) actually correct?**
-  _`error_response()` has 301 INFERRED edges - model-reasoned connections that need verification._
+- **Why does `generate_uuid()` connect `Community 3` to `Community 1`, `Community 2`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 11`, `Community 13`, `Community 15`, `Community 16`, `Community 17`, `Community 18`, `Community 20`, `Community 21`, `Community 22`, `Community 23`, `Community 24`, `Community 25`, `Community 26`, `Community 27`, `Community 28`, `Community 29`, `Community 30`, `Community 31`, `Community 32`, `Community 33`, `Community 34`, `Community 35`, `Community 36`?**
+  _High betweenness centrality (0.124) - this node is a cross-community bridge._
+- **Why does `SysUser` connect `Community 0` to `Community 2`, `Community 4`, `Community 6`, `Community 9`, `Community 10`, `Community 17`, `Community 18`?**
+  _High betweenness centrality (0.084) - this node is a cross-community bridge._
+- **Why does `success_response()` connect `Community 1` to `Community 0`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 9`, `Community 41`, `Community 16`?**
+  _High betweenness centrality (0.066) - this node is a cross-community bridge._
+- **Are the 378 inferred relationships involving `success_response()` (e.g. with `web_login_rsa_public_key()` and `web_login()`) actually correct?**
+  _`success_response()` has 378 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 350 inferred relationships involving `str` (e.g. with `extract_login_credentials()` and `._make_request()`) actually correct?**
+  _`str` has 350 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 303 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `web_login()`) actually correct?**
+  _`error_response()` has 303 INFERRED edges - model-reasoned connections that need verification._
 - **Are the 215 inferred relationships involving `SysUser` (e.g. with `数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤)  data_scope 值:   1 = 全部数据   2 = 本部门数据   3` and `从 SysUser 读取用户的 dept_id。`) actually correct?**
   _`SysUser` has 215 INFERRED edges - model-reasoned connections that need verification._

File diff suppressed because it is too large
+ 1 - 1
backend/graphify-out/graph.html


File diff suppressed because it is too large
+ 112 - 112
backend/graphify-out/graph.json


+ 6 - 6
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": 1779336613.0254703,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/models/models.py": 1779341971.6717553,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/__init__.py": 1779206668.0388827,
   "/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,
@@ -40,7 +40,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/product_parameter_value.py": 1778578258.0980055,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/product_review_media.py": 1778578258.0986822,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/product_parameter.py": 1778578258.0977957,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/production_task.py": 1779206906.9311461,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/production_task.py": 1779352530.666423,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/product_detail_media.py": 1778578258.0973988,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/product_banner.py": 1778578258.097102,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/region.py": 1778578258.099429,
@@ -48,7 +48,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/order.py": 1778578258.0960286,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/__init__.py": 1778578258.0864499,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/order_adjust.py": 1778578258.0963318,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/cell_product.py": 1779339582.3612273,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/cell_product.py": 1779341969.2484393,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/menu.py": 1778578258.0957444,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/attachment.py": 1778763456.6244864,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/category_parameter.py": 1778578258.0944862,
@@ -74,7 +74,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/tenant.py": 1779265611.0532296,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/consumable.py": 1779265707.1662717,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/sop_document.py": 1779265721.9928973,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_task.py": 1779265679.474413,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_task.py": 1779352518.519091,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_third_party_item.py": 1779265661.482352,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/reagent.py": 1779265718.524432,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol.py": 1779329214.3583212,
@@ -84,7 +84,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/express_company.py": 1779265659.3453207,
   "/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": 1779340488.4383276,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/cell_product.py": 1779341969.895009,
   "/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/sample_reception_check_item.py": 1779007909.2933013,
@@ -128,7 +128,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/workflow.py": 1779266232.4568832,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_reception.py": 1779304425.1104686,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/insurance_company.py": 1779281097.279193,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/api/production.py": 1779340693.8205905,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/api/production.py": 1779343860.3325307,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/leave.py": 1779266168.8693042,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/workflow_callback.py": 1778727966.7277644,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/workflow_engine.py": 1778634664.6293883,

File diff suppressed because it is too large
+ 410 - 399
web-admin/graphify-out/.graphify_analysis.json


+ 149 - 215
web-admin/graphify-out/.graphify_extract.json

@@ -2882,14 +2882,14 @@
       "label": "logSidebarMenu()",
       "file_type": "code",
       "source_file": "components/core/layouts/art-menus/art-sidebar-menu/index.vue",
-      "source_location": "L171"
+      "source_location": "L165"
     },
     {
       "id": "art_sidebar_menu_index_if",
       "label": "if()",
       "file_type": "code",
       "source_file": "components/core/layouts/art-menus/art-sidebar-menu/index.vue",
-      "source_location": "L262"
+      "source_location": "L256"
     },
     {
       "id": "components_core_layouts_art_menus_art_sidebar_menu_widget_sidebarsubmenu_vue",
@@ -6524,6 +6524,27 @@
       "source_file": "api/production-task.ts",
       "source_location": "L72"
     },
+    {
+      "id": "api_production_task_fetchreordertasksteps",
+      "label": "fetchReorderTaskSteps()",
+      "file_type": "code",
+      "source_file": "api/production-task.ts",
+      "source_location": "L76"
+    },
+    {
+      "id": "api_production_task_fetchcopytaskstep",
+      "label": "fetchCopyTaskStep()",
+      "file_type": "code",
+      "source_file": "api/production-task.ts",
+      "source_location": "L80"
+    },
+    {
+      "id": "api_production_task_fetchreplacestepoutputs",
+      "label": "fetchReplaceStepOutputs()",
+      "file_type": "code",
+      "source_file": "api/production-task.ts",
+      "source_location": "L87"
+    },
     {
       "id": "api_qc_material_usage_ts",
       "label": "qc-material-usage.ts",
@@ -6942,14 +6963,14 @@
       "label": "dash()",
       "file_type": "code",
       "source_file": "views/cell-storage/protocol/ProtocolViewDialog.vue",
-      "source_location": "L316"
+      "source_location": "L329"
     },
     {
       "id": "protocol_protocolviewdialog_handleclosed",
       "label": "handleClosed()",
       "file_type": "code",
       "source_file": "views/cell-storage/protocol/ProtocolViewDialog.vue",
-      "source_location": "L1635"
+      "source_location": "L1648"
     },
     {
       "id": "views_cell_storage_protocol_protocolattachmentreadonly_vue",
@@ -8324,53 +8345,67 @@
       "source_location": "L1"
     },
     {
-      "id": "production_work_order_index_layoutworkordertable",
-      "label": "layoutWorkOrderTable()",
+      "id": "views_production_production_work_order_usetaskstepeditor_ts",
+      "label": "useTaskStepEditor.ts",
       "file_type": "code",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L280"
+      "source_file": "views/production/production-work-order/useTaskStepEditor.ts",
+      "source_location": "L1"
     },
     {
-      "id": "production_work_order_index_loaddata",
-      "label": "loadData()",
+      "id": "production_work_order_usetaskstepeditor_toapiflag",
+      "label": "toApiFlag()",
       "file_type": "code",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L285"
+      "source_file": "views/production/production-work-order/useTaskStepEditor.ts",
+      "source_location": "L24"
     },
     {
-      "id": "production_work_order_index_handlesearch",
-      "label": "handleSearch()",
+      "id": "production_work_order_usetaskstepeditor_fromapiflag",
+      "label": "fromApiFlag()",
       "file_type": "code",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L302"
+      "source_file": "views/production/production-work-order/useTaskStepEditor.ts",
+      "source_location": "L28"
     },
     {
-      "id": "production_work_order_index_handlereset",
-      "label": "handleReset()",
+      "id": "production_work_order_usetaskstepeditor_normalizestepsubtables",
+      "label": "normalizeStepSubTables()",
       "file_type": "code",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L307"
+      "source_file": "views/production/production-work-order/useTaskStepEditor.ts",
+      "source_location": "L31"
     },
     {
-      "id": "production_work_order_index_handlereceptionattachmentpreview",
-      "label": "handleReceptionAttachmentPreview()",
+      "id": "production_work_order_usetaskstepeditor_parseordernum",
+      "label": "parseOrderNum()",
       "file_type": "code",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L345"
+      "source_file": "views/production/production-work-order/useTaskStepEditor.ts",
+      "source_location": "L46"
     },
     {
-      "id": "production_work_order_index_handlereceptionattachmentdownload",
-      "label": "handleReceptionAttachmentDownload()",
+      "id": "production_work_order_usetaskstepeditor_usetaskstepeditor",
+      "label": "useTaskStepEditor()",
       "file_type": "code",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L350"
+      "source_file": "views/production/production-work-order/useTaskStepEditor.ts",
+      "source_location": "L53"
     },
     {
-      "id": "production_work_order_index_formatmetric",
-      "label": "formatMetric()",
+      "id": "views_production_production_work_order_stepdetailpanel_vue",
+      "label": "StepDetailPanel.vue",
       "file_type": "code",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L354"
+      "source_file": "views/production/production-work-order/StepDetailPanel.vue",
+      "source_location": "L1"
+    },
+    {
+      "id": "views_production_production_work_order_dispatchtaskdialog_vue",
+      "label": "DispatchTaskDialog.vue",
+      "file_type": "code",
+      "source_file": "views/production/production-work-order/DispatchTaskDialog.vue",
+      "source_location": "L1"
+    },
+    {
+      "id": "production_work_order_dispatchtaskdialog_if",
+      "label": "if()",
+      "file_type": "code",
+      "source_file": "views/production/production-work-order/DispatchTaskDialog.vue",
+      "source_location": "L205"
     },
     {
       "id": "views_production_consumable_pack_index_vue",
@@ -15530,7 +15565,7 @@
       "relation": "imports_from",
       "confidence": "EXTRACTED",
       "source_file": "components/core/layouts/art-menus/art-sidebar-menu/index.vue",
-      "source_location": "L148",
+      "source_location": "L142",
       "weight": 1.0
     },
     {
@@ -15539,7 +15574,7 @@
       "relation": "imports_from",
       "confidence": "EXTRACTED",
       "source_file": "components/core/layouts/art-menus/art-sidebar-menu/index.vue",
-      "source_location": "L149",
+      "source_location": "L143",
       "weight": 1.0
     },
     {
@@ -15548,7 +15583,7 @@
       "relation": "imports_from",
       "confidence": "EXTRACTED",
       "source_file": "components/core/layouts/art-menus/art-sidebar-menu/index.vue",
-      "source_location": "L150",
+      "source_location": "L144",
       "weight": 1.0
     },
     {
@@ -15557,7 +15592,7 @@
       "relation": "imports_from",
       "confidence": "EXTRACTED",
       "source_file": "components/core/layouts/art-menus/art-sidebar-menu/index.vue",
-      "source_location": "L151",
+      "source_location": "L145",
       "weight": 1.0
     },
     {
@@ -15566,7 +15601,7 @@
       "relation": "imports_from",
       "confidence": "EXTRACTED",
       "source_file": "components/core/layouts/art-menus/art-sidebar-menu/index.vue",
-      "source_location": "L152",
+      "source_location": "L146",
       "weight": 1.0
     },
     {
@@ -15575,7 +15610,7 @@
       "relation": "imports_from",
       "confidence": "EXTRACTED",
       "source_file": "components/core/layouts/art-menus/art-sidebar-menu/index.vue",
-      "source_location": "L153",
+      "source_location": "L147",
       "weight": 1.0
     },
     {
@@ -15584,7 +15619,7 @@
       "relation": "imports_from",
       "confidence": "EXTRACTED",
       "source_file": "components/core/layouts/art-menus/art-sidebar-menu/index.vue",
-      "source_location": "L154",
+      "source_location": "L148",
       "weight": 1.0
     },
     {
@@ -15593,7 +15628,7 @@
       "relation": "imports_from",
       "confidence": "EXTRACTED",
       "source_file": "components/core/layouts/art-menus/art-sidebar-menu/index.vue",
-      "source_location": "L155",
+      "source_location": "L149",
       "weight": 1.0
     },
     {
@@ -15602,7 +15637,7 @@
       "relation": "imports_from",
       "confidence": "EXTRACTED",
       "source_file": "components/core/layouts/art-menus/art-sidebar-menu/index.vue",
-      "source_location": "L156",
+      "source_location": "L150",
       "weight": 1.0
     },
     {
@@ -15611,7 +15646,7 @@
       "relation": "imports_from",
       "confidence": "EXTRACTED",
       "source_file": "components/core/layouts/art-menus/art-sidebar-menu/index.vue",
-      "source_location": "L157",
+      "source_location": "L151",
       "weight": 1.0
     },
     {
@@ -15620,7 +15655,7 @@
       "relation": "contains",
       "confidence": "EXTRACTED",
       "source_file": "components/core/layouts/art-menus/art-sidebar-menu/index.vue",
-      "source_location": "L171",
+      "source_location": "L165",
       "weight": 1.0
     },
     {
@@ -15629,7 +15664,7 @@
       "relation": "contains",
       "confidence": "EXTRACTED",
       "source_file": "components/core/layouts/art-menus/art-sidebar-menu/index.vue",
-      "source_location": "L262",
+      "source_location": "L256",
       "weight": 1.0
     },
     {
@@ -15638,7 +15673,7 @@
       "relation": "calls",
       "confidence": "EXTRACTED",
       "source_file": "components/core/layouts/art-menus/art-sidebar-menu/index.vue",
-      "source_location": "L263",
+      "source_location": "L257",
       "weight": 1.0
     },
     {
@@ -20780,6 +20815,33 @@
       "source_location": "L72",
       "weight": 1.0
     },
+    {
+      "source": "api_production_task_ts",
+      "target": "api_production_task_fetchreordertasksteps",
+      "relation": "contains",
+      "confidence": "EXTRACTED",
+      "source_file": "api/production-task.ts",
+      "source_location": "L76",
+      "weight": 1.0
+    },
+    {
+      "source": "api_production_task_ts",
+      "target": "api_production_task_fetchcopytaskstep",
+      "relation": "contains",
+      "confidence": "EXTRACTED",
+      "source_file": "api/production-task.ts",
+      "source_location": "L80",
+      "weight": 1.0
+    },
+    {
+      "source": "api_production_task_ts",
+      "target": "api_production_task_fetchreplacestepoutputs",
+      "relation": "contains",
+      "confidence": "EXTRACTED",
+      "source_file": "api/production-task.ts",
+      "source_location": "L87",
+      "weight": 1.0
+    },
     {
       "source": "api_qc_material_usage_ts",
       "target": "users_wuzilong_work_00code_zkxw_web_admin_src_utils_http",
@@ -21326,7 +21388,7 @@
       "relation": "contains",
       "confidence": "EXTRACTED",
       "source_file": "views/cell-storage/protocol/ProtocolViewDialog.vue",
-      "source_location": "L316",
+      "source_location": "L329",
       "weight": 1.0
     },
     {
@@ -21335,7 +21397,7 @@
       "relation": "contains",
       "confidence": "EXTRACTED",
       "source_file": "views/cell-storage/protocol/ProtocolViewDialog.vue",
-      "source_location": "L1635",
+      "source_location": "L1648",
       "weight": 1.0
     },
     {
@@ -23868,201 +23930,93 @@
       "weight": 1.0
     },
     {
-      "source": "views_production_production_work_order_index_vue",
-      "target": "element_plus",
-      "relation": "imports_from",
-      "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L227",
-      "weight": 1.0
-    },
-    {
-      "source": "views_production_production_work_order_index_vue",
-      "target": "element_plus",
-      "relation": "imports_from",
-      "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L228",
-      "weight": 1.0
-    },
-    {
-      "source": "views_production_production_work_order_index_vue",
-      "target": "icons_vue",
-      "relation": "imports_from",
-      "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L229",
-      "weight": 1.0
-    },
-    {
-      "source": "views_production_production_work_order_index_vue",
-      "target": "users_wuzilong_work_00code_zkxw_web_admin_src_api_production_work_order",
-      "relation": "imports_from",
-      "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L230",
-      "weight": 1.0
-    },
-    {
-      "source": "views_production_production_work_order_index_vue",
+      "source": "views_production_production_work_order_usetaskstepeditor_ts",
       "target": "users_wuzilong_work_00code_zkxw_web_admin_src_api_production_task",
       "relation": "imports_from",
       "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L236",
+      "source_file": "views/production/production-work-order/useTaskStepEditor.ts",
+      "source_location": "L1",
       "weight": 1.0
     },
     {
-      "source": "views_production_production_work_order_index_vue",
+      "source": "views_production_production_work_order_usetaskstepeditor_ts",
       "target": "users_wuzilong_work_00code_zkxw_web_admin_src_api_production",
       "relation": "imports_from",
       "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L239",
-      "weight": 1.0
-    },
-    {
-      "source": "views_production_production_work_order_index_vue",
-      "target": "users_wuzilong_work_00code_zkxw_web_admin_src_utils_qc_labels",
-      "relation": "imports_from",
-      "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L243",
-      "weight": 1.0
-    },
-    {
-      "source": "views_production_production_work_order_index_vue",
-      "target": "views_cell_storage_protocol_protocolattachmentreadonly_vue",
-      "relation": "imports_from",
-      "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L248",
-      "weight": 1.0
-    },
-    {
-      "source": "views_production_production_work_order_index_vue",
-      "target": "components_business_system_view_dialog_index_vue",
-      "relation": "imports_from",
-      "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L249",
-      "weight": 1.0
-    },
-    {
-      "source": "views_production_production_work_order_index_vue",
-      "target": "components_business_system_view_dialog_index_vue",
-      "relation": "imports_from",
-      "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L250",
-      "weight": 1.0
-    },
-    {
-      "source": "views_production_production_work_order_index_vue",
-      "target": "element_plus",
-      "relation": "imports_from",
-      "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L251",
+      "source_file": "views/production/production-work-order/useTaskStepEditor.ts",
+      "source_location": "L13",
       "weight": 1.0
     },
     {
-      "source": "views_production_production_work_order_index_vue",
-      "target": "users_wuzilong_work_00code_zkxw_web_admin_src_utils_download_attachment",
+      "source": "views_production_production_work_order_usetaskstepeditor_ts",
+      "target": "users_wuzilong_work_00code_zkxw_web_admin_src_api_admin",
       "relation": "imports_from",
       "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L252",
+      "source_file": "views/production/production-work-order/useTaskStepEditor.ts",
+      "source_location": "L21",
       "weight": 1.0
     },
     {
-      "source": "views_production_production_work_order_index_vue",
-      "target": "production_work_order_index_layoutworkordertable",
+      "source": "views_production_production_work_order_usetaskstepeditor_ts",
+      "target": "production_work_order_usetaskstepeditor_toapiflag",
       "relation": "contains",
       "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L280",
+      "source_file": "views/production/production-work-order/useTaskStepEditor.ts",
+      "source_location": "L24",
       "weight": 1.0
     },
     {
-      "source": "views_production_production_work_order_index_vue",
-      "target": "production_work_order_index_loaddata",
+      "source": "views_production_production_work_order_usetaskstepeditor_ts",
+      "target": "production_work_order_usetaskstepeditor_fromapiflag",
       "relation": "contains",
       "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L285",
+      "source_file": "views/production/production-work-order/useTaskStepEditor.ts",
+      "source_location": "L28",
       "weight": 1.0
     },
     {
-      "source": "views_production_production_work_order_index_vue",
-      "target": "production_work_order_index_handlesearch",
+      "source": "views_production_production_work_order_usetaskstepeditor_ts",
+      "target": "production_work_order_usetaskstepeditor_normalizestepsubtables",
       "relation": "contains",
       "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L302",
+      "source_file": "views/production/production-work-order/useTaskStepEditor.ts",
+      "source_location": "L31",
       "weight": 1.0
     },
     {
-      "source": "views_production_production_work_order_index_vue",
-      "target": "production_work_order_index_handlereset",
+      "source": "views_production_production_work_order_usetaskstepeditor_ts",
+      "target": "production_work_order_usetaskstepeditor_parseordernum",
       "relation": "contains",
       "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L307",
+      "source_file": "views/production/production-work-order/useTaskStepEditor.ts",
+      "source_location": "L46",
       "weight": 1.0
     },
     {
-      "source": "views_production_production_work_order_index_vue",
-      "target": "production_work_order_index_handlereceptionattachmentpreview",
+      "source": "views_production_production_work_order_usetaskstepeditor_ts",
+      "target": "production_work_order_usetaskstepeditor_usetaskstepeditor",
       "relation": "contains",
       "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L345",
+      "source_file": "views/production/production-work-order/useTaskStepEditor.ts",
+      "source_location": "L53",
       "weight": 1.0
     },
     {
-      "source": "views_production_production_work_order_index_vue",
-      "target": "production_work_order_index_handlereceptionattachmentdownload",
-      "relation": "contains",
+      "source": "views_production_production_work_order_stepdetailpanel_vue",
+      "target": "users_wuzilong_work_00code_zkxw_web_admin_src_views_production_production_work_order_usetaskstepeditor",
+      "relation": "imports_from",
       "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L350",
+      "source_file": "views/production/production-work-order/StepDetailPanel.vue",
+      "source_location": "L265",
       "weight": 1.0
     },
     {
-      "source": "views_production_production_work_order_index_vue",
-      "target": "production_work_order_index_formatmetric",
+      "source": "views_production_production_work_order_dispatchtaskdialog_vue",
+      "target": "production_work_order_dispatchtaskdialog_if",
       "relation": "contains",
       "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L354",
-      "weight": 1.0
-    },
-    {
-      "source": "production_work_order_index_loaddata",
-      "target": "production_work_order_index_layoutworkordertable",
-      "relation": "calls",
-      "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L298",
-      "weight": 1.0
-    },
-    {
-      "source": "production_work_order_index_handlesearch",
-      "target": "production_work_order_index_loaddata",
-      "relation": "calls",
-      "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L304",
-      "weight": 1.0
-    },
-    {
-      "source": "production_work_order_index_handlereset",
-      "target": "production_work_order_index_loaddata",
-      "relation": "calls",
-      "confidence": "EXTRACTED",
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L311",
+      "source_file": "views/production/production-work-order/DispatchTaskDialog.vue",
+      "source_location": "L205",
       "weight": 1.0
     },
     {
@@ -29610,7 +29564,7 @@
       "confidence": "INFERRED",
       "confidence_score": 0.8,
       "source_file": "views/cell-storage/protocol/ProtocolViewDialog.vue",
-      "source_location": "L316",
+      "source_location": "L329",
       "weight": 1.0
     },
     {
@@ -30373,26 +30327,6 @@
       "source_location": "L571",
       "weight": 1.0
     },
-    {
-      "source": "production_work_order_index_loaddata",
-      "target": "api_production_work_order_fetchproductionworkorderlist",
-      "relation": "calls",
-      "confidence": "INFERRED",
-      "confidence_score": 0.8,
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L288",
-      "weight": 1.0
-    },
-    {
-      "source": "production_work_order_index_handlereceptionattachmentdownload",
-      "target": "utils_download_attachment_downloadattachmentfile",
-      "relation": "calls",
-      "confidence": "INFERRED",
-      "confidence_score": 0.8,
-      "source_file": "views/production/production-work-order/index.vue",
-      "source_location": "L351",
-      "weight": 1.0
-    },
     {
       "source": "qc_task_index_handlereceptionattachmentdownload",
       "target": "utils_download_attachment_downloadattachmentfile",

+ 45 - 52
web-admin/graphify-out/GRAPH_REPORT.md

@@ -1,11 +1,11 @@
 # Graph Report - src  (2026-05-21)
 
 ## Corpus Check
-- Large corpus: 577 files · ~299,569 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
+- Large corpus: 580 files · ~302,856 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
 
 ## Summary
-- 1410 nodes · 1768 edges · 43 communities detected
-- Extraction: 78% EXTRACTED · 22% INFERRED · 0% AMBIGUOUS · INFERRED: 386 edges (avg confidence: 0.8)
+- 1415 nodes · 1763 edges · 42 communities detected
+- Extraction: 78% EXTRACTED · 22% INFERRED · 0% AMBIGUOUS · INFERRED: 384 edges (avg confidence: 0.8)
 - Token cost: 0 input · 0 output
 
 ## Community Hubs (Navigation)
@@ -34,7 +34,7 @@
 - [[_COMMUNITY_Community 22|Community 22]]
 - [[_COMMUNITY_Community 23|Community 23]]
 - [[_COMMUNITY_Community 24|Community 24]]
-- [[_COMMUNITY_Community 26|Community 26]]
+- [[_COMMUNITY_Community 25|Community 25]]
 - [[_COMMUNITY_Community 27|Community 27]]
 - [[_COMMUNITY_Community 28|Community 28]]
 - [[_COMMUNITY_Community 30|Community 30]]
@@ -46,7 +46,6 @@
 - [[_COMMUNITY_Community 36|Community 36]]
 - [[_COMMUNITY_Community 37|Community 37]]
 - [[_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]]
@@ -66,6 +65,8 @@
 10. `StorageConfig` - 10 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
 - `handleUnauthorizedResponse()` --calls--> `fetchRefreshToken()`  [INFERRED]
@@ -74,14 +75,12 @@
   api/leave.ts → views/production/process-route/index.vue
 - `fetchLeaveDetail()` --calls--> `get()`  [INFERRED]
   api/leave.ts → views/production/process-route/index.vue
-- `fetchSampleReceptionCheckItemList()` --calls--> `get()`  [INFERRED]
-  api/sample-reception-check-item.ts → views/production/process-route/index.vue
 
 ## Communities
 
 ### Community 0 - "Community 0"
 Cohesion: 0.01
-Nodes (74): fetchActiveRegions(), fetchCategoryList(), fetchCategoryParameters(), fetchCellProductPriceList(), fetchCellProductSpecList(), fetchCellProductTypeList(), fetchCellSampleTypeList(), fetchChannelAgreementPrices() (+66 more)
+Nodes (75): fetchActiveRegions(), fetchCategoryList(), fetchCategoryParameters(), fetchCellProductPriceList(), fetchCellProductSpecList(), fetchCellProductTypeList(), fetchCellSampleTypeList(), fetchChannelAgreementPrices() (+67 more)
 
 ### Community 1 - "Community 1"
 Cohesion: 0.03
@@ -101,87 +100,87 @@ Nodes (18): fetchAllConsumablePacks(), fetchAllConsumables(), fetchAllOutputType
 
 ### Community 5 - "Community 5"
 Cohesion: 0.05
-Nodes (39): fetchAccessLogList(), fetchCleanRoomList(), fetchCreateAccessLog(), fetchCreateCleanRoom(), fetchCreateSite(), fetchDeleteAccessLog(), fetchDeleteCleanRoom(), fetchDeleteSite() (+31 more)
+Nodes (40): fetchAccessLogList(), fetchCleanRoomList(), fetchCreateAccessLog(), fetchCreateCleanRoom(), fetchCreateSite(), fetchDeleteAccessLog(), fetchDeleteCleanRoom(), fetchDeleteSite() (+32 more)
 
 ### Community 6 - "Community 6"
-Cohesion: 0.06
-Nodes (11): collectIds(), useSettingsHandlers(), useSettingsPanel(), useSettingsState(), RoutePermissionValidator, useCeremony(), useTheme(), TableCache (+3 more)
+Cohesion: 0.09
+Nodes (31): toLogin(), base64ToBytes(), bytesToBase64(), decryptJson(), deriveKey(), encryptJson(), fromBase64Url(), hexToBytes() (+23 more)
 
 ### Community 7 - "Community 7"
 Cohesion: 0.06
-Nodes (28): fetchCreateCustomer(), fetchCreateMenu(), fetchCustomerList(), fetchDeleteCustomer(), fetchDeleteMenu(), fetchMenuTree(), fetchUpdateCustomer(), fetchUpdateMenu() (+20 more)
+Nodes (10): useSettingsHandlers(), useSettingsPanel(), useSettingsState(), RoutePermissionValidator, useCeremony(), useTheme(), TableCache, themeAnimation() (+2 more)
 
 ### Community 8 - "Community 8"
 Cohesion: 0.09
-Nodes (31): toLogin(), base64ToBytes(), bytesToBase64(), decryptJson(), deriveKey(), encryptJson(), fromBase64Url(), hexToBytes() (+23 more)
+Nodes (8): getDefaultLanguage(), checkStorageCompatibility(), StorageConfig, getSystemStorage(), getSysVersion(), StorageKeyManager, StorageCompatibilityManager, validateStorageData()
 
 ### Community 9 - "Community 9"
-Cohesion: 0.09
-Nodes (8): getDefaultLanguage(), checkStorageCompatibility(), StorageConfig, getSystemStorage(), getSysVersion(), StorageKeyManager, StorageCompatibilityManager, validateStorageData()
+Cohesion: 0.13
+Nodes (4): handleReset(), handleSearch(), WebSocketClient, emit()
 
 ### Community 10 - "Community 10"
 Cohesion: 0.07
-Nodes (15): fetchProductionWorkOrderDetail(), fetchProductionWorkOrderList(), auditorDisplay(), dash(), expressName(), formatYm(), formatYmd(), sampleTypeName() (+7 more)
+Nodes (13): fetchAttachmentDownloadUrl(), fetchOrderAdjusts(), fetchOrderDetail(), fetchOrderItems(), fetchOrderList(), fetchPriceVersionDetail(), fetchSystemConfig(), handleReceptionAttachmentDownload() (+5 more)
 
 ### Community 11 - "Community 11"
-Cohesion: 0.13
-Nodes (4): handleReset(), handleSearch(), WebSocketClient, emit()
+Cohesion: 0.09
+Nodes (9): fetchGetAllUpgradeLogs(), fetchGetChinaMapData(), fetchGetComments(), fetchGetCurrentVersion(), fetchGetLatestUpgradeLog(), fetchGetSystemConfig(), fetchGetUpgradeLogs(), systemUpgrade() (+1 more)
 
 ### Community 12 - "Community 12"
-Cohesion: 0.06
-Nodes (14): fetchAttachmentDownloadUrl(), fetchOrderAdjusts(), fetchOrderDetail(), fetchOrderItems(), fetchOrderList(), fetchPriceVersionDetail(), fetchSystemConfig(), handleReceptionAttachmentDownload() (+6 more)
-
-### Community 13 - "Community 13"
 Cohesion: 0.1
 Nodes (8): fetchGetMenuList(), fetchGetMenuCodes(), fetchLogin(), fetchRefreshToken(), MenuProcessor, useAppMode(), useAuth(), buildWebEncryptedLoginBody()
 
-### Community 14 - "Community 14"
+### Community 13 - "Community 13"
 Cohesion: 0.1
 Nodes (13): fetchEthnicList(), addSample(), applyDonationBundleToForm(), confirmBatchTransportApply(), createEmptyTransport(), ensureDonationProtocolNoInOptions(), ensureEthnicOptionInList(), handleOpenForAdd() (+5 more)
 
-### Community 15 - "Community 15"
+### Community 14 - "Community 14"
 Cohesion: 0.1
 Nodes (18): fetchCollectionRegionList(), fetchCreateCollectionRegion(), fetchCreateHospital(), fetchDeleteCollectionRegion(), fetchDeleteHospital(), fetchHospitalList(), fetchUpdateCollectionRegion(), fetchUpdateHospital() (+10 more)
 
+### Community 15 - "Community 15"
+Cohesion: 0.11
+Nodes (9): auditorDisplay(), dash(), expressName(), formatYm(), formatYmd(), sampleTypeName(), siteName(), unitName() (+1 more)
+
 ### Community 16 - "Community 16"
 Cohesion: 0.12
 Nodes (15): addCopyButton(), highlightCode(), insertLineNumbers(), isBlockProcessed(), markBlockAsProcessed(), processBlock(), setupHighlightDirective(), setupRippleDirective() (+7 more)
 
 ### Community 17 - "Community 17"
-Cohesion: 0.14
-Nodes (17): fetchCreateUser(), fetchDeleteUser(), fetchResetPassword(), fetchUpdateUser(), fetchUserDetail(), formatUserDeptDisplay(), handleAdd(), handleDelete() (+9 more)
+Cohesion: 0.13
+Nodes (16): fetchCreateMenu(), fetchDeleteMenu(), fetchMenuTree(), fetchUpdateMenu(), postorder(), preorder(), applyTreeExpand(), handleAdd() (+8 more)
 
 ### Community 18 - "Community 18"
-Cohesion: 0.19
-Nodes (3): fetchGetLatestUpgradeLog(), systemUpgrade(), VersionManager
+Cohesion: 0.14
+Nodes (17): fetchCreateUser(), fetchDeleteUser(), fetchResetPassword(), fetchUpdateUser(), fetchUserDetail(), formatUserDeptDisplay(), handleAdd(), handleDelete() (+9 more)
 
 ### Community 19 - "Community 19"
-Cohesion: 0.12
-Nodes (12): fetchApproveTask(), fetchBusinessAllTasks(), fetchCompletedTasks(), fetchDesignerData(), fetchInstanceDetail(), fetchMyInstances(), fetchPendingTasks(), fetchProcessDefList() (+4 more)
-
-### Community 20 - "Community 20"
 Cohesion: 0.11
 Nodes (8): fetchCollectionProtocolAuditList(), fetchCollectionProtocolDetail(), fetchCollectionProtocolList(), fetchPaymentDetailList(), fetchReceiptConfirmedList(), fetchReceiptConfirmList(), fetchRefundConfirmedList(), fetchRefundConfirmList()
 
+### Community 20 - "Community 20"
+Cohesion: 0.12
+Nodes (12): fetchApproveTask(), fetchBusinessAllTasks(), fetchCompletedTasks(), fetchDesignerData(), fetchInstanceDetail(), fetchMyInstances(), fetchPendingTasks(), fetchProcessDefList() (+4 more)
+
 ### Community 21 - "Community 21"
 Cohesion: 0.16
 Nodes (10): useTable(), useTableImpl(), getColumnChecks(), useTableColumns(), createErrorHandler(), createSmartDebounce(), defaultResponseAdapter(), extractPagination() (+2 more)
 
 ### Community 22 - "Community 22"
+Cohesion: 0.12
+Nodes (2): fetchProductionTaskDetail(), fetchProductionTaskList()
+
+### Community 23 - "Community 23"
 Cohesion: 0.2
 Nodes (2): ComponentLoader, RouteTransformer
 
-### Community 23 - "Community 23"
+### Community 24 - "Community 24"
 Cohesion: 0.27
 Nodes (14): useChart(), useChartComponent(), useChartOps(), colourBlend(), getCssVar(), getDarkColor(), getLightColor(), handleElementThemeColor() (+6 more)
 
-### Community 24 - "Community 24"
-Cohesion: 0.14
-Nodes (2): fetchProductionTaskDetail(), fetchProductionTaskList()
-
-### Community 26 - "Community 26"
+### Community 25 - "Community 25"
 Cohesion: 0.17
-Nodes (3): fetchResearchDonationAuditList(), fetchResearchDonationDetail(), fetchResearchDonationList()
+Nodes (12): fetchCreateCustomer(), fetchCustomerList(), fetchDeleteCustomer(), fetchUpdateCustomer(), handleAdd(), handleBatchDelete(), handleDelete(), handleReset() (+4 more)
 
 ### Community 27 - "Community 27"
 Cohesion: 0.18
@@ -227,10 +226,6 @@ Nodes (2): fetchQcThirdPartyCompanyList(), fetchQcThirdPartyCompanyOptions()
 Cohesion: 0.33
 Nodes (2): fetchQcLabTestItemList(), fetchQcLabTestItemsActive()
 
-### Community 39 - "Community 39"
-Cohesion: 0.33
-Nodes (2): fetchQcTaskDetail(), fetchQcTaskList()
-
 ### Community 40 - "Community 40"
 Cohesion: 0.53
 Nodes (4): isEtaTodayOrEarlier(), parseEtaLocalDate(), sampleReceptionRowClassName(), startOfLocalDay()
@@ -252,9 +247,9 @@ Cohesion: 1.0
 Nodes (2): defaultAvatarPathByRole(), resolveUserAvatarUrl()
 
 ## Knowledge Gaps
-- **Thin community `Community 22`** (17 nodes): `ComponentLoader`, `.constructor()`, `.createEmptyComponent()`, `.createErrorComponent()`, `.load()`, `.loadIframe()`, `.loadLayout()`, `.load()`, `RouteTransformer`, `.extractFirstSegment()`, `.handleFirstLevelRoute()`, `.handleIframeRoute()`, `.handleNormalRoute()`, `.isFirstLevelRoute()`, `.transform()`, `ComponentLoader.ts`, `RouteTransformer.ts`
+- **Thin community `Community 22`** (17 nodes): `fetchAddTaskStep()`, `fetchCopyTaskStep()`, `fetchCreateProductionTask()`, `fetchDeleteTaskStep()`, `fetchProductionTaskDetail()`, `fetchProductionTaskList()`, `fetchReorderTaskSteps()`, `fetchReplaceStepConsumables()`, `fetchReplaceStepDataCollection()`, `fetchReplaceStepOutputs()`, `fetchReplaceStepReagents()`, `fetchSetTaskInputs()`, `fetchSetTaskOutputs()`, `fetchUpdateProductionTask()`, `fetchUpdateProductionTaskStatus()`, `fetchUpdateTaskStep()`, `production-task.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 24`** (14 nodes): `fetchAddTaskStep()`, `fetchCreateProductionTask()`, `fetchDeleteTaskStep()`, `fetchProductionTaskDetail()`, `fetchProductionTaskList()`, `fetchReplaceStepConsumables()`, `fetchReplaceStepDataCollection()`, `fetchReplaceStepReagents()`, `fetchSetTaskInputs()`, `fetchSetTaskOutputs()`, `fetchUpdateProductionTask()`, `fetchUpdateProductionTaskStatus()`, `fetchUpdateTaskStep()`, `production-task.ts`
+- **Thin community `Community 23`** (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 28`** (10 nodes): `fetchBatchStockInQcMaterial()`, `fetchBatchStockOutQcMaterial()`, `fetchCreateQcMaterial()`, `fetchDeleteQcMaterial()`, `fetchQcMaterialList()`, `fetchQcMaterialOptions()`, `fetchStockInQcMaterial()`, `fetchStockOutQcMaterial()`, `fetchUpdateQcMaterial()`, `qc-material.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
@@ -272,8 +267,6 @@ Nodes (2): defaultAvatarPathByRole(), resolveUserAvatarUrl()
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
 - **Thin community `Community 38`** (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 39`** (6 nodes): `fetchCompleteQcTask()`, `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 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 42`** (4 nodes): `if()`, `logSidebarMenu()`, `index.vue`, `SidebarSubmenu.vue`
@@ -286,11 +279,11 @@ Nodes (2): defaultAvatarPathByRole(), resolveUserAvatarUrl()
 ## 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 7`, `Community 10`, `Community 12`, `Community 13`, `Community 14`, `Community 15`, `Community 17`, `Community 18`, `Community 19`, `Community 20`, `Community 24`, `Community 26`, `Community 27`, `Community 28`, `Community 33`, `Community 35`, `Community 36`, `Community 37`, `Community 38`, `Community 39`?**
-  _High betweenness centrality (0.259) - this node is a cross-community bridge._
-- **Why does `push()` connect `Community 3` to `Community 0`, `Community 2`, `Community 6`, `Community 11`, `Community 14`, `Community 18`?**
-  _High betweenness centrality (0.085) - this node is a cross-community bridge._
-- **Why does `fetchGetLatestUpgradeLog()` connect `Community 18` to `Community 0`?**
+- **Why does `get()` connect `Community 0` to `Community 1`, `Community 2`, `Community 3`, `Community 4`, `Community 5`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 17`, `Community 18`, `Community 19`, `Community 20`, `Community 22`, `Community 25`, `Community 27`, `Community 28`, `Community 33`, `Community 35`, `Community 36`, `Community 37`, `Community 38`?**
+  _High betweenness centrality (0.266) - this node is a cross-community bridge._
+- **Why does `push()` connect `Community 3` to `Community 0`, `Community 2`, `Community 7`, `Community 9`, `Community 11`, `Community 13`?**
+  _High betweenness centrality (0.077) - this node is a cross-community bridge._
+- **Why does `fetchGetLatestUpgradeLog()` connect `Community 11` to `Community 0`?**
   _High betweenness centrality (0.043) - this node is a cross-community bridge._
 - **Are the 152 inferred relationships involving `get()` (e.g. with `buildTreePathLabelFromFlat()` and `alignFqPaymentDetailsForDisplay()`) actually correct?**
   _`get()` has 152 INFERRED edges - model-reasoned connections that need verification._

File diff suppressed because it is too large
+ 1 - 1
web-admin/graphify-out/graph.html


+ 10 - 7
web-admin/graphify-out/manifest.json

@@ -17,13 +17,13 @@
   "/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": 1779342029.1834326,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1779342021.1408348,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts": 1779352117.4443235,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1779352105.3240538,
   "/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,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/setting.ts": 1778582067.9954739,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/index.ts": 1778598499.045689,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/index.ts": 1779344841.362722,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/modules/component.ts": 1778582067.9943824,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/modules/festival.ts": 1778582067.9949014,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/modules/headerBar.ts": 1778582067.9952137,
@@ -144,7 +144,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-menus/art-horizontal-menu/index.vue": 1778582067.9713962,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue": 1778582067.9716818,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-menus/art-mixed-menu/index.vue": 1778582067.97204,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue": 1778775532.893366,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue": 1779344843.5310895,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue": 1778775532.8943634,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-fireworks-effect/index.vue": 1778582067.9689474,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-global-search/index.vue": 1778600476.1964514,
@@ -209,7 +209,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/research-donation.ts": 1779160004.1431847,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-task.ts": 1779182848.881475,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-item-param.ts": 1779022632.5658252,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/production-task.ts": 1779207893.9550278,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/production-task.ts": 1779343665.7247121,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-material-usage.ts": 1779284726.00147,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-material.ts": 1779283280.0705447,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/system.ts": 1778582067.862994,
@@ -221,7 +221,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/refund-confirm/index.vue": 1779269573.509064,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/index.vue": 1779329214.96505,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolStatusChangeDialog.vue": 1779161483.857836,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue": 1779333532.6375403,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue": 1779348607.613315,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue": 1778938314.528914,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue": 1779333591.365972,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolAuditDialog.vue": 1778978838.5026703,
@@ -274,7 +274,10 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/sop/index.vue": 1779309609.6630545,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-task/index.vue": 1779341706.1749396,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/reagent/index.vue": 1779307562.93293,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/index.vue": 1779342203.7289343,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/index.vue": 1779352577.0334256,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/useTaskStepEditor.ts": 1779348972.995939,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/StepDetailPanel.vue": 1779360359.145311,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/DispatchTaskDialog.vue": 1779352611.1311061,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/consumable-pack/index.vue": 1779307488.018346,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/consumable/index.vue": 1779307921.9798808,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/process/index.vue": 1779309524.0991938,

+ 4 - 4
web-admin/src/api/production-task.ts

@@ -44,21 +44,21 @@ export function fetchDeleteTaskStep(taskId: string, stepId: string) {
   return request.del({ url: `/web/admin/production-tasks/${taskId}/steps/${stepId}` })
 }
 
-export function fetchReplaceStepReagents(taskId: string, stepId: string, data: Record<string, any>) {
+export function fetchReplaceStepReagents(taskId: string, stepId: string, data: any[]) {
   return request.put({
     url: `/web/admin/production-tasks/${taskId}/steps/${stepId}/reagents`,
     params: data
   })
 }
 
-export function fetchReplaceStepConsumables(taskId: string, stepId: string, data: Record<string, any>) {
+export function fetchReplaceStepConsumables(taskId: string, stepId: string, data: any[]) {
   return request.put({
     url: `/web/admin/production-tasks/${taskId}/steps/${stepId}/consumables`,
     params: data
   })
 }
 
-export function fetchReplaceStepDataCollection(taskId: string, stepId: string, data: Record<string, any>) {
+export function fetchReplaceStepDataCollection(taskId: string, stepId: string, data: any[]) {
   return request.put({
     url: `/web/admin/production-tasks/${taskId}/steps/${stepId}/data-collection`,
     params: data
@@ -84,7 +84,7 @@ export function fetchCopyTaskStep(data: { taskId: string; stepId: string }) {
   })
 }
 
-export function fetchReplaceStepOutputs(taskId: string, stepId: string, data: Record<string, any>) {
+export function fetchReplaceStepOutputs(taskId: string, stepId: string, data: any[]) {
   return request.put({
     url: `/web/admin/production-tasks/${taskId}/steps/${stepId}/outputs`,
     params: data

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

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_production_production_work_order_stepdetailpanel_vue", "label": "StepDetailPanel.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/StepDetailPanel.vue", "source_location": "L1"}], "edges": [{"source": "users_wuzilong_work_00code_zkxw_web_admin_src_views_production_production_work_order_stepdetailpanel_vue", "target": "users_wuzilong_work_00code_zkxw_web_admin_src_views_production_production_work_order_usetaskstepeditor", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/StepDetailPanel.vue", "source_location": "L265", "weight": 1.0}], "raw_calls": []}

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

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_config_index_ts", "label": "index.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/index.ts", "source_location": "L1"}], "edges": [{"source": "users_wuzilong_work_00code_zkxw_web_admin_src_config_index_ts", "target": "users_wuzilong_work_00code_zkxw_web_admin_src_enums_appenum", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/index.ts", "source_location": "L32", "weight": 1.0}, {"source": "users_wuzilong_work_00code_zkxw_web_admin_src_config_index_ts", "target": "users_wuzilong_work_00code_zkxw_web_admin_src_types_config", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/index.ts", "source_location": "L33", "weight": 1.0}, {"source": "users_wuzilong_work_00code_zkxw_web_admin_src_config_index_ts", "target": "users_wuzilong_work_00code_zkxw_web_admin_src_config_assets_images", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/index.ts", "source_location": "L34", "weight": 1.0}, {"source": "users_wuzilong_work_00code_zkxw_web_admin_src_config_index_ts", "target": "users_wuzilong_work_00code_zkxw_web_admin_src_config_modules_fastenter", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/index.ts", "source_location": "L35", "weight": 1.0}, {"source": "users_wuzilong_work_00code_zkxw_web_admin_src_config_index_ts", "target": "users_wuzilong_work_00code_zkxw_web_admin_src_config_modules_headerbar", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/index.ts", "source_location": "L36", "weight": 1.0}], "raw_calls": []}

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

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_production_production_work_order_dispatchtaskdialog_vue", "label": "DispatchTaskDialog.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/DispatchTaskDialog.vue", "source_location": "L1"}, {"id": "production_work_order_dispatchtaskdialog_if", "label": "if()", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/DispatchTaskDialog.vue", "source_location": "L205"}], "edges": [{"source": "users_wuzilong_work_00code_zkxw_web_admin_src_views_production_production_work_order_dispatchtaskdialog_vue", "target": "production_work_order_dispatchtaskdialog_if", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/DispatchTaskDialog.vue", "source_location": "L205", "weight": 1.0}], "raw_calls": [{"caller_nid": "production_work_order_dispatchtaskdialog_if", "callee": "initEditor", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/DispatchTaskDialog.vue", "source_location": "L209"}]}

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

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_cell_storage_protocol_protocolviewdialog_vue", "label": "ProtocolViewDialog.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue", "source_location": "L1"}, {"id": "protocol_protocolviewdialog_dash", "label": "dash()", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue", "source_location": "L329"}, {"id": "protocol_protocolviewdialog_handleclosed", "label": "handleClosed()", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue", "source_location": "L1648"}], "edges": [{"source": "users_wuzilong_work_00code_zkxw_web_admin_src_views_cell_storage_protocol_protocolviewdialog_vue", "target": "protocol_protocolviewdialog_dash", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue", "source_location": "L329", "weight": 1.0}, {"source": "users_wuzilong_work_00code_zkxw_web_admin_src_views_cell_storage_protocol_protocolviewdialog_vue", "target": "protocol_protocolviewdialog_handleclosed", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue", "source_location": "L1648", "weight": 1.0}], "raw_calls": [{"caller_nid": "protocol_protocolviewdialog_dash", "callee": "unitName", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue", "source_location": "L329"}]}

File diff suppressed because it is too large
+ 0 - 0
web-admin/src/graphify-out/cache/ast/5107c337d946ea2cac20cfddf419260605ab9656390377f8770b315020e17029.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/9cd15a4ee5b6c7ebd3da16b2fef9f90a9d7fb6c8c6a97980fc6e2a5c268b4b40.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": []}

File diff suppressed because it is too large
+ 0 - 0
web-admin/src/graphify-out/cache/ast/9f5771c5a73698669f23a0d2eb95df12ebe14723da02dbae1f454e3eb88aa0d1.json


File diff suppressed because it is too large
+ 0 - 0
web-admin/src/graphify-out/cache/ast/af018cbe895cb8652c9a71972f1a29052c627557edff959e50401ee4791a1590.json


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

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_production_production_work_order_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 7 - 0
web-admin/src/styles/_route-config.scss

@@ -248,6 +248,13 @@
   overflow-y: auto;
   padding: 16px;
 }
+.route-config-right-footer {
+  padding: 12px 16px;
+  border-top: 1px solid #e5e7eb;
+  display: flex;
+  justify-content: flex-end;
+  flex-shrink: 0;
+}
 .route-config-right-empty {
   display: flex;
   flex-direction: column;

+ 132 - 15
web-admin/src/views/production/process-route/index.vue

@@ -678,16 +678,6 @@
                           </el-select>
                         </template>
                       </el-table-column>
-                      <el-table-column label="排序号" min-width="100" align="center" header-align="center">
-                        <template #default="{ row }">
-                          <el-input
-                            v-model="row.orderNum"
-                            placeholder="排序号"
-                            class="inner-cell-input"
-                            @change="saveDataCollections"
-                          />
-                        </template>
-                      </el-table-column>
                       <el-table-column label="备注" min-width="120" align="center" header-align="center">
                         <template #default="{ row }">
                           <el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" @change="saveDataCollections" />
@@ -703,6 +693,12 @@
                   </el-tab-pane>
                 </el-tabs>
               </div>
+              <div class="route-config-right-footer">
+                <el-button class="btn-main" type="primary" :loading="manualSaveLoading" @click="handleManualSave">
+                  <el-icon><Check /></el-icon>
+                  保存
+                </el-button>
+              </div>
             </template>
             <template v-else>
               <div class="route-config-right-empty">
@@ -1021,6 +1017,16 @@
     reagentMasterById.value = next
   }
 
+  /** 通过 reagentCode 在已缓存的主表数据中反查 masterId */
+  function resolveReagentMasterId(row: Record<string, any>): string | null {
+    const code = (row.reagentCode || '').trim()
+    if (!code) return null
+    for (const master of Object.values(reagentMasterById.value)) {
+      if ((master.reagentCode || '').trim() === code) return master.id
+    }
+    return null
+  }
+
   async function searchReagentOptions(query: string) {
     reagentOptionsLoading.value = true
     try {
@@ -1054,6 +1060,14 @@
       }
       return
     }
+    // reagentId 为空(后端不存储该字段)— 通过 reagentCode 反查主表真实 ID
+    const masterId = resolveReagentMasterId(row)
+    if (masterId) {
+      row.reagentId = masterId
+      // 主表选项已在 searchReagentOptions 中加载,无需重复添加
+      return
+    }
+    // 实在找不到主表记录才走 legacy 兜底
     const code = (row.reagentCode || '').trim()
     const name = (row.reagentName || '').trim()
     if (!code && !name) return
@@ -1080,6 +1094,16 @@
       saveReagents()
       return
     }
+    // 重复校验:同一试剂 ID 不可出现多次
+    const reagents = selectedStep.value?.reagents || []
+    const duplicate = reagents.find(
+      (r: Record<string, any>) => r !== row && r.reagentId === reagentId
+    )
+    if (duplicate) {
+      ElMessage.warning('该试剂已添加,不可重复选择')
+      row.reagentId = ''
+      return
+    }
     const master = reagentMasterById.value[reagentId]
     if (!master) {
       saveReagents()
@@ -1111,6 +1135,16 @@
     consumableMasterById.value = next
   }
 
+  /** 通过 consumableCode 在已缓存的主表数据中反查 masterId */
+  function resolveConsumableMasterId(row: Record<string, any>): string | null {
+    const code = (row.consumableCode || '').trim()
+    if (!code) return null
+    for (const master of Object.values(consumableMasterById.value)) {
+      if ((master.consumableCode || '').trim() === code) return master.id
+    }
+    return null
+  }
+
   async function searchConsumableOptions(query: string) {
     consumableOptionsLoading.value = true
     try {
@@ -1143,6 +1177,14 @@
       }
       return
     }
+    // consumableId 为空(后端不存储该字段)— 通过 consumableCode 反查主表真实 ID
+    const masterId = resolveConsumableMasterId(row)
+    if (masterId) {
+      row.consumableId = masterId
+      // 主表选项已在 searchConsumableOptions 中加载,无需重复添加
+      return
+    }
+    // 实在找不到主表记录才走 legacy 兜底
     const code = (row.consumableCode || '').trim()
     const name = (row.consumableName || '').trim()
     if (!code && !name) return
@@ -1169,6 +1211,16 @@
       saveConsumables()
       return
     }
+    // 重复校验:同一耗材 ID 不可出现多次
+    const consumables = selectedStep.value?.consumables || []
+    const duplicate = consumables.find(
+      (c: Record<string, any>) => c !== row && c.consumableId === consumableId
+    )
+    if (duplicate) {
+      ElMessage.warning('该耗材已添加,不可重复选择')
+      row.consumableId = ''
+      return
+    }
     const master = consumableMasterById.value[consumableId]
     if (!master) {
       saveConsumables()
@@ -1449,16 +1501,32 @@
 
   async function saveReagents() {
     if (!selectedStep.value || configSuppressSave.value) return
+    const reagents = selectedStep.value.reagents || []
+    // 按 reagentId(如有)或 reagentCode 检查重复
+    const seen = new Map<string, number>()
+    for (let i = 0; i < reagents.length; i++) {
+      const r = reagents[i]
+      const key = r.reagentId
+        ? String(r.reagentId)
+        : (r.reagentCode || '').trim()
+      if (!key) continue
+      if (seen.has(key)) {
+        ElMessage.warning(`试剂"${r.reagentName || r.reagentCode}"重复,请移除重复项`)
+        return
+      }
+      seen.set(key, i)
+    }
     try {
       await fetchUpdateStepReagents({
         stepId: selectedStep.value.id,
-        items: (selectedStep.value.reagents || []).map((r: Record<string, any>) => ({
+        items: reagents.map((r: Record<string, any>, idx: number) => ({
           reagentCode: r.reagentCode,
           reagentName: r.reagentName,
           spec: r.spec,
           standardUsage: r.standardUsage,
           unit: r.unit,
           isKeyMaterial: toApiFlag(r.isKeyMaterial),
+          orderNum: idx,
           remark: r.remark || ''
         }))
       })
@@ -1491,16 +1559,32 @@
 
   async function saveConsumables() {
     if (!selectedStep.value || configSuppressSave.value) return
+    const consumables = selectedStep.value.consumables || []
+    // 按 consumableId(如有)或 consumableCode 检查重复
+    const seen = new Map<string, number>()
+    for (let i = 0; i < consumables.length; i++) {
+      const c = consumables[i]
+      const key = c.consumableId
+        ? String(c.consumableId)
+        : (c.consumableCode || '').trim()
+      if (!key) continue
+      if (seen.has(key)) {
+        ElMessage.warning(`耗材"${c.consumableName || c.consumableCode}"重复,请移除重复项`)
+        return
+      }
+      seen.set(key, i)
+    }
     try {
       await fetchUpdateStepConsumables({
         stepId: selectedStep.value.id,
-        items: (selectedStep.value.consumables || []).map((c: Record<string, any>) => ({
+        items: consumables.map((c: Record<string, any>, idx: number) => ({
           consumableCode: c.consumableCode,
           consumableName: c.consumableName,
           specModel: c.specModel,
           standardUsage: c.standardUsage,
           unit: c.unit,
           isRequired: toApiFlag(c.isRequired),
+          orderNum: idx,
           remark: c.remark || ''
         }))
       })
@@ -1532,10 +1616,11 @@
     try {
       await fetchUpdateStepOutputs({
         stepId: selectedStep.value.id,
-        items: (selectedStep.value.outputs || []).map((o: Record<string, any>) => ({
+        items: (selectedStep.value.outputs || []).map((o: Record<string, any>, idx: number) => ({
           outputTypeId: o.outputTypeId,
           outputName: o.outputName,
           outputBarcode: o.outputBarcode,
+          orderNum: idx,
           remark: o.remark || ''
         }))
       })
@@ -1573,10 +1658,10 @@
     try {
       await fetchUpdateStepDataCollections({
         stepId: selectedStep.value.id,
-        items: (selectedStep.value.dataCollections || []).map((d: Record<string, any>) => ({
+        items: (selectedStep.value.dataCollections || []).map((d: Record<string, any>, idx: number) => ({
           collectionName: d.collectionName,
           unit: d.unit,
-          orderNum: parseOrderNum(d.orderNum),
+          orderNum: idx,
           remark: d.remark || ''
         }))
       })
@@ -1585,6 +1670,30 @@
     }
   }
 
+  // ---------------------------------------------------------------------------
+  // 右侧区域手动保存
+  // ---------------------------------------------------------------------------
+  const manualSaveLoading = ref(false)
+
+  async function handleManualSave() {
+    if (!selectedStep.value) return
+    manualSaveLoading.value = true
+    try {
+      await Promise.all([
+        saveStepBasic(),
+        saveReagents(),
+        saveConsumables(),
+        saveOutputs(),
+        saveDataCollections()
+      ])
+      ElMessage.success('保存成功')
+    } catch {
+      ElMessage.error('保存失败,请重试')
+    } finally {
+      manualSaveLoading.value = false
+    }
+  }
+
   // ---------------------------------------------------------------------------
   // 初始化
   // ---------------------------------------------------------------------------
@@ -1874,6 +1983,14 @@
     padding: 12px 16px;
   }
 
+  .route-config-right-footer {
+    padding: 12px 16px;
+    border-top: 1px solid #e5e7eb;
+    display: flex;
+    justify-content: flex-end;
+    flex-shrink: 0;
+  }
+
   .route-config-right-empty {
     display: flex;
     flex-direction: column;

+ 32 - 31
web-admin/src/views/production/production-work-order/StepDetailPanel.vue

@@ -7,23 +7,23 @@
         <el-form label-position="top" class="route-config-step-form">
           <div class="form-grid-2">
             <el-form-item label="工序名称">
-              <el-input v-model="editor.selectedStep.value.processName" @change="editor.saveStepBasic" />
+              <el-input v-model="editor.selectedStep.value.processName" />
             </el-form-item>
             <el-form-item label="工序分类">
-              <el-select v-model="editor.selectedStep.value.categoryId" placeholder="选择分类" clearable @change="editor.saveStepBasic">
+              <el-select v-model="editor.selectedStep.value.categoryId" placeholder="选择分类" clearable>
                 <el-option v-for="cat in editor.categoryOptions.value" :key="cat.id"
                   :label="cat.categoryName || cat.name" :value="cat.id" />
               </el-select>
             </el-form-item>
             <el-form-item label="描述" class="form-col-span-2">
               <el-input v-model="editor.selectedStep.value.description" type="textarea" :rows="2"
-                placeholder="工序描述" @change="editor.saveStepBasic" />
+                placeholder="工序描述" />
             </el-form-item>
             <el-form-item label="设备名称">
-              <el-input v-model="editor.selectedStep.value.equipmentName" @change="editor.saveStepBasic" />
+              <el-input v-model="editor.selectedStep.value.equipmentName" />
             </el-form-item>
             <el-form-item label="预计时长(min)">
-              <el-input v-model="editor.selectedStep.value.estimatedDuration" placeholder="如 2h" @change="editor.saveStepBasic" />
+              <el-input v-model="editor.selectedStep.value.estimatedDuration" placeholder="如 2h" />
             </el-form-item>
             <el-form-item label="关联SOP" class="form-col-span-2">
               <el-select v-model="editor.sopIdsArr.value" multiple placeholder="选择SOP文档" filterable
@@ -46,7 +46,7 @@
               <el-table-column type="index" label="序号" width="56" align="center" :index="(i: number) => i + 1" />
               <el-table-column label="试剂编码" min-width="110" align="center">
                 <template #default="{ row }">
-                  <el-input v-model="row.reagentCode" placeholder="编码" class="inner-cell-input" @change="editor.saveReagents" />
+                  <el-input v-model="row.reagentCode" placeholder="编码" class="inner-cell-input" />
                 </template>
               </el-table-column>
               <el-table-column label="试剂名称" min-width="160" align="center">
@@ -62,12 +62,12 @@
               </el-table-column>
               <el-table-column label="规格" min-width="100" align="center">
                 <template #default="{ row }">
-                  <el-input v-model="row.spec" placeholder="规格" class="inner-cell-input" @change="editor.saveReagents" />
+                  <el-input v-model="row.spec" placeholder="规格" class="inner-cell-input" />
                 </template>
               </el-table-column>
               <el-table-column label="标准用量" min-width="110" align="center">
                 <template #default="{ row }">
-                  <el-input v-model="row.standardUsage" placeholder="用量" class="inner-cell-input" @change="editor.saveReagents" />
+                  <el-input v-model="row.standardUsage" placeholder="用量" class="inner-cell-input" />
                 </template>
               </el-table-column>
               <el-table-column label="单位" min-width="120" align="center">
@@ -75,20 +75,19 @@
                   <el-select v-model="row.unit" placeholder="请选择单位" clearable filterable remote
                     reserve-keyword class="inner-cell-select" :loading="editor.itemUnitOptionsLoading.value"
                     :remote-method="editor.searchItemUnitOptions"
-                    @visible-change="(vis: boolean) => vis && editor.searchItemUnitOptions('')"
-                    @change="editor.saveReagents">
+                    @visible-change="(vis: boolean) => vis && editor.searchItemUnitOptions('')">
                     <el-option v-for="item in editor.itemUnitOptions.value" :key="item.value" :label="item.label" :value="item.value" />
                   </el-select>
                 </template>
               </el-table-column>
               <el-table-column label="关键物料" width="90" align="center">
                 <template #default="{ row }">
-                  <el-switch v-model="row.isKeyMaterial" @change="editor.saveReagents" />
+                  <el-switch v-model="row.isKeyMaterial" />
                 </template>
               </el-table-column>
               <el-table-column label="备注" min-width="120" align="center">
                 <template #default="{ row }">
-                  <el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" @change="editor.saveReagents" />
+                  <el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" />
                 </template>
               </el-table-column>
               <el-table-column label="操作" width="80" align="center">
@@ -110,7 +109,7 @@
               <el-table-column type="index" label="序号" width="56" align="center" :index="(i: number) => i + 1" />
               <el-table-column label="耗材编码" min-width="110" align="center">
                 <template #default="{ row }">
-                  <el-input v-model="row.consumableCode" placeholder="编码" class="inner-cell-input" @change="editor.saveConsumables" />
+                  <el-input v-model="row.consumableCode" placeholder="编码" class="inner-cell-input" />
                 </template>
               </el-table-column>
               <el-table-column label="耗材名称" min-width="160" align="center">
@@ -126,12 +125,12 @@
               </el-table-column>
               <el-table-column label="规格型号" min-width="110" align="center">
                 <template #default="{ row }">
-                  <el-input v-model="row.specModel" placeholder="规格型号" class="inner-cell-input" @change="editor.saveConsumables" />
+                  <el-input v-model="row.specModel" placeholder="规格型号" class="inner-cell-input" />
                 </template>
               </el-table-column>
               <el-table-column label="标准用量" min-width="110" align="center">
                 <template #default="{ row }">
-                  <el-input v-model="row.standardUsage" placeholder="用量" class="inner-cell-input" @change="editor.saveConsumables" />
+                  <el-input v-model="row.standardUsage" placeholder="用量" class="inner-cell-input" />
                 </template>
               </el-table-column>
               <el-table-column label="单位" min-width="120" align="center">
@@ -139,20 +138,19 @@
                   <el-select v-model="row.unit" placeholder="请选择单位" clearable filterable remote
                     reserve-keyword class="inner-cell-select" :loading="editor.itemUnitOptionsLoading.value"
                     :remote-method="editor.searchItemUnitOptions"
-                    @visible-change="(vis: boolean) => vis && editor.searchItemUnitOptions('')"
-                    @change="editor.saveConsumables">
+                    @visible-change="(vis: boolean) => vis && editor.searchItemUnitOptions('')">
                     <el-option v-for="item in editor.itemUnitOptions.value" :key="item.value" :label="item.label" :value="item.value" />
                   </el-select>
                 </template>
               </el-table-column>
               <el-table-column label="必需" width="80" align="center">
                 <template #default="{ row }">
-                  <el-switch v-model="row.isRequired" @change="editor.saveConsumables" />
+                  <el-switch v-model="row.isRequired" />
                 </template>
               </el-table-column>
               <el-table-column label="备注" min-width="120" align="center">
                 <template #default="{ row }">
-                  <el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" @change="editor.saveConsumables" />
+                  <el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" />
                 </template>
               </el-table-column>
               <el-table-column label="操作" width="80" align="center">
@@ -174,8 +172,7 @@
               <el-table-column type="index" label="序号" width="56" align="center" :index="(i: number) => i + 1" />
               <el-table-column label="产出类型" min-width="150" align="center">
                 <template #default="{ row }">
-                  <el-select v-model="row.outputTypeId" placeholder="选择类型" filterable class="inner-cell-select"
-                    @change="editor.saveOutputs">
+                  <el-select v-model="row.outputTypeId" placeholder="选择类型" filterable class="inner-cell-select">
                     <el-option v-for="ot in editor.outputTypeOptions.value" :key="ot.id"
                       :label="ot.typeName || ot.name" :value="ot.id" />
                   </el-select>
@@ -183,17 +180,17 @@
               </el-table-column>
               <el-table-column label="名称" min-width="150" align="center">
                 <template #default="{ row }">
-                  <el-input v-model="row.outputName" placeholder="名称" class="inner-cell-input" @change="editor.saveOutputs" />
+                  <el-input v-model="row.outputName" placeholder="名称" class="inner-cell-input" />
                 </template>
               </el-table-column>
               <el-table-column label="条码" min-width="150" align="center">
                 <template #default="{ row }">
-                  <el-input v-model="row.outputBarcode" placeholder="条码" class="inner-cell-input" @change="editor.saveOutputs" />
+                  <el-input v-model="row.outputBarcode" placeholder="条码" class="inner-cell-input" />
                 </template>
               </el-table-column>
               <el-table-column label="备注" min-width="120" align="center">
                 <template #default="{ row }">
-                  <el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" @change="editor.saveOutputs" />
+                  <el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" />
                 </template>
               </el-table-column>
               <el-table-column label="操作" width="80" align="center">
@@ -215,8 +212,7 @@
               <el-table-column type="index" label="序号" width="56" align="center" :index="(i: number) => i + 1" />
               <el-table-column label="采集名称" min-width="180" align="center">
                 <template #default="{ row }">
-                  <el-input v-model="row.collectionName" placeholder="采集项名称" class="inner-cell-input"
-                    @change="editor.saveDataCollections" />
+                  <el-input v-model="row.collectionName" placeholder="采集项名称" class="inner-cell-input" />
                 </template>
               </el-table-column>
               <el-table-column label="单位" min-width="120" align="center">
@@ -224,20 +220,19 @@
                   <el-select v-model="row.unit" placeholder="请选择单位" clearable filterable remote
                     reserve-keyword class="inner-cell-select" :loading="editor.itemUnitOptionsLoading.value"
                     :remote-method="editor.searchItemUnitOptions"
-                    @visible-change="(vis: boolean) => vis && editor.searchItemUnitOptions('')"
-                    @change="editor.saveDataCollections">
+                    @visible-change="(vis: boolean) => vis && editor.searchItemUnitOptions('')">
                     <el-option v-for="item in editor.itemUnitOptions.value" :key="item.value" :label="item.label" :value="item.value" />
                   </el-select>
                 </template>
               </el-table-column>
               <el-table-column label="排序号" min-width="100" align="center">
                 <template #default="{ row }">
-                  <el-input v-model="row.orderNum" placeholder="排序号" class="inner-cell-input" @change="editor.saveDataCollections" />
+                  <el-input v-model="row.orderNum" placeholder="排序号" class="inner-cell-input" />
                 </template>
               </el-table-column>
               <el-table-column label="备注" min-width="120" align="center">
                 <template #default="{ row }">
-                  <el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" @change="editor.saveDataCollections" />
+                  <el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" />
                 </template>
               </el-table-column>
               <el-table-column label="操作" width="80" align="center">
@@ -250,6 +245,12 @@
           </el-tab-pane>
         </el-tabs>
       </div>
+      <div class="route-config-right-footer">
+        <el-button class="btn-main" type="primary" :loading="editor.manualSaveLoading.value" @click="editor.handleManualSave">
+          <el-icon><Check /></el-icon>
+          保存
+        </el-button>
+      </div>
     </template>
     <template v-else>
       <div class="route-config-right-empty">
@@ -261,7 +262,7 @@
 </template>
 
 <script setup lang="ts">
-  import { Plus, Rank } from '@element-plus/icons-vue'
+  import { Check, Plus, Rank } from '@element-plus/icons-vue'
   import { useTaskStepEditor } from './useTaskStepEditor'
 
   defineProps<{

+ 1 - 0
web-admin/src/views/production/production-work-order/index.vue

@@ -148,6 +148,7 @@
       <el-dialog v-model="taskSelectVisible" title="生产任务列表" width="780px" destroy-on-close align-center>
         <div v-loading="taskSelectLoading" style="max-height:60vh;overflow-y:auto">
           <el-table :data="taskSelectList" stripe style="width:100%">
+            <el-table-column type="index" label="序号" width="68" align="center" :index="(i) => i + 1" />
             <el-table-column prop="taskName" label="任务名称" min-width="140" />
             <el-table-column prop="routeName" label="工艺路线" min-width="140">
               <template #default="{ row }">{{ row.routeName || '—' }}</template>

+ 87 - 46
web-admin/src/views/production/production-work-order/useTaskStepEditor.ts

@@ -146,10 +146,19 @@ export function useTaskStepEditor() {
     }
   }
   function onReagentRowSelect(row: Record<string, any>, reagentId: string) {
-    if (!reagentId) { row.reagentId = ''; saveReagents(); return }
-    if (String(reagentId).startsWith('legacy:')) { saveReagents(); return }
+    if (!reagentId) { row.reagentId = ''; return }
+    if (String(reagentId).startsWith('legacy:')) { return }
+    // 去重:同一试剂不允许出现多次(排除当前行和 legacy 占位符)
+    const dup = (selectedStep.value?.reagents || []).some(
+      (r: Record<string, any>) => r !== row && r.reagentId === reagentId && !String(r.reagentId).startsWith('legacy:')
+    )
+    if (dup) {
+      ElMessage.warning('该试剂已添加,请勿重复选择')
+      row.reagentId = ''
+      return
+    }
     const master = reagentMasterById.value[reagentId]
-    if (!master) { saveReagents(); return }
+    if (!master) { return }
     Object.assign(row, {
       reagentId: master.id, reagentCode: master.reagentCode || '',
       reagentName: master.reagentName || '', spec: master.spec || '',
@@ -158,7 +167,6 @@ export function useTaskStepEditor() {
     })
     ensureItemUnitOption(row.unit)
     ensureReagentOption(row)
-    saveReagents()
   }
 
   function mapConsumableOptionLabel(c: { consumableName?: string; consumableCode?: string }) {
@@ -208,8 +216,28 @@ export function useTaskStepEditor() {
     }
   }
   function onConsumableRowSelect(row: Record<string, any>, consumableId: string) {
-    if (!consumableId) { row.consumableId = ''; saveConsumables(); return }
-    if (String(consumableId).startsWith('legacy:')) { saveConsumables(); return }
+    if (!consumableId) { row.consumableId = ''; return }
+    if (String(consumableId).startsWith('legacy:')) { return }
+    // 去重:同一耗材不允许出现多次(排除当前行和 legacy 占位符)
+    const dup = (selectedStep.value?.consumables || []).some(
+      (c: Record<string, any>) => c !== row && c.consumableId === consumableId && !String(c.consumableId).startsWith('legacy:')
+    )
+    if (dup) {
+      ElMessage.warning('该耗材已添加,请勿重复选择')
+      row.consumableId = ''
+      return
+    }
+    const master = consumableMasterById.value[consumableId]
+    if (!master) { return }
+    Object.assign(row, {
+      consumableId: master.id, consumableCode: master.consumableCode || '',
+      consumableName: master.consumableName || '', specModel: master.specModel || '',
+      standardUsage: master.standardUsage ?? '', unit: master.unit || '',
+      remark: master.remark ?? master.description ?? ''
+    })
+    ensureItemUnitOption(row.unit)
+    ensureConsumableOption(row)
+  }
     const master = consumableMasterById.value[consumableId]
     if (!master) { saveConsumables(); return }
     Object.assign(row, {
@@ -359,19 +387,18 @@ export function useTaskStepEditor() {
     if (!selectedStep.value) return
     try {
       await fetchUpdateTaskStep(taskId.value, selectedStep.value.id, {
-        processName: selectedStep.value.processName,
-        categoryId: selectedStep.value.categoryId,
+        process_name: selectedStep.value.processName,
+        category_id: selectedStep.value.categoryId,
         description: selectedStep.value.description,
-        equipmentName: selectedStep.value.equipmentName,
-        estimatedDuration: selectedStep.value.estimatedDuration,
-        sopIds: selectedStep.value.sopIds
+        equipment_name: selectedStep.value.equipmentName,
+        estimated_duration: selectedStep.value.estimatedDuration,
+        sop_ids: selectedStep.value.sopIds
       })
     } catch { /* ignore */ }
   }
   function onSopIdsChange(val: string[]) {
     if (!selectedStep.value) return
     selectedStep.value.sopIds = val.join(',')
-    saveStepBasic()
   }
 
   function addReagentRow() {
@@ -381,19 +408,17 @@ export function useTaskStepEditor() {
       reagentId: '', reagentCode: '', reagentName: '', spec: '',
       standardUsage: '', unit: '', isKeyMaterial: false, remark: ''
     })
-    saveReagents()
   }
-  function removeReagentRow(i: number) { selectedStep.value?.reagents?.splice(i, 1); saveReagents() }
+  function removeReagentRow(i: number) { selectedStep.value?.reagents?.splice(i, 1) }
   async function saveReagents() {
     if (!selectedStep.value || configSuppressSave.value) return
     try {
-      await fetchReplaceStepReagents(taskId.value, selectedStep.value.id, {
-        items: (selectedStep.value.reagents || []).map((r: Record<string, any>) => ({
-          reagentCode: r.reagentCode, reagentName: r.reagentName, spec: r.spec,
-          standardUsage: r.standardUsage, unit: r.unit,
-          isKeyMaterial: toApiFlag(r.isKeyMaterial), remark: r.remark || ''
-        }))
-      })
+      const items = (selectedStep.value.reagents || []).map((r: Record<string, any>) => ({
+        material_code: r.reagentCode, material_name: r.reagentName, spec: r.spec,
+        standard_usage: r.standardUsage, unit: r.unit,
+        is_key_material: toApiFlag(r.isKeyMaterial), remark: r.remark || ''
+      }))
+      await fetchReplaceStepReagents(taskId.value, selectedStep.value.id, items)
     } catch { /* ignore */ }
   }
 
@@ -404,19 +429,18 @@ export function useTaskStepEditor() {
       consumableId: '', consumableCode: '', consumableName: '', specModel: '',
       standardUsage: '', unit: '', isRequired: false, remark: ''
     })
-    saveConsumables()
   }
-  function removeConsumableRow(i: number) { selectedStep.value?.consumables?.splice(i, 1); saveConsumables() }
+  function removeConsumableRow(i: number) { selectedStep.value?.consumables?.splice(i, 1) }
   async function saveConsumables() {
     if (!selectedStep.value || configSuppressSave.value) return
     try {
-      await fetchReplaceStepConsumables(taskId.value, selectedStep.value.id, {
-        items: (selectedStep.value.consumables || []).map((c: Record<string, any>) => ({
-          consumableCode: c.consumableCode, consumableName: c.consumableName, specModel: c.specModel,
-          standardUsage: c.standardUsage, unit: c.unit,
-          isRequired: toApiFlag(c.isRequired), remark: c.remark || ''
-        }))
-      })
+      const items = (selectedStep.value.consumables || []).map((c: Record<string, any>) => ({
+        material_code: c.consumableCode, material_name: c.consumableName,
+        spec_model: c.specModel,
+        standard_usage: c.standardUsage, unit: c.unit,
+        is_required: toApiFlag(c.isRequired), remark: c.remark || ''
+      }))
+      await fetchReplaceStepConsumables(taskId.value, selectedStep.value.id, items)
     } catch { /* ignore */ }
   }
 
@@ -424,18 +448,16 @@ export function useTaskStepEditor() {
     if (!selectedStep.value) return
     if (!selectedStep.value.outputs) selectedStep.value.outputs = []
     selectedStep.value.outputs.push({ outputTypeId: '', outputName: '', outputBarcode: '', remark: '' })
-    saveOutputs()
   }
-  function removeOutputRow(i: number) { selectedStep.value?.outputs?.splice(i, 1); saveOutputs() }
+  function removeOutputRow(i: number) { selectedStep.value?.outputs?.splice(i, 1) }
   async function saveOutputs() {
     if (!selectedStep.value || configSuppressSave.value) return
     try {
-      await fetchReplaceStepOutputs(taskId.value, selectedStep.value.id, {
-        items: (selectedStep.value.outputs || []).map((o: Record<string, any>) => ({
-          outputTypeId: o.outputTypeId, outputName: o.outputName,
-          outputBarcode: o.outputBarcode, remark: o.remark || ''
-        }))
-      })
+      const items = (selectedStep.value.outputs || []).map((o: Record<string, any>) => ({
+        output_type_id: o.outputTypeId, output_name: o.outputName,
+        output_barcode: o.outputBarcode, remark: o.remark || ''
+      }))
+      await fetchReplaceStepOutputs(taskId.value, selectedStep.value.id, items)
     } catch { /* ignore */ }
   }
 
@@ -443,21 +465,39 @@ export function useTaskStepEditor() {
     if (!selectedStep.value) return
     if (!selectedStep.value.dataCollections) selectedStep.value.dataCollections = []
     selectedStep.value.dataCollections.push({ collectionName: '', unit: '', orderNum: 0, remark: '' })
-    saveDataCollections()
   }
-  function removeDataCollectionRow(i: number) { selectedStep.value?.dataCollections?.splice(i, 1); saveDataCollections() }
+  function removeDataCollectionRow(i: number) { selectedStep.value?.dataCollections?.splice(i, 1) }
   async function saveDataCollections() {
     if (!selectedStep.value || configSuppressSave.value) return
     try {
-      await fetchReplaceStepDataCollection(taskId.value, selectedStep.value.id, {
-        items: (selectedStep.value.dataCollections || []).map((d: Record<string, any>) => ({
-          collectionName: d.collectionName, unit: d.unit,
-          orderNum: parseOrderNum(d.orderNum), remark: d.remark || ''
-        }))
-      })
+      const items = (selectedStep.value.dataCollections || []).map((d: Record<string, any>) => ({
+        collection_name: d.collectionName, unit: d.unit,
+        collection_value: '', remark: d.remark || ''
+      }))
+      await fetchReplaceStepDataCollection(taskId.value, selectedStep.value.id, items)
     } catch { /* ignore */ }
   }
 
+  const manualSaveLoading = ref(false)
+  async function handleManualSave() {
+    if (!selectedStep.value) return
+    manualSaveLoading.value = true
+    try {
+      await Promise.all([
+        saveStepBasic(),
+        saveReagents(),
+        saveConsumables(),
+        saveOutputs(),
+        saveDataCollections()
+      ])
+      ElMessage.success('保存成功')
+    } catch {
+      ElMessage.error('保存失败,请重试')
+    } finally {
+      manualSaveLoading.value = false
+    }
+  }
+
   async function initEditor(id: string) {
     taskId.value = id
     selectedStepId.value = ''
@@ -491,6 +531,7 @@ export function useTaskStepEditor() {
     addConsumableRow, removeConsumableRow, saveConsumables,
     addOutputRow, removeOutputRow, saveOutputs,
     addDataCollectionRow, removeDataCollectionRow, saveDataCollections,
+    manualSaveLoading, handleManualSave,
     initEditor
   }
 }

Some files were not shown because too many files changed in this diff