348 lines
15 KiB
Python
348 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from server.aps_domain.closed_loop_runtime import run_closed_loop_candidate
|
|
from server.aps_domain.mes import preview_dispatch, validate_dispatchable_version
|
|
from server.aps_domain.workflow import execute_confirmed, stage_schedule_publish
|
|
from server.engines.cp_engine import CpSatEngine
|
|
from tests.golden.test_closed_loop_runtime import BUSINESS_DATE, _ready_world
|
|
from tests.golden.test_cp_c3_calendar import _counter, _params, _single_line_world
|
|
from tests.golden.test_flex_publish_mes_gate import _MemStore, _scheduled_world
|
|
|
|
|
|
def _scheduled_v2_world() -> tuple[dict, int]:
|
|
world = _ready_world()
|
|
counters: dict[str, int] = {}
|
|
|
|
def next_id(kind: str) -> int:
|
|
counters[kind] = counters.get(kind, 0) + 1
|
|
return counters[kind]
|
|
|
|
result = run_closed_loop_candidate(world, next_id, business_date=BUSINESS_DATE)
|
|
assert result["solveStatus"] == "FEASIBLE"
|
|
return world, int(result["versionId"])
|
|
|
|
|
|
def _scheduled_multi_resource_v2_world() -> tuple[dict, int]:
|
|
world = _ready_world()
|
|
world.update({
|
|
"factories": [{
|
|
"id": 101, "code": "F1", "name": "Factory", "status": "ACTIVE",
|
|
"capacityMinutesPerDay": 1000,
|
|
}],
|
|
"workshops": [{
|
|
"id": 201, "code": "WS1", "name": "Workshop", "factoryId": 101,
|
|
"status": "ACTIVE", "capacityMinutesPerDay": 900,
|
|
}],
|
|
"lines": [{
|
|
"id": 301, "code": "L1", "name": "Line", "workshopId": 201,
|
|
"status": "ACTIVE", "capacityMinutesPerDay": 800,
|
|
}],
|
|
"workstations": [{
|
|
"id": 401, "code": "ST1", "name": "Station", "lineId": 301,
|
|
"status": "ACTIVE", "capacityMinutesPerDay": 700,
|
|
}],
|
|
"flexTeams": [{
|
|
"id": 501, "code": "TEAM-CUT", "name": "Cut team", "status": "ACTIVE",
|
|
"memberCount": 2, "supportOps": ["CUT"], "workstationId": 401,
|
|
}],
|
|
"flexMolds": [{
|
|
"id": 601, "code": "MOLD-CUT", "name": "Cut mold", "status": "AVAILABLE",
|
|
"quantity": 1, "operationCode": "CUT", "capabilities": ["CUT"],
|
|
"adaptableEquipment": ["EQ-CUT"], "lifeTotal": 100, "lifeUsed": 2,
|
|
"workstationId": 401,
|
|
}],
|
|
})
|
|
world["flexEquipment"][0]["workstationId"] = 401
|
|
world["routingSteps"][0].update({
|
|
"requireMold": True,
|
|
"requiredTeamMembers": 1,
|
|
"moldLifePerUnit": 2,
|
|
})
|
|
counters: dict[str, int] = {}
|
|
|
|
def next_id(kind: str) -> int:
|
|
counters[kind] = counters.get(kind, 0) + 1
|
|
return counters[kind]
|
|
|
|
result = run_closed_loop_candidate(world, next_id, business_date=BUSINESS_DATE)
|
|
assert result["solveStatus"] == "FEASIBLE"
|
|
return world, int(result["versionId"])
|
|
|
|
|
|
def _codes(validation: dict) -> set[str]:
|
|
return {str(item["code"]) for item in validation["publishBlockingReasons"]}
|
|
|
|
|
|
def _scheduled_fixed_segmented_world() -> tuple[dict, int]:
|
|
world, start_date = _single_line_world(quantity=500, day_shift_only=True)
|
|
result = CpSatEngine().solve(world, _params(start_date), _counter())
|
|
assert result.solveStatus in {"OPTIMAL", "FEASIBLE"}
|
|
version = next(row for row in world["scheduleVersions"] if row["id"] == result.versionId)
|
|
version["status"] = "PUBLISHED"
|
|
return world, int(result.versionId)
|
|
|
|
|
|
def test_mes_evidence_legacy_compatibility_is_limited_to_explicit_demo_world():
|
|
world, version_id = _scheduled_world()
|
|
|
|
demo_gate = validate_dispatchable_version(world, "flex", version_id)
|
|
assert demo_gate["publishReady"] is True
|
|
assert demo_gate["evidenceSummary"]["mode"] == "LEGACY_DEMO_COMPAT"
|
|
|
|
world["factories"][0]["name"] = "????"
|
|
real_gate = validate_dispatchable_version(world, "flex", version_id)
|
|
assert real_gate["publishReady"] is False
|
|
assert real_gate["dispatchReady"] is False
|
|
assert "V2_EVIDENCE_REQUIRED" in _codes(real_gate)
|
|
assert real_gate["evidenceRef"] != demo_gate["evidenceRef"]
|
|
|
|
|
|
def test_fixed_cp_segments_are_in_mes_evidence_and_drift_fails_closed():
|
|
world, version_id = _scheduled_fixed_segmented_world()
|
|
|
|
baseline = validate_dispatchable_version(world, "fixed", version_id)
|
|
preview = preview_dispatch(world, "fixed", version_id)
|
|
|
|
assert baseline["publishReady"] is True
|
|
assert baseline["dispatchReady"] is True
|
|
assert preview["dispatchReady"] is True
|
|
assert preview["items"][0]["segments"] == world["workOrders"][0]["plannedSegments"]
|
|
assert preview["items"][0]["processingMinutes"] == 280
|
|
assert preview["items"][0]["elapsedSpanMinutes"] == 340
|
|
assert preview["items"][0]["pauseMinutes"] == 60
|
|
|
|
world["workOrders"][0]["plannedSegments"][0]["durationMin"] += 1
|
|
drifted = validate_dispatchable_version(world, "fixed", version_id)
|
|
|
|
assert drifted["publishReady"] is False
|
|
assert drifted["dispatchReady"] is False
|
|
assert "INVALID_PLANNED_SEGMENTS" in _codes(drifted)
|
|
assert drifted["evidenceRef"] != baseline["evidenceRef"]
|
|
assert preview_dispatch(world, "fixed", version_id)["items"] == []
|
|
|
|
|
|
def test_fixed_cp_publish_revalidates_staged_evidence_and_rejects_segment_drift(
|
|
tmp_path: Path,
|
|
):
|
|
world, version_id = _scheduled_fixed_segmented_world()
|
|
version = next(row for row in world["scheduleVersions"] if row["id"] == version_id)
|
|
version["status"] = "DRAFT"
|
|
store = _MemStore(world, tmp_path / "fixed-publish-checkpoints.json")
|
|
reply = stage_schedule_publish(
|
|
store,
|
|
session_id="fixed-publish-drift",
|
|
actor="planner",
|
|
track="fixed",
|
|
version_id=version_id,
|
|
)
|
|
confirm_id = str(
|
|
next(block for block in reply.blocks if block.type == "confirm-card").props["confirmId"]
|
|
)
|
|
|
|
world["workOrders"][0]["plannedSegments"][0]["durationMin"] += 1
|
|
text = execute_confirmed(store, confirm_id, True, actor="planner")
|
|
|
|
assert "结构校验" in text or "漂移" in text
|
|
assert version["status"] == "DRAFT"
|
|
|
|
|
|
def test_mes_evidence_v2_digest_is_stable_and_binds_planning_solution_validation_resources():
|
|
world, version_id = _scheduled_v2_world()
|
|
|
|
first = validate_dispatchable_version(world, "flex", version_id)
|
|
second = validate_dispatchable_version(world, "flex", version_id)
|
|
|
|
assert first["publishReady"] is True
|
|
assert first["dispatchReady"] is False # DRAFT 尚未完成 P2 发布
|
|
assert first["evidenceRef"] == second["evidenceRef"]
|
|
assert first["evidenceSummary"] == second["evidenceSummary"]
|
|
summary = first["evidenceSummary"]
|
|
version = world["flexScheduleVersions"][-1]
|
|
assert summary["mode"] == "V2_FAIL_CLOSED"
|
|
assert summary["planningProblemId"] == version["planningProblemId"]
|
|
assert summary["planningSourceHash"] == version["planningSourceHash"]
|
|
assert summary["solveStatus"] == "FEASIBLE"
|
|
for key in (
|
|
"problemDigest",
|
|
"solutionDigest",
|
|
"validationDigest",
|
|
"workOrderIdentityDigest",
|
|
"plannedResourceConstraintDigest",
|
|
"currentResourceConstraintDigest",
|
|
"currentValidationDigest",
|
|
"currentOperationIdentityHash",
|
|
):
|
|
assert len(summary[key]) == 64
|
|
assert summary["plannedResourceConstraintDigest"] == summary["currentResourceConstraintDigest"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("field", "replacement", "expected_code"),
|
|
[
|
|
("activityId", "ACT:TAMPERED:999", "WORK_ORDER_ACTIVITY_NOT_IN_SOLUTION"),
|
|
("activityIdentity", "0" * 64, "WORK_ORDER_ACTIVITY_IDENTITY_DRIFT"),
|
|
("closedLoopRequirementId", "REQ-TAMPERED", "WORK_ORDER_REQUIREMENT_ID_DRIFT"),
|
|
],
|
|
)
|
|
def test_mes_evidence_v2_work_order_identity_tampering_fails_closed_and_changes_evidence(
|
|
field: str,
|
|
replacement: str,
|
|
expected_code: str,
|
|
):
|
|
world, version_id = _scheduled_v2_world()
|
|
baseline = validate_dispatchable_version(world, "flex", version_id)
|
|
|
|
world["flexWorkOrders"][0][field] = replacement
|
|
drifted = validate_dispatchable_version(world, "flex", version_id)
|
|
|
|
assert drifted["publishReady"] is False
|
|
assert drifted["dispatchReady"] is False
|
|
assert expected_code in _codes(drifted)
|
|
assert drifted["evidenceRef"] != baseline["evidenceRef"]
|
|
assert drifted["evidenceSummary"]["workOrderIdentityDigest"] != baseline["evidenceSummary"]["workOrderIdentityDigest"]
|
|
|
|
|
|
@pytest.mark.parametrize("field", ["activityId", "activityIdentity", "closedLoopRequirementId"])
|
|
def test_mes_evidence_v2_missing_work_order_identity_field_fails_closed(field: str):
|
|
world, version_id = _scheduled_v2_world()
|
|
world["flexWorkOrders"][0][field] = None
|
|
|
|
validation = validate_dispatchable_version(world, "flex", version_id)
|
|
|
|
assert validation["publishReady"] is False
|
|
assert validation["dispatchReady"] is False
|
|
assert "INCOMPLETE_V2_WORK_ORDER_IDENTITY" in _codes(validation)
|
|
|
|
|
|
def test_mes_evidence_v2_conflicting_maintenance_fails_closed_and_changes_evidence():
|
|
world, version_id = _scheduled_v2_world()
|
|
baseline = validate_dispatchable_version(world, "flex", version_id)
|
|
activity = world["flexScheduleVersions"][-1]["schedulingSolutionV2"]["activities"][0]
|
|
equipment = world["flexEquipment"][0]
|
|
world["maintenance"].append({
|
|
"id": 9001,
|
|
"equipmentId": equipment["id"],
|
|
"equipmentCode": equipment["code"],
|
|
"startTime": activity["start"],
|
|
"endTime": activity["end"],
|
|
"status": "PLANNED",
|
|
})
|
|
|
|
drifted = validate_dispatchable_version(world, "flex", version_id)
|
|
|
|
assert drifted["publishReady"] is False
|
|
assert drifted["dispatchReady"] is False
|
|
assert "CURRENT_RESOURCE_CONSTRAINT_DRIFT" in _codes(drifted)
|
|
assert "CURRENT_RESOURCE_MAINTENANCE_VIOLATION" in _codes(drifted)
|
|
assert drifted["evidenceRef"] != baseline["evidenceRef"]
|
|
assert drifted["evidenceSummary"]["currentResourceConstraintDigest"] != baseline["evidenceSummary"]["currentResourceConstraintDigest"]
|
|
|
|
|
|
def test_mes_evidence_v2_calendar_drift_fails_closed_and_changes_evidence():
|
|
world, version_id = _scheduled_v2_world()
|
|
baseline = validate_dispatchable_version(world, "flex", version_id)
|
|
world["flexCalendar"][0]["startTime"] = "09:00"
|
|
|
|
drifted = validate_dispatchable_version(world, "flex", version_id)
|
|
|
|
assert drifted["publishReady"] is False
|
|
assert drifted["dispatchReady"] is False
|
|
assert "CURRENT_RESOURCE_CONSTRAINT_DRIFT" in _codes(drifted)
|
|
assert "CURRENT_RESOURCE_CALENDAR_VIOLATION" in _codes(drifted)
|
|
assert drifted["evidenceRef"] != baseline["evidenceRef"]
|
|
assert drifted["evidenceSummary"]["currentResourceConstraintDigest"] != baseline["evidenceSummary"]["currentResourceConstraintDigest"]
|
|
|
|
|
|
def test_mes_evidence_v2_equipment_capability_drift_fails_closed_and_changes_evidence():
|
|
world, version_id = _scheduled_v2_world()
|
|
baseline = validate_dispatchable_version(world, "flex", version_id)
|
|
world["flexEquipment"][0]["capabilities"] = []
|
|
|
|
drifted = validate_dispatchable_version(world, "flex", version_id)
|
|
|
|
assert drifted["publishReady"] is False
|
|
assert drifted["dispatchReady"] is False
|
|
assert "CURRENT_RESOURCE_CONSTRAINT_DRIFT" in _codes(drifted)
|
|
assert "CURRENT_RESOURCE_CAPABILITY_MISMATCH" in _codes(drifted)
|
|
assert drifted["evidenceRef"] != baseline["evidenceRef"]
|
|
assert drifted["evidenceSummary"]["currentResourceConstraintDigest"] != baseline["evidenceSummary"]["currentResourceConstraintDigest"]
|
|
|
|
|
|
def test_mes_evidence_v2_normalizes_complete_multi_resource_solution_and_snapshot():
|
|
world, version_id = _scheduled_multi_resource_v2_world()
|
|
baseline = validate_dispatchable_version(world, "flex", version_id)
|
|
|
|
assert baseline["publishReady"] is True
|
|
assert baseline["evidenceSummary"]["currentResourceKinds"] == [
|
|
"EQUIPMENT", "FACTORY", "LINE", "TEAM", "TOOLING", "WORKSHOP", "WORKSTATION",
|
|
]
|
|
allocations = world["flexScheduleVersions"][-1]["schedulingSolutionV2"]["activities"][0]["resourceAllocations"]
|
|
assert {item["kind"] for item in allocations} == {"EQUIPMENT", "TEAM", "TOOLING"}
|
|
|
|
allocations.reverse()
|
|
reordered = validate_dispatchable_version(world, "flex", version_id)
|
|
|
|
assert reordered["publishReady"] is True
|
|
assert reordered["evidenceSummary"]["solutionDigest"] == baseline["evidenceSummary"]["solutionDigest"]
|
|
assert reordered["evidenceSummary"]["solutionActivityDigest"] == baseline["evidenceSummary"]["solutionActivityDigest"]
|
|
assert reordered["evidenceRef"] == baseline["evidenceRef"]
|
|
|
|
|
|
def test_mes_evidence_v2_observed_snapshot_automatically_covers_future_resource_fields():
|
|
world, version_id = _scheduled_multi_resource_v2_world()
|
|
baseline = validate_dispatchable_version(world, "flex", version_id)
|
|
world["flexTeams"][0]["futureDispatchConstraint"] = {"mode": "STRICT", "limit": 7}
|
|
|
|
changed = validate_dispatchable_version(world, "flex", version_id)
|
|
|
|
assert changed["publishReady"] is True # Unknown field is evidenced, not guessed as a constraint.
|
|
assert changed["evidenceSummary"]["currentResourceSnapshotDigest"] != baseline["evidenceSummary"]["currentResourceSnapshotDigest"]
|
|
assert changed["evidenceRef"] != baseline["evidenceRef"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("field", "replacement"),
|
|
[("teamCode", "TEAM-OTHER"), ("moldCode", "MOLD-OTHER")],
|
|
)
|
|
def test_mes_evidence_v2_work_order_binds_every_resource_allocation(field: str, replacement: str):
|
|
world, version_id = _scheduled_multi_resource_v2_world()
|
|
baseline = validate_dispatchable_version(world, "flex", version_id)
|
|
world["flexWorkOrders"][0][field] = replacement
|
|
|
|
changed = validate_dispatchable_version(world, "flex", version_id)
|
|
|
|
assert changed["publishReady"] is False
|
|
assert "WORK_ORDER_RESOURCE_ALLOCATION_DRIFT" in _codes(changed)
|
|
assert changed["evidenceRef"] != baseline["evidenceRef"]
|
|
|
|
|
|
def test_mes_evidence_v2_team_tooling_and_hierarchy_drift_change_normalized_snapshot():
|
|
world, version_id = _scheduled_multi_resource_v2_world()
|
|
baseline = validate_dispatchable_version(world, "flex", version_id)
|
|
|
|
world["flexTeams"][0]["memberCount"] = 1
|
|
team_drift = validate_dispatchable_version(world, "flex", version_id)
|
|
assert team_drift["publishReady"] is False
|
|
assert "CURRENT_RESOURCE_CONSTRAINT_DRIFT" in _codes(team_drift)
|
|
assert team_drift["evidenceSummary"]["currentResourceConstraintDigest"] != baseline["evidenceSummary"]["currentResourceConstraintDigest"]
|
|
|
|
world, version_id = _scheduled_multi_resource_v2_world()
|
|
baseline = validate_dispatchable_version(world, "flex", version_id)
|
|
world["flexMolds"][0]["lifeUsed"] += 1
|
|
tooling_drift = validate_dispatchable_version(world, "flex", version_id)
|
|
assert tooling_drift["publishReady"] is False
|
|
assert "CURRENT_RESOURCE_CONSTRAINT_DRIFT" in _codes(tooling_drift)
|
|
assert tooling_drift["evidenceSummary"]["currentResourceConstraintDigest"] != baseline["evidenceSummary"]["currentResourceConstraintDigest"]
|
|
|
|
world, version_id = _scheduled_multi_resource_v2_world()
|
|
baseline = validate_dispatchable_version(world, "flex", version_id)
|
|
world["lines"][0]["capacityMinutesPerDay"] = 5
|
|
hierarchy_drift = validate_dispatchable_version(world, "flex", version_id)
|
|
assert hierarchy_drift["publishReady"] is False
|
|
assert "CURRENT_RESOURCE_DAILY_CAPACITY_EXCEEDED" in _codes(hierarchy_drift)
|
|
assert hierarchy_drift["evidenceSummary"]["currentResourceConstraintDigest"] != baseline["evidenceSummary"]["currentResourceConstraintDigest"]
|