aps-agent/tests/golden/test_masterdata_consumption.py

275 lines
13 KiB
Python
Raw Permalink Normal View History

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