2026-09-03 13:14:04 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-09-03 13:27:27 +08:00
|
|
|
import hashlib
|
|
|
|
|
import os
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
2026-09-03 13:14:04 +08:00
|
|
|
from server.aps_domain.closed_loop_runtime import run_closed_loop_candidate
|
2026-09-03 13:27:27 +08:00
|
|
|
from server.aps_domain.kangni_intake import apply_site_payload_to_world, build_site_payload_from_data_dir
|
2026-09-03 13:14:04 +08:00
|
|
|
from server.engines import get_engine
|
|
|
|
|
from server.engines.optimize_engine import normalize_dispatch_rule
|
|
|
|
|
from server.engines.pool_engine import PoolEngine
|
2026-09-03 13:27:27 +08:00
|
|
|
from server.state.seed import seed_world
|
2026-09-03 13:14:04 +08:00
|
|
|
|
|
|
|
|
from tests.golden.test_closed_loop_runtime import BUSINESS_DATE, _ready_world
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _next_id_factory():
|
|
|
|
|
counters: dict[str, int] = {}
|
|
|
|
|
|
|
|
|
|
def next_id(kind: str) -> int:
|
|
|
|
|
counters[kind] = counters.get(kind, 0) + 1
|
|
|
|
|
return counters[kind]
|
|
|
|
|
|
|
|
|
|
return next_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_optimize_factory_and_rule_catalog_are_available():
|
|
|
|
|
assert get_engine("OPTIMIZE").name == "OPTIMIZE"
|
|
|
|
|
assert normalize_dispatch_rule("DELIVERY_FIRST") == "EDD"
|
|
|
|
|
assert normalize_dispatch_rule("first-in-first-out") == "FIFO"
|
|
|
|
|
assert normalize_dispatch_rule("unknown") == "EDD"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_dispatch_rules_have_stable_ordering_keys():
|
|
|
|
|
routings = [
|
|
|
|
|
{"productCode": "SHORT", "stdTimePerUnit": 2},
|
|
|
|
|
{"productCode": "LONG", "stdTimePerUnit": 10},
|
|
|
|
|
]
|
|
|
|
|
ops = {}
|
|
|
|
|
short = {"productCode": "SHORT", "quantity": 1, "dueDate": "2026-08-05", "priority": 2, "orderNo": "SO-S"}
|
|
|
|
|
long = {"productCode": "LONG", "quantity": 1, "dueDate": "2026-08-04", "priority": 1, "orderNo": "SO-L"}
|
|
|
|
|
assert sorted((long, short), key=lambda row: PoolEngine._dispatch_key("SPT", row, routings, ops)) == [short, long]
|
|
|
|
|
assert sorted((short, long), key=lambda row: PoolEngine._dispatch_key("LPT", row, routings, ops)) == [long, short]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_optimize_runs_through_closed_loop_v2_and_records_provenance():
|
|
|
|
|
world = _ready_world()
|
|
|
|
|
result = run_closed_loop_candidate(
|
|
|
|
|
world,
|
|
|
|
|
_next_id_factory(),
|
|
|
|
|
business_date=BUSINESS_DATE,
|
|
|
|
|
engine_type="OPTIMIZE",
|
|
|
|
|
sort_mode="SPT",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["solveStatus"] == "FEASIBLE"
|
|
|
|
|
assert result["engineType"] == "OPTIMIZE"
|
|
|
|
|
version = world["flexScheduleVersions"][-1]
|
|
|
|
|
assert version["engineType"] == "OPTIMIZE"
|
|
|
|
|
assert version["solverId"] == "optimize-dispatch"
|
|
|
|
|
assert version["algorithmId"] == "optimize.spt"
|
|
|
|
|
assert version["schedulingSolutionV2"]["assumptions"][0]["code"] == "OPTIMIZE_ENGINE_V1_ADAPTER"
|
|
|
|
|
assert version["schedulingSolutionV2"]["provenance"]["solverId"] == "optimize-dispatch"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_optimize_blocker_keeps_engine_identity_and_zero_artifacts():
|
|
|
|
|
world = _ready_world()
|
|
|
|
|
world["materials"][1]["stock"] = 0
|
|
|
|
|
world["routings"] = []
|
|
|
|
|
result = run_closed_loop_candidate(
|
|
|
|
|
world,
|
|
|
|
|
_next_id_factory(),
|
|
|
|
|
business_date=BUSINESS_DATE,
|
|
|
|
|
engine_type="OPTIMIZE",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["solveStatus"] == "BLOCKED"
|
|
|
|
|
assert result["engineType"] == "OPTIMIZE"
|
|
|
|
|
assert result["woCount"] == 0
|
|
|
|
|
assert world["flexScheduleVersions"][-1]["engineType"] == "OPTIMIZE"
|
|
|
|
|
assert world["flexWorkOrders"] == []
|
2026-09-03 13:27:27 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
|
|
|
not os.environ.get("APS_KANGNI_DATA_DIR"),
|
|
|
|
|
reason="set APS_KANGNI_DATA_DIR to run the external Kangni/MOM workbook test",
|
|
|
|
|
)
|
|
|
|
|
def test_real_kangni_workbooks_run_through_optimize_closed_loop_without_source_mutation():
|
|
|
|
|
data_dir = Path(os.environ["APS_KANGNI_DATA_DIR"])
|
|
|
|
|
files = sorted(data_dir.glob("*.xlsx"))
|
|
|
|
|
assert files, f"no .xlsx workbooks found in {data_dir}"
|
|
|
|
|
before = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in files}
|
|
|
|
|
|
|
|
|
|
payload = build_site_payload_from_data_dir(data_dir, station_count=4)
|
|
|
|
|
world = seed_world()
|
|
|
|
|
intake = apply_site_payload_to_world(world, payload, clear_all=True)
|
|
|
|
|
result = run_closed_loop_candidate(
|
|
|
|
|
world,
|
|
|
|
|
_next_id_factory(),
|
|
|
|
|
business_date=BUSINESS_DATE,
|
|
|
|
|
engine_type="OPTIMIZE",
|
|
|
|
|
sort_mode="EDD",
|
|
|
|
|
strict=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
after = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in files}
|
|
|
|
|
assert before == after
|
|
|
|
|
assert intake["orderCount"] == 10
|
|
|
|
|
assert intake["routingRecordCount"] == 72
|
|
|
|
|
assert intake["bomCount"] == 823
|
|
|
|
|
assert result["engineType"] == "OPTIMIZE"
|
|
|
|
|
assert result["solveStatus"] == "REJECTED"
|
|
|
|
|
assert result["solverId"] == "optimize-dispatch"
|
|
|
|
|
assert result["algorithmId"] == "optimize.edd"
|
|
|
|
|
assert result["vlCount"] == 0
|
|
|
|
|
assert result["woCount"] == 0
|
|
|
|
|
assert result["planning"]["summary"]["blockerCounts"]["SUPPLY_SHORTAGE"] > 0
|