aps-agent/tests/golden/test_flex_publish_mes_gate.py

239 lines
8.9 KiB
Python
Raw Normal View History

from __future__ import annotations
import copy
from pathlib import Path
import pytest
from server.agent_core import harness
from server.aps_domain.workflow import execute_confirmed, stage_schedule_publish
from server.aps_domain.mes import (
apply_dispatch,
preview_dispatch,
stage_dispatch,
validate_dispatchable_version,
)
from server.engines.pool_engine import PoolEngine
from server.integrations.mes_stub import reset_mes_client
from server.state.checkpoints import CheckpointStore
from server.state.seed import build_demo_world
BUSINESS_DATE = "2026-08-02"
class _MemStore:
def __init__(self, data: dict, checkpoint_path: Path):
self.data = data
self.checkpoints = CheckpointStore(str(checkpoint_path))
def next_id(self, kind: str) -> int:
key = f"_test_{kind}"
self.data[key] = self.data.get(key, 9000) + 1
return self.data[key]
def save(self):
pass
def _scheduled_world() -> tuple[dict, int]:
world = build_demo_world()
order = copy.deepcopy(next(item for item in world["flexOrders"] if item["productCode"] == "HV-HARNESS"))
world["flexOrders"] = [order]
counters: dict[str, int] = {}
def next_id(kind: str) -> int:
counters[kind] = counters.get(kind, 0) + 1
return counters[kind]
result = PoolEngine().solve(
world,
next_id,
sort_mode="ASC",
start_date=BUSINESS_DATE,
)
return world, result["versionId"]
def _evidence_refs(world: dict, version_id: int) -> list[str]:
validation = validate_dispatchable_version(world, "flex", version_id)
return [f"schedule-version:{version_id}", validation["evidenceRef"]]
def _approved_grant(version_id: int, evidence_refs: list[str]) -> tuple[str, str]:
from server.auth.context import IdentityContext, bind_identity, reset_identity
block = harness.stage_confirmation(
"test-flex-publish-mes",
"mes.dispatch",
{"track": "flex", "versionId": version_id, "evidenceRefs": evidence_refs},
title="MES dispatch",
summary_lines=["test"],
)
confirm_id = str(block.props["confirmId"])
first = harness.take_confirmation(confirm_id, approve=True)
token = bind_identity(IdentityContext(
9202,
"flex-approver-2",
"Flex Approver 2",
"platform",
roles=("planner",),
))
try:
second = harness.take_confirmation(confirm_id, approve=True)
finally:
reset_identity(token)
assert first and first["needsSecondConfirm"] is True
assert second and second["executionGrant"]
return confirm_id, str(second["executionGrant"])
def test_complete_draft_is_publish_ready_but_mes_preview_and_stage_fail_closed(tmp_path: Path):
world, version_id = _scheduled_world()
validation = validate_dispatchable_version(world, "flex", version_id)
assert validation["structuralValid"] is True
assert validation["publishReady"] is True
assert validation["dispatchReady"] is False
assert {item["code"] for item in validation["blockingReasons"]} == {"VERSION_NOT_PUBLISHED"}
assert validation["publishBlockingReasons"] == []
preview = preview_dispatch(world, "flex", version_id=version_id)
assert preview["items"] == []
assert preview["newCount"] == 0
assert preview["dispatchReady"] is False
store = _MemStore(world, tmp_path / "draft-checkpoints.json")
staged = stage_dispatch(store, "flex", session_id="draft-stage", actor="test")
assert staged["staged"] is False
assert staged["block"] is None
assert {item["code"] for item in staged["blockingReasons"]} == {"VERSION_NOT_PUBLISHED"}
def test_published_complete_version_is_dispatchable_and_has_stable_evidence():
world, version_id = _scheduled_world()
version = next(item for item in world["flexScheduleVersions"] if item["id"] == version_id)
version["status"] = "PUBLISHED"
first = validate_dispatchable_version(world, "flex", version_id)
second = validate_dispatchable_version(world, "flex", version_id)
assert first["structuralValid"] is True
assert first["dispatchReady"] is True
assert first["blockingReasons"] == []
assert first["eligibleWorkOrderIds"]
assert first["evidenceRef"] == second["evidenceRef"]
assert first["evidenceRef"].startswith("schedule-evidence:")
def test_orphan_work_order_and_incomplete_routing_block_publish_and_dispatch():
world, version_id = _scheduled_world()
version = next(item for item in world["flexScheduleVersions"] if item["id"] == version_id)
version["status"] = "PUBLISHED"
work_order = next(item for item in world["flexWorkOrders"] if item["versionId"] == version_id)
work_order["vlId"] = 999999
validation = validate_dispatchable_version(world, "flex", version_id)
codes = {item["code"] for item in validation["blockingReasons"]}
assert validation["structuralValid"] is False
assert validation["publishReady"] is False
assert validation["dispatchReady"] is False
assert "ORPHAN_WORK_ORDER" in codes
assert "INCOMPLETE_ROUTING" in codes
assert preview_dispatch(world, "flex", version_id=version_id)["items"] == []
def test_unresolved_hard_conflict_blocks_publish_and_dispatch():
world, version_id = _scheduled_world()
version = next(item for item in world["flexScheduleVersions"] if item["id"] == version_id)
version["status"] = "PUBLISHED"
world["flexConflicts"].append({
"id": 991,
"versionId": version_id,
"conflictType": "NO_CAPABILITY",
"severity": "CRITICAL",
"description": "No qualified equipment",
"isResolved": False,
})
version["conflictCount"] = len([
item for item in world["flexConflicts"] if item["versionId"] == version_id
])
validation = validate_dispatchable_version(world, "flex", version_id)
assert validation["structuralValid"] is True
assert validation["publishReady"] is False
assert validation["dispatchReady"] is False
assert "HARD_CONFLICT" in {item["code"] for item in validation["blockingReasons"]}
def test_apply_dispatch_rejects_work_order_drift_after_checkpoint(tmp_path: Path):
reset_mes_client(tmp_path / "mes-mirror.json")
world, version_id = _scheduled_world()
version = next(item for item in world["flexScheduleVersions"] if item["id"] == version_id)
version["status"] = "PUBLISHED"
store = _MemStore(world, tmp_path / "drift-checkpoints.json")
evidence_refs = _evidence_refs(world, version_id)
confirm_id, grant = _approved_grant(version_id, evidence_refs)
checkpoint = store.checkpoints.create(world, label="approved", reason="test")
work_order = next(item for item in world["flexWorkOrders"] if item["versionId"] == version_id)
work_order["plannedEndTime"] = "2026-08-31 23:59"
with pytest.raises(PermissionError, match="\u6f02\u79fb"):
apply_dispatch(
store,
track="flex",
actor="test",
confirm_id=confirm_id,
execution_grant=grant,
version_id=version_id,
before_snapshot=str(checkpoint["pairId"]),
evidence_refs=evidence_refs,
checkpoint_store=store.checkpoints,
)
assert world.get("mesLinks") in (None, [])
def test_flex_publish_uses_p2_confirmation_before_p3_dispatch(tmp_path: Path):
world, version_id = _scheduled_world()
store = _MemStore(world, tmp_path / "publish-checkpoints.json")
reply = stage_schedule_publish(
store, session_id="flex-publish", actor="planner",
track="flex", version_id=version_id,
)
block = next(block for block in reply.blocks if block.type == "confirm-card")
confirm_id = str(block.props["confirmId"])
version = next(row for row in world["flexScheduleVersions"] if row["id"] == version_id)
assert version["status"] == "DRAFT"
assert world.get("mesLinks") in (None, [])
text = execute_confirmed(store, confirm_id, True, actor="planner")
assert "已发布" in text
assert version["status"] == "PUBLISHED"
assert world.get("mesLinks") in (None, [])
assert validate_dispatchable_version(world, "flex", version_id)["dispatchReady"] is True
def test_flex_publish_revalidates_exact_version_and_rejects_drift(tmp_path: Path):
world, version_id = _scheduled_world()
store = _MemStore(world, tmp_path / "publish-drift-checkpoints.json")
reply = stage_schedule_publish(
store, session_id="flex-publish-drift", actor="planner",
track="flex", version_id=version_id,
)
confirm_id = str(next(block for block in reply.blocks if block.type == "confirm-card").props["confirmId"])
version = next(row for row in world["flexScheduleVersions"] if row["id"] == version_id)
work_order = next(row for row in world["flexWorkOrders"] if row["versionId"] == version_id)
work_order["plannedEndTime"] = "2026-08-31 23:59"
text = execute_confirmed(store, confirm_id, True, actor="planner")
assert "漂移" in text
assert version["status"] == "DRAFT"
assert world.get("mesLinks") in (None, [])