165 lines
6.5 KiB
Python
165 lines
6.5 KiB
Python
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"]
|