aps-agent/tests/golden/test_activity_input_consume...

174 lines
7.9 KiB
Python

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()