aps-agent/tests/golden/test_wms_events.py

252 lines
12 KiB
Python
Raw Permalink Normal View History

# ============================================================
# round-38 方向 N:WMS 缺料事件闭环黄金测试
# 固化:幂等去重 / 乱序不重复触发 / 库存版本进证据链 / 闭环全链
# 事件 → 影响半径 → Explore 方案卡 → 人工确认 → MES 下发 → 回执
# ============================================================
from __future__ import annotations
from pathlib import Path
from server.agent_core import harness
from server.aps_domain import wms_events
from server.aps_domain.workflow import execute_confirmed, stage_schedule_publish
from server.auth.context import IdentityContext, bind_identity, reset_identity
from server.integrations.mes_stub import 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):
pass
def _baseline(store):
"""先跑一版柔性排产作为基线(缺料事件前的已发布/DRAFT 版本)。"""
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 _approve_p3(confirm_id: str) -> str:
"""P3 双人职责分离确认:默认身份第一重 + approver-2 第二重 → executionGrant。"""
first = harness.take_confirmation(confirm_id, approve=True)
assert first and first["needsSecondConfirm"] is True
token = bind_identity(IdentityContext(
2002, "approver-2", "Approver 2", "platform", roles=("planner",),
))
try:
second = harness.take_confirmation(confirm_id, approve=True)
finally:
reset_identity(token)
assert second and second.get("executionGrant")
return str(second["executionGrant"])
# ------------------------------------------------------------
# ① 幂等去重 + 乱序容忍:不重复触发
# ------------------------------------------------------------
def test_consume_dedup_and_out_of_order_no_retrigger(tmp_path: Path):
reset_wms_client(tmp_path / "wms_mirror.json")
store = _MemStore(seed_world(), tmp_path / "checkpoints.json")
_baseline(store)
client = reset_wms_client(tmp_path / "wms_mirror.json")
_seed_ledger(client)
ev1 = _shortage_event(client, "WIRE-HV", 500, 220, "2026-08-02 09:00:00")
ev2 = _shortage_event(client, "TERM-HV", 3000, 0, "2026-08-02 10:00:00")
ev3 = _shortage_event(client, "BUSBAR", 200, 180, "2026-08-02 11:00:00")
# 乱序投递:seq3 先到 → 缓冲不消费;seq1 到 → 排空 1;seq2 到 → 排空 2、3
r3 = wms_events.consume_event(store, ev3, actor="test")
assert r3["consumed"] is False and r3["duplicate"] is False
assert store.data["inventoryVersion"] == 0 # 未排空 → 版本不动
r1 = wms_events.consume_event(store, ev1, actor="test")
assert r1["consumed"] is True
assert [a["inventoryVersion"] for a in r1["applied"]] == [1]
r2 = wms_events.consume_event(store, ev2, actor="test")
assert r2["consumed"] is True
assert [a["inventoryVersion"] for a in r2["applied"]] == [2, 3]
assert store.data["inventoryVersion"] == 3
# 重复投递 → eventId 幂等,不重复消费/触发
dup = wms_events.consume_event(store, ev1, actor="test")
assert dup["duplicate"] is True and dup["consumed"] is False
assert len(store.data["wmsConsumed"]) == 3
# 证据链:每次消费唯一 inventory-version 且带 wms-event 引用
refs = [e["evidenceRefs"] for e in store.data["auditEvents"]
if e["action"] == "wms.event.consume"]
assert len(refs) == 3
versions = sorted({r[1] for r in refs if len(r) >= 2 and r[1].startswith("inventory-version:")})
assert versions == ["inventory-version:1", "inventory-version:2", "inventory-version:3"]
by_ev = {ev["eventId"]: refs[i] for i, ev in enumerate((ev1, ev2, ev3))}
assert all(f"wms-event:{eid}" in by_ev[eid] for eid in by_ev)
# 触发护栏:同一缺料事件只出一张方案卡;重复/重放不重复触发
s1 = wms_events.stage_shortage_solution(store, ev1, session_id="s1", actor="test")
assert s1["staged"] is True and s1["confirmId"]
s2 = wms_events.stage_shortage_solution(store, ev1, session_id="s1", actor="test")
assert s2["staged"] is False and s2["reason"] == "already-triggered"
assert s2["confirmId"] == s1["confirmId"]
replay_dup = wms_events.consume_shortage_event(store, ev1, session_id="s1", actor="test")
assert replay_dup["duplicate"] is True
cards = [e for e in store.data["auditEvents"] if e["action"] == "wms.event.stage"]
assert len(cards) == 1 # 只出一张方案卡
# ------------------------------------------------------------
# ③ + ④ 闭环全链:事件 → 方案卡 → 确认 → MES 下发 → 回执
# ------------------------------------------------------------
def test_shortage_closed_loop_event_to_mes_receipt(tmp_path: Path):
reset_mes_client(tmp_path / "mes_mirror.json") # MES 桩独立镜像,防幂等键串扰
reset_wms_client(tmp_path / "wms_mirror.json")
store = _MemStore(seed_world(), tmp_path / "checkpoints.json")
_baseline(store)
client = reset_wms_client(tmp_path / "wms_mirror.json")
_seed_ledger(client)
ev = _shortage_event(client, "WIRE-HV", 500, 220, "2026-08-02 09:00:00")
# 1) 事件上报 → 消费 + 库存版本 + 影响半径 + Explore 方案卡(P2)
r = wms_events.consume_shortage_event(store, ev, session_id="s-wms", actor="test")
assert r["duplicate"] is False
assert r["staged"] is True
inv_version = r["inventoryVersion"]
assert inv_version >= 1
assert r["card"]["type"] == "confirm-card"
assert r["card"]["props"]["action"] == "flex.reschedule"
assert r["card"]["props"]["power"] == "P2"
assert r["impact"]["affectedOrderCount"] >= 1
assert all(o["orderNo"] for o in r["impact"]["affectedOrders"])
assert r["impact"]["readOnly"] is True
confirm_id = r["confirmId"]
# 库存版本进入证据链(wms-event + inventory-version)
consume_audit = next(e for e in store.data["auditEvents"]
if e["action"] == "wms.event.consume")
assert f"wms-event:{ev['eventId']}" in consume_audit["evidenceRefs"]
assert f"inventory-version:{inv_version}" in consume_audit["evidenceRefs"]
stage_audit = next(e for e in store.data["auditEvents"]
if e["action"] == "wms.event.stage")
assert f"inventory-version:{inv_version}" in stage_audit["evidenceRefs"]
# 影响半径沙盒只读:主干订单/版本数不变
assert len(store.data["flexOrders"]) == 10
# 2) 人工确认(P2)→ L4 全量重排出 DRAFT 版本
versions_before = len(store.data["flexScheduleVersions"])
msg = execute_confirmed(store, confirm_id, approve=True, actor="tester")
assert "重排已执行" in msg
assert len(store.data["flexScheduleVersions"]) == versions_before + 1
new_version = store.data["flexScheduleVersions"][-1]
assert new_version["status"] == "DRAFT"
approve_audit = next(e for e in store.data["auditEvents"]
if e["action"] == "flex.reschedule.approve")
assert f"inventory-version:{inv_version}" in approve_audit["evidenceRefs"]
assert f"wms-event:{ev['eventId']}" in approve_audit["evidenceRefs"]
# 3) P2 publish establishes the execution baseline; it does not call MES.
publish_reply = stage_schedule_publish(
store,
session_id="s-wms",
actor="tester",
track="flex",
version_id=new_version["id"],
)
publish_confirm_id = next(
block.props["confirmId"]
for block in publish_reply.blocks
if block.type == "confirm-card"
)
publish_msg = execute_confirmed(
store,
publish_confirm_id,
approve=True,
actor="tester",
)
assert "已发布" in publish_msg
assert new_version["status"] == "PUBLISHED"
assert store.data.get("mesLinks") in (None, [])
# 3) MES 下发(复用 mes.dispatch,P3 门禁:双人确认)
stage = wms_events.stage_dispatch(store, ev["eventId"], session_id="s-wms", actor="test")
assert stage["staged"] is True
d_confirm_id = stage["block"].props["confirmId"]
grant = _approve_p3(d_confirm_id)
checkpoint = store.checkpoints.create(store.data, label="MES 下发前基线",
reason="auto:mes.dispatch")
res = wms_events.execute_dispatch_receipt(
store, event_id=ev["eventId"], confirm_id=d_confirm_id, execution_grant=grant,
version_id=new_version["id"], before_snapshot=str(checkpoint["pairId"]),
checkpoint_store=store.checkpoints, actor="tester")
assert len(res["created"]) >= 1
assert res["receipt"]["eventId"] == ev["eventId"]
assert res["receipt"]["scheduleVersionId"] == new_version["id"]
assert res["receipt"]["inventoryVersion"] == inv_version
# 4) 回执进入审计(全链证据:wms-event + inventory-version + schedule-version)
receipt_audits = [e for e in store.data["auditEvents"]
if e["action"] == "wms.mes.receipt"]
assert len(receipt_audits) == 1
refs = receipt_audits[0]["evidenceRefs"]
assert f"wms-event:{ev['eventId']}" in refs
assert f"inventory-version:{inv_version}" in refs
assert f"schedule-version:{new_version['id']}" in refs
assert len(receipt_audits[0]["rationale"]["externalWoIds"]) >= 1
assert len(store.data["wmsReceipts"]) == 1
# 全链审计可复现:WMS 缺料 → 影响半径 → 方案卡 → 人工确认 → MES 下发 → 回执
actions = [e["action"] for e in store.data["auditEvents"]]
for expected in ("wms.event.consume", "wms.event.stage",
"flex.reschedule.approve", "mes.dispatch", "wms.mes.receipt"):
assert expected in actions
# ------------------------------------------------------------
# 驳回分支:不重排、不留副作用
# ------------------------------------------------------------
def test_shortage_card_reject_does_not_reschedule(tmp_path: Path):
reset_wms_client(tmp_path / "wms_mirror.json")
store = _MemStore(seed_world(), tmp_path / "checkpoints.json")
_baseline(store)
client = reset_wms_client(tmp_path / "wms_mirror.json")
_seed_ledger(client)
ev = _shortage_event(client, "WIRE-HV", 500, 220, "2026-08-02 09:00:00")
r = wms_events.consume_shortage_event(store, ev, session_id="s-wms", actor="test")
assert r["staged"] is True
versions_before = len(store.data["flexScheduleVersions"])
msg = execute_confirmed(store, r["confirmId"], approve=False, actor="tester")
assert "已驳回" in msg
assert len(store.data["flexScheduleVersions"]) == versions_before
actions = [e["action"] for e in store.data["auditEvents"]]
assert "flex.reschedule.reject" in actions
assert all(e["result"] == "DENIED" for e in store.data["auditEvents"]
if e["action"] == "flex.reschedule.reject")
# 事件已消费但未触发副作用(无 MES 下发、无回执)
assert not store.data.get("wmsReceipts")