291 lines
11 KiB
Python
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"
|