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"}]