aps-agent/tests/golden/test_flex_publish_mes_gate.py

291 lines
11 KiB
Python

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, [])
def test_trial_version_publish_reply_explains_unmet_conditions(tmp_path: Path):
"""试排版本被拒时,回复必须带结构化发布门禁:未满足条件、解除路径与边界。"""
world, version_id = _scheduled_world()
version = next(item for item in world["flexScheduleVersions"] if item["id"] == version_id)
version["trialOnly"] = True
store = _MemStore(world, tmp_path / "trial-publish-checkpoints.json")
reply = stage_schedule_publish(
store, session_id="flex-publish-trial", actor="planner",
track="flex", version_id=version_id,
)
assert not [block for block in reply.blocks if block.type == "confirm-card"]
gate = next(block.props["publicationGate"] for block in reply.blocks if block.type == "text")
assert gate["canPublish"] is False
assert gate["statusLabel"] == "不可发布为执行基准"
assert gate["sourceLabel"] == "历史资料试排"
assert "TRIAL_ONLY" in {reason["code"] for reason in gate["reasons"]}
assert all(reason["title"] and reason["detail"] for reason in gate["reasons"])
assert len(gate["nextSteps"]) >= 3
assert "MES" in gate["boundary"]
assert version["status"] == "DRAFT"
assert world.get("mesLinks") in (None, [])
def test_publish_ready_reply_lists_passed_conditions(tmp_path: Path):
"""可发布版本同样给出门禁说明:已通过条件 + P2 审批边界。"""
world, version_id = _scheduled_world()
context = world.get("planningContext")
if isinstance(context, dict):
context["trialOnly"] = False
store = _MemStore(world, tmp_path / "ready-publish-checkpoints.json")
reply = stage_schedule_publish(
store, session_id="flex-publish-ready", actor="planner",
track="flex", version_id=version_id,
)
gate = next(block.props["publicationGate"] for block in reply.blocks if block.type == "text")
confirm = next(block for block in reply.blocks if block.type == "confirm-card")
assert gate["canPublish"] is True
assert gate["reasons"] == []
assert "发布校验:通过" in gate["checks"]
assert "版本状态:草稿" in gate["checks"]
assert gate["nextSteps"]
assert "MES" in gate["boundary"]
assert confirm.props["power"] == "P2"