275 lines
13 KiB
Python
275 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
|
|
from server.aps_domain.flex import run_flex_schedule
|
|
from server.aps_domain.readiness import check_readiness
|
|
from server.engines.pool_engine import PoolEngine
|
|
from server.state.seed import empty_world
|
|
|
|
|
|
class MemoryStore:
|
|
def __init__(self, world):
|
|
self.data, self.counters = world, world.setdefault("_testNextIds", {})
|
|
|
|
def next_id(self, key):
|
|
self.counters[key] = self.counters.get(key, 100) + 1
|
|
return self.counters[key]
|
|
|
|
def save(self):
|
|
pass
|
|
|
|
|
|
def world_fixture():
|
|
world = empty_world()
|
|
world.update({
|
|
"planningContext": {"planStart": "2026-08-04T08:00:00", "dataDate": "2026-08-04",
|
|
"horizonDays": 21, "freezeHours": 24, "trialOnly": True},
|
|
"flexMaterials": [{"id": 1, "code": "FG", "name": "产品", "stock": 0, "inTransit": 0, "type": "FINISHED_PRODUCT"},
|
|
{"id": 2, "code": "RM", "name": "板材", "unit": "件", "stock": 100, "inTransit": 0}],
|
|
"flexBom": [{"productCode": "FG", "materialCode": "RM", "quantity": 1, "lossRate": 0.02}],
|
|
"flexEquipment": [{"id": 1, "code": "E1", "name": "设备一", "status": "RUNNING", "capabilities": ["CUT", "BEND"], "availabilityRate": 1},
|
|
{"id": 2, "code": "E2", "name": "设备二", "status": "RUNNING", "capabilities": ["CUT", "BEND"], "availabilityRate": 1}],
|
|
"flexOperations": [{"code": "CUT", "name": "下料"}, {"code": "BEND", "name": "折弯"}],
|
|
"flexRoutings": [{"id": 1, "productCode": "FG", "seq": 1, "operationCode": "CUT", "stdTimePerUnit": 5, "stdTimeSource": "demo"}],
|
|
"flexOrders": [{"id": 1, "orderNo": "SO1", "productCode": "FG", "quantity": 10, "dueDate": "2026-08-24", "priority": 5, "status": "RELEASED"}],
|
|
"flexPersonnel": [{"code": "P1", "skills": ["CUT", "BEND"], "skillLevel": "L3", "shiftCode": "D"}],
|
|
"flexCalendar": [{"code": "D", "startTime": "08:00", "endTime": "17:00", "enabled": True,
|
|
"workdays": [1, 2, 3, 4, 5], "breaks": [{"start": "12:00", "end": "13:00"}]},
|
|
{"code": "N", "startTime": "18:00", "endTime": "22:00", "enabled": False, "workdays": [1, 2, 3, 4, 5]}],
|
|
})
|
|
return world
|
|
|
|
|
|
def solve(world, store=None):
|
|
store = store or MemoryStore(world)
|
|
result = PoolEngine().solve(world, store.next_id, sort_mode="ASC")
|
|
rows = [w for w in world["flexWorkOrders"] if w["versionId"] == result["versionId"]]
|
|
return result, rows
|
|
|
|
|
|
def test_edit_duration_changes_new_version_and_leaves_old_snapshot_unchanged():
|
|
world = world_fixture()
|
|
store = MemoryStore(world)
|
|
first, old_rows = solve(world, store)
|
|
old_version = copy.deepcopy(world["flexScheduleVersions"][-1])
|
|
old_rows = copy.deepcopy(old_rows)
|
|
world["flexRoutings"][0]["stdTimePerUnit"] = 9
|
|
second, new_rows = solve(world, store)
|
|
assert first["versionId"] != second["versionId"]
|
|
assert old_rows[0]["runMin"] == 50 and new_rows[0]["runMin"] == 90
|
|
assert world["flexScheduleVersions"][0] == old_version
|
|
assert world["flexWorkOrders"][0] == old_rows[0]
|
|
assert second["planStart"] == "2026-08-04 08:00"
|
|
assert second["horizonEnd"] == "2026-08-25 08:00"
|
|
assert second["freezeUntil"] == "2026-08-05 08:00"
|
|
assert second["trialOnly"] and second["productionReady"] is False
|
|
|
|
|
|
def test_loss_and_shared_stock_are_counted_and_maintained_stock_changes_admission():
|
|
world = world_fixture()
|
|
world["flexMaterials"][1]["stock"] = 20
|
|
world["flexOrders"].append({**world["flexOrders"][0], "id": 2, "orderNo": "SO2"})
|
|
result, rows = solve(world)
|
|
assert result["vlCount"] == 1
|
|
assert {r["flexOrderNo"] for r in rows} == {"SO1"}
|
|
shortage = next(c for c in world["flexConflicts"] if c["conflictType"] == "MATERIAL_SHORTAGE")
|
|
assert shortage["orderNo"] == "SO2" and shortage["requiredQuantity"] == 10.2
|
|
world["flexMaterials"][1]["stock"] = 21
|
|
assert solve(world)[0]["vlCount"] == 2
|
|
|
|
|
|
def test_transit_requires_its_recorded_date_and_never_uses_wall_clock():
|
|
world = world_fixture()
|
|
material = world["flexMaterials"][1]
|
|
material.update(stock=0, inTransit=11, expectedArrivalDate="2026-08-07")
|
|
_, rows = solve(world)
|
|
assert rows[0]["plannedStartTime"] == "2026-08-07 08:00"
|
|
del material["expectedArrivalDate"]
|
|
assert solve(world)[0]["woCount"] == 0
|
|
assert any(c["conflictType"] == "MATERIAL_ETA_UNKNOWN" for c in world["flexConflicts"])
|
|
|
|
|
|
def test_person_is_not_double_allocated_across_two_skills():
|
|
world = world_fixture()
|
|
world["flexRoutings"].append({"id": 2, "productCode": "FG2", "seq": 1, "operationCode": "BEND", "stdTimePerUnit": 5})
|
|
world["flexOrders"].append({**world["flexOrders"][0], "id": 2, "orderNo": "SO2", "productCode": "FG2"})
|
|
_, rows = solve(world)
|
|
assert len(rows) == 2
|
|
assert {r["personCode"] for r in rows} == {"P1"}
|
|
assert rows[1]["plannedStartTime"] >= rows[0]["plannedEndTime"]
|
|
|
|
|
|
def test_missing_skill_is_a_readiness_and_engine_blocker():
|
|
world = world_fixture()
|
|
world["flexPersonnel"][0]["skills"] = ["BEND"]
|
|
readiness = check_readiness(world)
|
|
assert readiness["summary"]["blocked"] == 1
|
|
assert any(i["type"] == "NO_PERSONNEL_SKILL" for i in readiness["orders"][0]["issues"])
|
|
assert readiness["summary"]["timeDemo"] == 1
|
|
assert solve(world)[0]["woCount"] == 0
|
|
|
|
|
|
def test_disabled_equipment_maintenance_and_calendar_breaks_are_consumed():
|
|
world = world_fixture()
|
|
world["flexEquipment"][0]["status"] = "INACTIVE"
|
|
world["flexMaintenance"] = [{"equipmentCode": "E2", "start": "2026-08-04 08:00", "end": "2026-08-04 10:00"}]
|
|
world["flexRoutings"][0]["stdTimePerUnit"] = 40
|
|
_, rows = solve(world)
|
|
row = rows[0]
|
|
assert row["equipmentCode"] == "E2"
|
|
assert row["plannedStartTime"] == "2026-08-04 10:00"
|
|
assert row["plannedEndTime"] == "2026-08-05 08:40"
|
|
assert row["workSegments"] == [
|
|
{"start": "2026-08-04 10:00", "end": "2026-08-04 12:00"},
|
|
{"start": "2026-08-04 13:00", "end": "2026-08-04 17:00"},
|
|
{"start": "2026-08-05 08:00", "end": "2026-08-05 08:40"},
|
|
]
|
|
|
|
|
|
def test_weekend_override_works_and_empty_override_means_rest():
|
|
world = world_fixture()
|
|
world["planningContext"]["planStart"] = "2026-08-08T08:00:00"
|
|
world["flexEquipment"][0]["status"] = "INACTIVE"
|
|
world["flexCalendarOverrides"] = [{"equipmentCode": "E2", "date": "2026-08-08",
|
|
"shifts": [{"start": "08:00", "end": "12:00"}]}]
|
|
assert solve(world)[1][0]["plannedStartTime"] == "2026-08-08 08:00"
|
|
world["flexCalendarOverrides"][0]["shifts"] = []
|
|
assert solve(world)[1][0]["plannedStartTime"] == "2026-08-10 08:00"
|
|
|
|
|
|
def test_done_step_not_repeated_and_unconfirmed_running_task_reserves_its_equipment():
|
|
world = world_fixture()
|
|
world["flexRoutings"].append({"id": 2, "productCode": "FG", "seq": 2, "operationCode": "BEND", "stdTimePerUnit": 5})
|
|
world["flexWip"] = [{"taskNo": "W1", "orderNo": "SO1", "operationCode": "CUT", "equipmentCode": "E1", "status": "DONE",
|
|
"completedQuantity": 10, "completionTime": "2026-08-04 10:30"},
|
|
{"taskNo": "W2", "orderNo": "EXTERNAL", "operationCode": "BEND", "equipmentCode": "E1", "status": "RUNNING", "personCode": "EXTERNAL-PERSON"}]
|
|
_, rows = solve(world)
|
|
assert len(rows) == 1 and rows[0]["operationCode"] == "BEND"
|
|
assert rows[0]["equipmentCode"] == "E2" and rows[0]["plannedStartTime"] == "2026-08-04 10:30"
|
|
|
|
|
|
def test_unknown_wip_predecessors_block_instead_of_restarting_from_zero():
|
|
world = world_fixture()
|
|
world["flexRoutings"].append({"id": 2, "productCode": "FG", "seq": 2, "operationCode": "BEND", "stdTimePerUnit": 5})
|
|
world["flexWip"] = [{"taskNo": "W1", "orderNo": "SO1", "operationCode": "BEND", "equipmentCode": "UNKNOWN", "status": "RUNNING", "completedQuantity": 3}]
|
|
assert solve(world)[0]["woCount"] == 0
|
|
types = {i["type"] for i in check_readiness(world)["orders"][0]["issues"]}
|
|
assert {"WIP_RESOURCE_UNKNOWN", "WIP_END_UNKNOWN", "WIP_PREDECESSORS_UNCONFIRMED"} <= types
|
|
|
|
|
|
def test_freeze_carries_approved_assignment_without_mutating_old_version():
|
|
world = world_fixture()
|
|
store = MemoryStore(world)
|
|
_, first_rows = solve(world, store)
|
|
world["flexScheduleVersions"][-1]["status"] = "APPROVED"
|
|
old = copy.deepcopy(first_rows[0])
|
|
world["flexRoutings"][0]["stdTimePerUnit"] = 90
|
|
_, second_rows = solve(world, store)
|
|
assert second_rows[0]["frozen"]
|
|
assert second_rows[0]["plannedEndTime"] == old["plannedEndTime"]
|
|
assert world["flexWorkOrders"][0] == old
|
|
|
|
|
|
def test_no_slot_never_falls_back_to_closed_calendar():
|
|
world = world_fixture()
|
|
for shift in world["flexCalendar"]:
|
|
shift["enabled"] = False
|
|
result, rows = solve(world)
|
|
assert result["solveStatus"] == "BLOCKED" and not rows
|
|
assert check_readiness(world)["summary"]["blocked"] == 1
|
|
|
|
|
|
def test_trial_source_keeps_governed_entry_in_trial_and_uses_source_date():
|
|
world = world_fixture()
|
|
result = run_flex_schedule(MemoryStore(world))
|
|
assert result["executionMode"] == "POOL_TRIAL"
|
|
assert result["trialOnly"] and result["planStart"] == "2026-08-04 08:00"
|
|
|
|
|
|
def test_fractional_effective_minutes_round_up_and_finish_within_working_time():
|
|
world = world_fixture()
|
|
for equipment in world["flexEquipment"]:
|
|
equipment["availabilityRate"] = 0.9
|
|
_, rows = solve(world)
|
|
assert rows[0]["runMin"] == 55.6
|
|
assert rows[0]["plannedEndTime"] == "2026-08-04 08:56"
|
|
|
|
|
|
def test_running_operator_unknown_reserves_eligible_people_until_expected_end():
|
|
world = world_fixture()
|
|
world["flexWip"] = [{"taskNo": "EXTERNAL", "orderNo": "EXTERNAL", "operationCode": "CUT",
|
|
"equipmentCode": "E1", "status": "RUNNING", "expectedEnd": "2026-08-04 16:00"}]
|
|
_, rows = solve(world)
|
|
assert rows[0]["plannedStartTime"] == "2026-08-04 16:00"
|
|
|
|
|
|
def test_actual_ruiyang_workbook_complete_maintenance_to_schedule_acceptance():
|
|
from scripts.round87_masterdata_e2e import verify
|
|
from tests.workbook_acceptance import load_expectations, source_for_test
|
|
|
|
expected = load_expectations()
|
|
evidence = verify(source_for_test(), expected)
|
|
assert evidence["status"] == "PASS"
|
|
assert evidence["checks"]["weekendMaintenanceConsumed"]["afterStart"] == expected["weekend"]["expectedStart"]
|
|
|
|
|
|
def test_split_wip_never_overwrites_a_batch_or_restarts_full_quantity():
|
|
world = world_fixture()
|
|
world["flexRoutings"].append({"id": 2, "productCode": "FG", "seq": 2,
|
|
"operationCode": "BEND", "stdTimePerUnit": 5})
|
|
world["flexWip"] = [
|
|
{"taskNo": "PART1", "orderNo": "SO1", "operationCode": "CUT", "equipmentCode": "E1",
|
|
"status": "RUNNING", "completedQuantity": 3, "remainingQuantity": 2,
|
|
"expectedEnd": "2026-08-04 10:00"},
|
|
{"taskNo": "PART2", "orderNo": "SO1", "operationCode": "CUT", "equipmentCode": "E2",
|
|
"status": "WAITING", "completedQuantity": 0, "remainingQuantity": 5},
|
|
]
|
|
original = copy.deepcopy(world["flexWip"])
|
|
readiness = check_readiness(world)
|
|
assert readiness["summary"]["blocked"] == 1
|
|
assert any(i["type"] == "WIP_SPLIT_UNSUPPORTED" for i in readiness["orders"][0]["issues"])
|
|
result, rows = solve(world)
|
|
assert result["solveStatus"] == "BLOCKED" and rows == []
|
|
assert world["flexWip"] == original
|
|
|
|
|
|
def test_running_task_requires_total_remaining_quantity_before_scheduling_successor():
|
|
world = world_fixture()
|
|
world["flexRoutings"].append({"id": 2, "productCode": "FG", "seq": 2,
|
|
"operationCode": "BEND", "stdTimePerUnit": 5})
|
|
world["flexWip"] = [{"taskNo": "RUN1", "orderNo": "SO1", "operationCode": "CUT", "equipmentCode": "E1",
|
|
"status": "RUNNING", "completedQuantity": 3, "expectedEnd": "2026-08-04 10:00"}]
|
|
assert solve(world)[1] == []
|
|
assert any(i["type"] == "WIP_REMAINING_UNCONFIRMED" for i in check_readiness(world)["orders"][0]["issues"])
|
|
world["flexWip"][0]["remainingQuantity"] = 2
|
|
assert solve(world)[1] == []
|
|
world["flexWip"][0]["remainingQuantity"] = 7
|
|
_, rows = solve(world)
|
|
assert len(rows) == 1 and rows[0]["operationCode"] == "BEND" and rows[0]["quantity"] == 10
|
|
assert rows[0]["plannedStartTime"] == "2026-08-04 10:00"
|
|
|
|
|
|
def test_strict_shortage_blocks_readiness_and_solver_consistently():
|
|
world = world_fixture()
|
|
world["flexMaterials"][1]["stock"] = 0
|
|
report = check_readiness(world)
|
|
assert report["summary"]["ready"] == 0 and report["summary"]["blocked"] == 1
|
|
assert solve(world)[0]["solveStatus"] == "BLOCKED"
|
|
world["flexMaterials"].pop()
|
|
assert check_readiness(world)["summary"]["blocked"] == 1
|
|
assert solve(world)[0]["solveStatus"] == "BLOCKED"
|
|
|
|
|
|
def test_overnight_shift_excludes_next_day_break_from_real_work_segments():
|
|
world = world_fixture()
|
|
world["planningContext"]["planStart"] = "2026-08-04 22:00"
|
|
world["flexPersonnel"][0]["shiftCode"] = "N"
|
|
world["flexCalendar"] = [{"shiftCode": "N", "startTime": "22:00", "endTime": "06:00", "enabled": True,
|
|
"workdays": [1, 2, 3, 4, 5], "breaks": [{"start": "01:00", "end": "02:00"}]}]
|
|
world["flexRoutings"][0]["stdTimePerUnit"] = 30
|
|
_, rows = solve(world)
|
|
assert rows[0]["plannedEndTime"] == "2026-08-05 04:00"
|
|
assert rows[0]["workSegments"] == [{"start": "2026-08-04 22:00", "end": "2026-08-05 01:00"},
|
|
{"start": "2026-08-05 02:00", "end": "2026-08-05 04:00"}]
|