aps-agent/tests/golden/test_wms_events.py

252 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 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")