# ============================================================ # round-39 方向 P:WMS 缺料闭环 Saga 黄金测试(矩阵 78/119 验收) # 固化:全链成功 / 中途失败自动补偿(重排回滚 / MES 撤销)/ 人工接管 / 门禁暂停恢复 # ============================================================ from __future__ import annotations from pathlib import Path import pytest from server.aps_domain import saga, wms_events from server.integrations.mes_stub import get_mes_client, reset_mes_client from server.integrations.wms_stub import reset_wms_client from server.state.checkpoints import CheckpointStore from server.state.seed import seed_world class _MemStore: def __init__(self, data, checkpoint_path: Path): self.data = data self.checkpoints = CheckpointStore(str(checkpoint_path)) def next_id(self, kind: str) -> int: key = f"_c_{kind}" self.data[key] = self.data.get(key, 4000) + 1 return self.data[key] def save(self) -> None: pass @pytest.fixture(autouse=True) def _wf_checkpoints_in_tmp(monkeypatch, tmp_path: Path): """把 workflow.execute_confirmed 的内部检查点也隔离到 tmp(不污染 server/data)。""" import server.aps_domain.workflow as workflow_module cps = CheckpointStore(str(tmp_path / "wf_checkpoints.json")) monkeypatch.setattr(workflow_module, "get_checkpoints", lambda: cps) yield cps def _baseline(store): from server.aps_domain.flex import run_flex_schedule run_flex_schedule(store, sort_mode="BOTTLENECK", actor="test") return store.data["flexScheduleVersions"][-1] def _seed_ledger(client): client.upsert_inventory([ {"materialCode": "WIRE-HV", "name": "高压线材", "unit": "米", "stock": 12000, "inTransit": 0, "safetyStock": 1000}, {"materialCode": "TERM-HV", "name": "高压端子", "unit": "个", "stock": 6000, "inTransit": 2000, "safetyStock": 500}, {"materialCode": "BUSBAR", "name": "铜排", "unit": "根", "stock": 1500, "inTransit": 400, "safetyStock": 200}, ]) def _shortage_event(client, material_code: str, new_stock: float, shortage_qty: float, occurred_at: str): return client.emit_shortage(material_code, new_stock=new_stock, shortage_qty=shortage_qty, occurred_at=occurred_at) def _fresh_closure(tmp_path: Path, tag: str): """标准 WMS 缺料闭环现场:基线排产 + 库存台账 + 缺料事件。""" reset_mes_client(tmp_path / f"mes_{tag}.json") reset_wms_client(tmp_path / f"wms_{tag}.json") store = _MemStore(seed_world(), tmp_path / f"cp_{tag}.json") _baseline(store) client = reset_wms_client(tmp_path / f"wms_{tag}.json") _seed_ledger(client) ev = _shortage_event(client, "WIRE-HV", 500, 220, "2026-08-02 09:00:00") return store, ev def _audit_actions(store) -> list[str]: return [e["action"] for e in store.data["auditEvents"]] # ------------------------------------------------------------ # ① 全链成功:consume → impact → 方案卡 → 重排 → MES 下发 → 回执 # ------------------------------------------------------------ def test_wms_closure_saga_full_chain_success(tmp_path: Path): store, ev = _fresh_closure(tmp_path, "ok") versions_before = len(store.data["flexScheduleVersions"]) coord = saga.get_coordinator(store, actor="tester") record = saga.build_wms_shortage_saga(coord, ev, session_id="s-wms", actor="tester") out = coord.run(record["id"], auto_approve=True) assert out["status"] == "SUCCEEDED", out["steps"] steps = {s["name"]: s for s in out["steps"]} for name in ("consume_event", "evaluate_impact", "stage_solution", "reschedule", "stage_dispatch", "dispatch"): assert steps[name]["status"] == "SUCCEEDED", name assert steps[name]["idemKey"] # 每步都定义了幂等键 assert steps[name]["maxRetries"] >= 0 assert steps[name]["timeoutSec"] > 0 # 幂等键登记 assert f"wms-event:{ev['eventId']}" in store.data["sagaIdem"] assert f"dispatch:{ev['eventId']}" in store.data["sagaIdem"] # 闭环落地 assert len(store.data["wmsReceipts"]) == 1 assert store.data["flexScheduleVersions"][-1]["status"] == "DISPATCHED" assert len(store.data["flexScheduleVersions"]) == versions_before + 1 assert get_mes_client().status()["woCount"] >= 1 # 审计链完整(saga 事件 + 既有闭环动作) actions = _audit_actions(store) for expected in ("saga.created", "saga.succeeded", "wms.event.consume", "flex.reschedule.approve", "mes.dispatch", "wms.mes.receipt"): assert expected in actions, expected # 每步审计含 idemKey step_audit = next(e for e in store.data["auditEvents"] if e["action"] == "saga.step.succeeded") assert step_audit["rationale"]["idemKey"] # ------------------------------------------------------------ # ② 重排失败(副作用已发生)→ 自动补偿:回滚前版本 # ------------------------------------------------------------ def test_wms_closure_saga_reschedule_failure_compensates(tmp_path: Path): store, ev = _fresh_closure(tmp_path, "resched") versions_before = len(store.data["flexScheduleVersions"]) coord = saga.get_coordinator(store, actor="tester") real_flex = saga.default_registry()["flex.reschedule"]["fn"] def failing_reschedule(ctx): real_flex(ctx) # 副作用已发生(新 DRAFT 版本) raise saga.SagaStepFailed("simulated reschedule failure after effect") coord.registry["flex.reschedule"]["fn"] = failing_reschedule record = saga.build_wms_shortage_saga(coord, ev, session_id="s-wms", actor="tester") out = coord.run(record["id"], auto_approve=True) assert out["status"] == "COMPENSATED" res = next(s for s in out["steps"] if s["name"] == "reschedule") assert res["status"] == "FAILED" assert res["compensation"]["status"] == "SUCCEEDED" # 重排失败 → 回滚前版本:版本数回到基线 assert len(store.data["flexScheduleVersions"]) == versions_before # 无 MES 下发 / 无回执 assert not store.data.get("wmsReceipts") assert get_mes_client().status()["woCount"] == 0 assert "saga.compensated" in _audit_actions(store) # ------------------------------------------------------------ # ③ MES 下发失败(外部已提交)→ 自动补偿:撤销下发 + 回执补偿 + 世界回滚 # ------------------------------------------------------------ def test_wms_closure_saga_dispatch_failure_cancels_mes(tmp_path: Path): store, ev = _fresh_closure(tmp_path, "dispatch") versions_before = len(store.data["flexScheduleVersions"]) coord = saga.get_coordinator(store, actor="tester") real_dispatch = saga.default_registry()["mes.dispatch"]["fn"] def failing_dispatch(ctx): result = real_dispatch(ctx) # 外部副作用已提交(MES 工单 + 回执) ctx["step"]["result"] = result # 保留部分结果供补偿撤销 raise saga.SagaStepFailed("simulated dispatch failure after external commit") coord.registry["mes.dispatch"]["fn"] = failing_dispatch record = saga.build_wms_shortage_saga(coord, ev, session_id="s-wms", actor="tester") out = coord.run(record["id"], auto_approve=True) assert out["status"] == "COMPENSATED" disp = next(s for s in out["steps"] if s["name"] == "dispatch") assert disp["status"] == "FAILED" assert disp["compensation"]["status"] == "SUCCEEDED" # 外部 MES 工单已全部撤销(幂等 CANCELLED) wos = get_mes_client()._load().get("workOrders", []) assert wos and all(w["status"] == "CANCELLED" for w in wos) # 补偿链落地:撤销下发 + 回执补偿审计 actions = _audit_actions(store) assert "mes.cancel_dispatch" in actions assert "wms.mes.receipt.rollback" in actions # 世界回滚:无回执、版本回到基线(全链补偿到初始态,可整体重试) assert not store.data.get("wmsReceipts") assert len(store.data["flexScheduleVersions"]) == versions_before # ------------------------------------------------------------ # ④ 补偿也失败 → MANUAL_TAKEOVER(人工接管点明确) # ------------------------------------------------------------ def test_wms_closure_saga_compensation_failure_manual_takeover(tmp_path: Path): store, ev = _fresh_closure(tmp_path, "takeover") coord = saga.get_coordinator(store, actor="tester") def failing_reschedule(_ctx): raise saga.SagaStepFailed("simulated reschedule failure") def broken_rollback(_ctx): raise saga.SagaCompensationError("checkpoint restore unavailable") coord.registry["flex.reschedule"]["fn"] = failing_reschedule coord.registry["flex.reschedule.rollback"]["fn"] = broken_rollback record = saga.build_wms_shortage_saga(coord, ev, session_id="s-wms", actor="tester") out = coord.run(record["id"], auto_approve=True) assert out["status"] == "MANUAL_TAKEOVER" assert out["manualTakeover"] is True assert "compensation-failed" in out["takeoverReason"] assert "saga.manual_takeover" in _audit_actions(store) assert "saga.step.compensation_failed" in _audit_actions(store) # ------------------------------------------------------------ # ⑤ 门禁暂停(auto_approve=False)→ 中间态可见 → 恢复 # ------------------------------------------------------------ def test_wms_closure_saga_gate_pause_and_resume(tmp_path: Path): store, ev = _fresh_closure(tmp_path, "gate") coord = saga.get_coordinator(store, actor="tester") record = saga.build_wms_shortage_saga(coord, ev, session_id="s-wms", actor="tester") out = coord.run(record["id"], auto_approve=False) assert out["status"] == "WAITING_HUMAN" res = next(s for s in out["steps"] if s["name"] == "reschedule") assert res["status"] == "WAITING_HUMAN" assert res["result"] is None # 人工门禁前未执行 stage = next(s for s in out["steps"] if s["name"] == "stage_solution") assert stage["status"] == "SUCCEEDED" assert stage["result"]["confirmId"] # 中间态可见:方案卡已出 # 人工放行后自动续跑(auto_approve=True 代行确认) out2 = coord.run(record["id"], auto_approve=True) assert out2["status"] == "SUCCEEDED" assert len(store.data["wmsReceipts"]) == 1 # ------------------------------------------------------------ # ⑥ run_shortage_saga 入口:事件物化 + 幂等去重 # ------------------------------------------------------------ def test_run_shortage_saga_entry_idempotent(tmp_path: Path): store, ev = _fresh_closure(tmp_path, "entry") r1 = wms_events.run_shortage_saga(store, ev, session_id="s-wms", actor="tester") assert r1["status"] == "SUCCEEDED" assert len(store.data["wmsReceipts"]) == 1 # 同一事件重复触发 → 复用已有 saga(不重复下发) r2 = wms_events.run_shortage_saga(store, ev, session_id="s-wms", actor="tester") assert r2["id"] == r1["id"] assert len(store.data["sagas"]) == 1 assert len(store.data["wmsReceipts"]) == 1 assert len(store.data["wmsReceipts"]) == 1 # ------------------------------------------------------------ # Round 65:重排确认被拒绝时,绝不能把既有 DRAFT 当成本次新版本继续发布? # ------------------------------------------------------------ def test_wms_closure_saga_rejected_reschedule_never_publishes_old_draft( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): store, ev = _fresh_closure(tmp_path, "rejected-reschedule") versions_before = list(store.data["flexScheduleVersions"]) old_version_id = versions_before[-1]["id"] import server.aps_domain.workflow as workflow_module monkeypatch.setattr( workflow_module, "execute_confirmed", lambda *_args, **_kwargs: "证据校验未通过,未执行任何变更:source drift", ) coord = saga.get_coordinator(store, actor="tester") record = saga.build_wms_shortage_saga( coord, ev, session_id="s-wms", actor="tester" ) out = coord.run(record["id"], auto_approve=True) steps = {step["name"]: step for step in out["steps"]} assert out["status"] == "COMPENSATED" assert steps["reschedule"]["status"] == "FAILED" assert "未创建唯一的新排产版本" in steps["reschedule"]["error"] assert steps["stage_publish"]["status"] == "PENDING" assert steps["publish"]["status"] == "PENDING" assert steps["stage_dispatch"]["status"] == "PENDING" assert steps["dispatch"]["status"] == "PENDING" assert len(store.data["flexScheduleVersions"]) == len(versions_before) assert store.data["flexScheduleVersions"][-1]["id"] == old_version_id assert store.data["flexScheduleVersions"][-1]["status"] == "DRAFT" assert not store.data.get("wmsReceipts") assert get_mes_client().status()["woCount"] == 0