from __future__ import annotations from datetime import datetime, timedelta from fastapi.testclient import TestClient from server.aps_domain.closed_loop_problem import build_closed_loop_problem from server.aps_domain.closed_loop_runtime import closed_loop_to_problem_v2, run_closed_loop_candidate from server.engines.external_engine import ExternalEngine, run_external_flex from server.integrations.algo_skill_stub import solve_problem BUSINESS_DATE = "2026-08-05" class _MemStore: def __init__(self, data: dict): self.data = data self.world_key = "activity-input-consumer" self._counters: dict[str, int] = {} def next_id(self, kind: str) -> int: self._counters[kind] = self._counters.get(kind, 0) + 1 return self._counters[kind] def save(self) -> None: return None def _multi_step_bom_world() -> dict: return { "businessDate": BUSINESS_DATE, "salesOrders": [{ "id": 2, "orderNo": "SO-MULTI", "status": "APPROVED", "deliveryDate": "2026-08-12", "priority": 1, "items": [{"id": 21, "productId": 1, "productCode": "FG", "quantity": 1, "unit": "PCS"}], }], "materials": [ {"id": 1, "code": "FG", "name": "Finished", "type": "FINISHED_PRODUCT", "unit": "PCS"}, {"id": 2, "code": "RM-A", "name": "Raw A", "type": "RAW_MATERIAL", "unit": "PCS", "stock": 1}, {"id": 3, "code": "RM-B", "name": "Raw B", "type": "RAW_MATERIAL", "unit": "PCS", "stock": 2}, ], "flexMaterials": [], "boms": [{"id": 1, "productId": 1, "isDefault": True, "status": "ACTIVE"}], "bomItems": [ {"id": 1, "bomId": 1, "materialId": 2, "quantity": 1}, {"id": 2, "bomId": 1, "materialId": 3, "quantity": 2}, ], "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}, {"id": 2, "routingId": 1, "operationId": 2, "sequenceNo": 2, "runTimePerUnit": 10, "isExternal": False}, {"id": 3, "routingId": 1, "operationId": 3, "sequenceNo": 3, "runTimePerUnit": 10, "isExternal": False}, ], "operations": [ {"id": 1, "code": "CUT", "name": "Cut"}, {"id": 2, "code": "WELD", "name": "Weld"}, {"id": 3, "code": "ASM", "name": "Assembly"}, ], "flexRoutings": [], "flexEquipment": [ {"id": 1, "code": "EQ-CUT", "name": "Cutter", "status": "RUNNING", "capabilities": ["CUT"]}, {"id": 2, "code": "EQ-WELD", "name": "Welder", "status": "RUNNING", "capabilities": ["WELD"]}, {"id": 3, "code": "EQ-ASM", "name": "Assembler", "status": "RUNNING", "capabilities": ["ASM"]}, ], "flexCalendar": [{"startTime": "08:00", "endTime": "17:00", "breaks": [], "workdays": [1, 2, 3, 4, 5]}], "purchaseOrders": [], "outsourceOrders": [], "maintenance": [], "scheduleVersions": [], "flexParams": {"afterDays": 7, "weights": {"weightedTardiness": 1.0}}, } def _assert_consumer_alignment(problem) -> None: make = next(row for row in problem.requirements if row.sourcingType.value == "MAKE") activities = sorted( (row for row in problem.activities if row.requirementId == make.requirementId), key=lambda row: row.sequence, ) assert len(activities) == 3 first, *later = activities assert len(first.inputRequirementIds) == 2 assert all(not row.inputRequirementIds for row in later) requirement_by_id = {row.requirementId: row for row in problem.requirements} for child_id in first.inputRequirementIds: child = requirement_by_id[child_id] assert child.requiredByActivityIds == (first.activityId,) assert child.requiredByActivityId == first.activityId def test_consumer_alignment_survives_mapping_and_pool_solve() -> None: world = _multi_step_bom_world() closed_loop = build_closed_loop_problem(world, business_date=BUSINESS_DATE, strict=True) _assert_consumer_alignment(closed_loop_to_problem_v2(world, closed_loop)) store = _MemStore(world) result = run_closed_loop_candidate( world, store.next_id, business_date=BUSINESS_DATE, order_nos=["SO-MULTI"] ) codes = {row["code"] for row in result["validation"]["hardViolations"]} assert "ACTIVITY_INPUT_CONSUMER_MISMATCH" not in codes assert result["solveStatus"] == "FEASIBLE" assert result["woCount"] == 3 def test_execute_decomposition_persists_consumer_aligned_problem(monkeypatch) -> None: import server.gateway.app as gateway_module from tests.auth_provider import install_test_auth world = _multi_step_bom_world() store = _MemStore(world) monkeypatch.setattr(gateway_module, "get_store", lambda: store) install_test_auth(monkeypatch, "tenant-activity-input-consumer") client = TestClient(gateway_module.create_app()) assert client.post("/api/auth/login", json={"username": "planner"}).status_code == 200 response = client.post("/api/mrp/decompose", json={"sessionId": "consumer-regression", "orderNo": "SO-MULTI"}) assert response.status_code == 200 problem = response.json()["result"]["closedLoop"]["schedulingProblemV2"] activities = sorted(problem["activities"], key=lambda row: row["sequence"]) first, *later = activities assert len(first["inputRequirementIds"]) == 2 assert all(not row["inputRequirementIds"] for row in later) requirement_by_id = {row["requirementId"]: row for row in problem["requirements"]} for child_id in first["inputRequirementIds"]: assert requirement_by_id[child_id]["requiredByActivityIds"] == [first["activityId"]] assert world["closedLoopProblems"][-1]["schedulingProblemV2"] == problem def test_external_solve_preserves_consumer_alignment(tmp_path, monkeypatch) -> None: monkeypatch.setenv("APS_SKILLS_PATH", str(tmp_path / "skills.json")) import server.agent_core.skills as skills_module skills_module._registry = None world = _multi_step_bom_world() store = _MemStore(world) def sequential_solution(self, skill, problem): solution = solve_problem(problem) cursor = datetime(2026, 8, 6, 8, 0) operations = [] for operation in sorted(solution.operations, key=lambda row: (row.orderId, row.seq)): start = cursor end = start + timedelta(minutes=float(operation.runMin)) operations.append(operation.model_copy(update={ "start": start.strftime("%Y-%m-%d %H:%M"), "end": end.strftime("%Y-%m-%d %H:%M"), })) cursor = end return solution.model_copy(update={"operations": tuple(operations)}) monkeypatch.setattr(ExternalEngine, "_call_skill", sequential_solution) summary = run_external_flex(store, skill_id="algo.stub") assert summary["solveStatus"] == "FEASIBLE" assert summary["validation"]["valid"] is True assert "ACTIVITY_INPUT_CONSUMER_MISMATCH" not in { row["code"] for row in summary["validation"]["hardViolations"] } def test_database_projection_preserves_consumer_graph(tmp_path, monkeypatch) -> None: monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "consumer-projection.db")) monkeypatch.delenv("APS_DB_DISABLED", raising=False) from server.db.database import reset_engine from server.db.sync import db_to_world, world_to_db reset_engine() try: world_to_db(_multi_step_bom_world(), project_code="consumer-regression") projected: dict = {} db_to_world(projected, project_code="consumer-regression") closed_loop = build_closed_loop_problem( projected, business_date=BUSINESS_DATE, order_nos=["SO-MULTI"], strict=True ) _assert_consumer_alignment(closed_loop_to_problem_v2(projected, closed_loop)) finally: reset_engine()