aps-agent/tests/golden/test_closed_loop_runtime.py

165 lines
6.5 KiB
Python
Raw Normal View History

from __future__ import annotations
from copy import deepcopy
from server.aps_domain.closed_loop_problem import build_closed_loop_problem
from server.aps_domain.mes import validate_dispatchable_version
from server.aps_domain.closed_loop_runtime import (
closed_loop_to_problem_v2,
persist_closed_loop_projection,
project_admitted_demands_to_flex_orders,
record_blocked_flex_version,
run_closed_loop_candidate,
)
BUSINESS_DATE = "2026-08-02"
def _ready_world() -> dict:
return {
"salesOrders": [{
"id": 1, "orderNo": "SO-READY", "status": "APPROVED",
"deliveryDate": "2026-08-04", "priority": 1,
"items": [{"id": 11, "productId": 1, "productCode": "FG", "quantity": 1, "unit": "PCS"}],
}],
"materials": [
{"id": 1, "code": "FG", "name": "Finished", "type": "FINISHED_PRODUCT", "unit": "PCS"},
{"id": 2, "code": "RM", "name": "Raw", "type": "RAW_MATERIAL", "unit": "PCS", "stock": 1},
],
"flexMaterials": [],
"boms": [{"id": 1, "productId": 1, "isDefault": True, "status": "ACTIVE"}],
"bomItems": [{"id": 1, "bomId": 1, "materialId": 2, "quantity": 1}],
"flexBom": [],
"routings": [{"id": 1, "productId": 1, "isDefault": True, "status": "ACTIVE", "version": "V1"}],
"routingSteps": [{"id": 1, "routingId": 1, "operationId": 1, "sequenceNo": 1, "runTimePerUnit": 10, "isExternal": False}],
"operations": [{"id": 1, "code": "CUT", "name": "Cut"}],
"flexRoutings": [],
"flexEquipment": [{"id": 1, "code": "EQ-CUT", "name": "Cutter", "status": "RUNNING", "capabilities": ["CUT"]}],
"flexCalendar": [{"startTime": "08:00", "endTime": "17:00", "breaks": [{"start": "12:00", "end": "13:00"}], "workdays": [1, 2, 3, 4, 5]}],
"purchaseOrders": [], "outsourceOrders": [], "maintenance": [],
"flexParams": {"afterDays": 7, "weights": {"weightedTardiness": 1.0}},
}
def test_ready_graph_maps_to_v2_without_mutating_world():
world = _ready_world()
before = deepcopy(world)
closed_loop = build_closed_loop_problem(world, business_date=BUSINESS_DATE)
problem = closed_loop_to_problem_v2(world, closed_loop)
assert world == before
assert len(problem.requirements) == 2
assert len(problem.activities) == 1
assert problem.activities[0].operationCode == "CUT"
assert problem.activities[0].eligibleResourceIds == ("equipment:1",)
assert len(problem.resources) == 1
assert problem.resources[0].calendarIntervals
assert problem.sourceRevision == closed_loop.source_revision
def test_projection_persists_only_planning_artifacts():
world = _ready_world()
closed_loop = build_closed_loop_problem(world, business_date=BUSINESS_DATE)
problem = closed_loop_to_problem_v2(world, closed_loop)
summary = persist_closed_loop_projection(world, closed_loop, problem)
assert summary["readyDemandCount"] == 1
assert len(world["manufacturingDemands"]) == 1
assert world["closedLoopProblems"][0]["schedulingProblemV2"]["problemId"] == closed_loop.problem_id
assert world.get("productionOrders") in (None, [])
assert world.get("workOrders") in (None, [])
assert world.get("flexWorkOrders") in (None, [])
def test_ready_demands_project_to_idempotent_flex_inputs():
world = _ready_world()
closed_loop = build_closed_loop_problem(world, business_date=BUSINESS_DATE)
first = project_admitted_demands_to_flex_orders(world, closed_loop)
second = project_admitted_demands_to_flex_orders(world, closed_loop)
assert first["count"] == 1
assert first["generated"] == 1
assert second["count"] == 1
assert second["generated"] == 0
assert second["updated"] == 1
assert len(world["flexOrders"]) == 1
row = world["flexOrders"][0]
assert row["source"] == "CLOSED_LOOP"
assert row["closedLoopProblemId"] == closed_loop.problem_id
assert row["closedLoopRequirementId"] == closed_loop.manufacturing_demands[0].requirement_id
def test_blocked_version_records_diagnostics_without_orphan_work_orders():
world = _ready_world()
world["materials"][1]["stock"] = 0
world["routings"] = []
closed_loop = build_closed_loop_problem(world, business_date=BUSINESS_DATE)
counters: dict[str, int] = {}
def next_id(kind: str) -> int:
counters[kind] = counters.get(kind, 0) + 1
return counters[kind]
result = record_blocked_flex_version(world, next_id, closed_loop)
assert result["solveStatus"] == "BLOCKED"
assert result["woCount"] == 0
assert result["vlCount"] == 0
assert world["flexWorkOrders"] == []
assert world["flexVirtualLines"] == []
assert world["flexScheduleVersions"][-1]["status"] == "DRAFT"
assert world["flexConflicts"]
def test_closed_loop_candidate_commits_only_validated_schedule():
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"
assert result["validation"]["valid"] is True
assert result["vlCount"] == 1
assert result["woCount"] == 1
version = world["flexScheduleVersions"][-1]
assert version["planningProblemId"] == result["planningProblemId"]
assert version["validationReport"]["hardViolations"] == []
work_order = world["flexWorkOrders"][0]
assert work_order["activityId"].startswith("ACT:")
assert len(work_order["activityIdentity"]) == 64
gate = validate_dispatchable_version(world, "flex", result["versionId"])
assert gate["structuralValid"] is True
assert gate["publishReady"] is True
assert gate["dispatchReady"] is False
assert [reason["code"] for reason in gate["blockingReasons"]] == ["VERSION_NOT_PUBLISHED"]
def test_closed_loop_candidate_fails_closed_before_pool_artifacts():
world = _ready_world()
world["materials"][1]["stock"] = 0
world["routings"] = []
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"] == "BLOCKED"
assert result["woCount"] == 0
assert result["vlCount"] == 0
assert world["flexWorkOrders"] == []
assert world["flexVirtualLines"] == []
assert world["closedLoopProblems"][-1]["problemId"] == result["planningProblemId"]
assert world["flexConflicts"]