"""Integration helpers for Closed-loop Scheduling Kernel v1. The module bridges the pure W1 requirement/supply graph to SchedulingProblemV2 without mutating the source world. Mutation helpers are explicit and are used only from governed P1/P2 workflows. """ from __future__ import annotations from collections import defaultdict from copy import deepcopy from datetime import date, datetime, time, timedelta from typing import Any, Iterable, Mapping from zoneinfo import ZoneInfo from server.aps_domain.closed_loop_problem import ClosedLoopProblem, build_closed_loop_problem from server.aps_domain.scheduling_problem_v2 import ( ActivityResourceRequirement, Assumption, ConstraintPolicy, HardViolation, ObjectivePolicy, OperationActivity, PeggingAllocation, Requirement, Resource, ResourceKind, RoutingStatus, ScheduledActivity, ScheduledResourceAllocation, SchedulingProblemV2, SchedulingSolutionV2, SolutionProvenance, SolveStatus, SourceFingerprint, SourcingType, SupplyDecision, SupplyEvent, SupplySource, TimeInterval, UnscheduledRequirement, operation_activity_identity, scheduling_problem_hash, ) from server.aps_domain.scheduling_validator import validate_solution from server.engines.pool_engine import PoolEngine from server.engines.optimize_engine import OptimizeEngine World = dict[str, Any] _TZ = ZoneInfo("Asia/Shanghai") _SOURCE_HASH_RE = 64 def _as_day(value: str | date) -> date: if isinstance(value, date): return value return date.fromisoformat(str(value)[:10]) def _at(day: date, hhmm: str, *, default: time = time(8, 0)) -> datetime: try: hour, minute = (int(part) for part in str(hhmm).split(":", 1)) local_time = time(hour, minute) except (TypeError, ValueError): local_time = default return datetime.combine(day, local_time, tzinfo=_TZ) def _required_at(value: str, *, end_of_day: bool = True) -> datetime: day = _as_day(value) return datetime.combine(day, time(18, 0) if end_of_day else time(8, 0), tzinfo=_TZ) def _calendar_intervals(world: Mapping[str, Any], start: date, days: int) -> tuple[TimeInterval, ...]: templates = world.get("flexCalendar") or [] if not templates: templates = [{"startTime": "08:00", "endTime": "17:00", "workdays": [1, 2, 3, 4, 5]}] intervals: list[TimeInterval] = [] for offset in range(max(1, days)): day = start + timedelta(days=offset) weekday = day.isoweekday() for template in templates: if weekday not in {int(value) for value in template.get("workdays") or range(1, 8)}: continue shift_start = _at(day, str(template.get("startTime") or "08:00")) shift_end = _at(day, str(template.get("endTime") or "17:00"), default=time(17, 0)) if shift_end <= shift_start: shift_end += timedelta(days=1) cursor = shift_start breaks = sorted(template.get("breaks") or [], key=lambda row: str(row.get("start") or "")) for pause in breaks: pause_start = _at(day, str(pause.get("start") or "")) pause_end = _at(day, str(pause.get("end") or "")) if pause_end <= pause_start: pause_end += timedelta(days=1) if cursor < pause_start < shift_end: intervals.append(TimeInterval(start=cursor, end=min(pause_start, shift_end), label="SHIFT")) cursor = max(cursor, pause_end) if cursor < shift_end: intervals.append(TimeInterval(start=cursor, end=shift_end, label="SHIFT")) return tuple(intervals) def _maintenance_intervals(world: Mapping[str, Any], equipment: Mapping[str, Any]) -> tuple[TimeInterval, ...]: equipment_ids = {str(equipment.get("id") or ""), str(equipment.get("code") or "")} rows: list[TimeInterval] = [] for item in world.get("maintenance") or []: ref = str(item.get("equipmentId") or item.get("equipmentCode") or item.get("resourceId") or "") if ref not in equipment_ids: continue start_raw = item.get("startTime") or item.get("start") or item.get("plannedStart") end_raw = item.get("endTime") or item.get("end") or item.get("plannedEnd") if not start_raw or not end_raw: continue start = datetime.fromisoformat(str(start_raw)).replace(tzinfo=_TZ) if "T" not in str(start_raw) and "+" not in str(start_raw) else datetime.fromisoformat(str(start_raw)) end = datetime.fromisoformat(str(end_raw)).replace(tzinfo=_TZ) if "T" not in str(end_raw) and "+" not in str(end_raw) else datetime.fromisoformat(str(end_raw)) if start.tzinfo is None: start = start.replace(tzinfo=_TZ) if end.tzinfo is None: end = end.replace(tzinfo=_TZ) if end > start: rows.append(TimeInterval(start=start, end=end, label="MAINTENANCE")) return tuple(rows) def _source_fields(source_revision: str, source_ref: str) -> dict[str, str]: source_hash = source_revision if len(source_revision) == _SOURCE_HASH_RE else ("0" * _SOURCE_HASH_RE) return {"sourceRef": source_ref, "sourceRevision": source_revision, "sourceHash": source_hash} def _routing_rows( world: Mapping[str, Any], product_code: str, product_id: Any = None, ) -> list[dict[str, Any]]: rows = [ dict(row) for row in world.get("flexRoutings") or [] if str(row.get("productCode") or "") == product_code ] if rows: return sorted(rows, key=lambda row: int(row.get("seq") or row.get("sequenceNo") or 0)) headers = [ row for row in world.get("routings") or [] if row.get("productId") == product_id and row.get("isDefault", True) and str(row.get("status") or "ACTIVE").upper() == "ACTIVE" ] if not headers: return [] header = headers[0] operations = {row.get("id"): row for row in world.get("operations") or []} for step in world.get("routingSteps") or []: if step.get("routingId") != header.get("id") or step.get("isExternal"): continue operation = operations.get(step.get("operationId")) or {} rows.append({ "routingId": header.get("id"), "routingVersion": header.get("version") or header.get("versionName") or "CONFIRMED", "productCode": product_code, "seq": step.get("sequenceNo"), "operationId": step.get("operationId"), "operationCode": operation.get("code") or step.get("operationCode"), "stdTimePerUnit": step.get("runTimePerUnit") or operation.get("standardTime") or 1.0, "continuous": step.get("continuous", False), "requireMold": bool(step.get("requireMold") or step.get("requiresTooling")), "requiredTeamMembers": step.get("requiredTeamMembers") or step.get("teamMembers") or 1.0, "moldLifePerUnit": step.get("moldLifePerUnit") or step.get("toolingLifePerUnit") or 1.0, }) return sorted(rows, key=lambda row: int(row.get("seq") or row.get("sequenceNo") or 0)) def _positive_number(*values: Any, default: float | None = None) -> float | None: for value in values: if value in (None, ""): continue try: parsed = float(value) except (TypeError, ValueError): continue if parsed > 0: return parsed return default def _active_resource_row(row: Mapping[str, Any]) -> bool: return str(row.get("status") or "ACTIVE").upper() not in { "CLOSED", "DISABLED", "INACTIVE", "RETIRED", "STOPPED", } def _hierarchy_resources( world: Mapping[str, Any], calendar: tuple[TimeInterval, ...], source_revision: str, ) -> tuple[list[Resource], dict[ResourceKind, dict[str, str]], str | None]: """Build FACTORY?WORKSHOP?LINE?WORKSTATION resources from master data.""" definitions = ( (ResourceKind.FACTORY, "factories", None, None), (ResourceKind.WORKSHOP, "workshops", ResourceKind.FACTORY, "factoryId"), (ResourceKind.LINE, "lines", ResourceKind.WORKSHOP, "workshopId"), (ResourceKind.WORKSTATION, "workstations", ResourceKind.LINE, "lineId"), ) resources: list[Resource] = [] lookups: dict[ResourceKind, dict[str, str]] = defaultdict(dict) first_by_kind: dict[ResourceKind, str] = {} last_default_parent: str | None = None for kind, collection, parent_kind, parent_field in definitions: for row in world.get(collection) or []: if not isinstance(row, Mapping) or not _active_resource_row(row): continue key = row.get("id") if row.get("id") not in (None, "") else row.get("code") if key in (None, ""): continue resource_id = f"{kind.value.lower()}:{key}" parent_id = None if parent_kind is not None: parent_value = row.get(parent_field or "") parent_id = lookups[parent_kind].get(str(parent_value)) if parent_value not in (None, "") else None parent_id = parent_id or first_by_kind.get(parent_kind) daily_capacity = _positive_number( row.get("capacityMinutesPerDay"), row.get("capacityPerDayMin"), row.get("capacityPerDay"), ) resources.append(Resource( resourceId=resource_id, code=str(row.get("code") or key), name=str(row.get("name") or row.get("code") or key), kind=kind, parentResourceId=parent_id, capabilities=tuple(sorted(str(value) for value in row.get("capabilities") or [] if value)), calendarIntervals=calendar, maintenanceIntervals=_maintenance_intervals(world, row), cumulativeCapacity=float(_positive_number( row.get("cumulativeCapacity"), row.get("parallelCapacity"), row.get("capacity"), default=1_000_000.0, )), dailyCapacityMinutes=daily_capacity, **_source_fields(source_revision, f"{kind.value.lower()}:{row.get('code') or key}"), )) for lookup_value in (row.get("id"), row.get("code"), key): if lookup_value not in (None, ""): lookups[kind][str(lookup_value)] = resource_id first_by_kind.setdefault(kind, resource_id) if first_by_kind.get(kind): last_default_parent = first_by_kind[kind] return resources, {kind: dict(values) for kind, values in lookups.items()}, last_default_parent def _resolve_hierarchy_parent( row: Mapping[str, Any], hierarchy: Mapping[ResourceKind, Mapping[str, str]], default_parent: str | None, ) -> str | None: choices = ( (ResourceKind.WORKSTATION, row.get("workstationId") or row.get("workstationCode")), (ResourceKind.LINE, row.get("lineId") or row.get("lineCode")), (ResourceKind.WORKSHOP, row.get("workshopId") or row.get("workshopCode")), (ResourceKind.FACTORY, row.get("factoryId") or row.get("factoryCode")), ) for kind, value in choices: if value not in (None, "") and str(value) in hierarchy.get(kind, {}): return hierarchy[kind][str(value)] return default_parent def closed_loop_to_problem_v2( world: Mapping[str, Any], closed_loop: ClosedLoopProblem, *, horizon_days: int | None = None, ) -> SchedulingProblemV2: """Convert the pure W1 graph into a strict SchedulingProblemV2.""" business_day = _as_day(closed_loop.business_date) start_day = business_day + timedelta(days=1) days = int(horizon_days or (world.get("flexParams") or {}).get("afterDays") or 30) planning_start = datetime.combine(start_day, time(8, 0), tzinfo=_TZ) planning_end = planning_start + timedelta(days=max(1, days)) source_revision = closed_loop.source_revision source_fields = _source_fields(source_revision, f"closed-loop:{closed_loop.problem_id}") calendar = _calendar_intervals(world, start_day, max(1, days)) resources, hierarchy, default_hierarchy_parent = _hierarchy_resources( world, calendar, source_revision, ) resource_ids_by_capability: dict[str, list[str]] = defaultdict(list) team_ids_by_capability: dict[str, list[str]] = defaultdict(list) tooling_ids_by_capability: dict[str, list[str]] = defaultdict(list) equipment_resource_by_code: dict[str, str] = {} resource_by_id: dict[str, Resource] = {resource.resourceId: resource for resource in resources} for equipment in world.get("flexEquipment") or []: if not isinstance(equipment, Mapping) or str(equipment.get("status") or "RUNNING").upper() != "RUNNING": continue key = equipment.get("id") if equipment.get("id") not in (None, "") else equipment.get("code") if key in (None, ""): continue resource_id = f"equipment:{key}" capabilities = tuple(sorted({str(value) for value in equipment.get("capabilities") or [] if value})) resource = Resource( resourceId=resource_id, code=str(equipment.get("code") or resource_id), name=str(equipment.get("name") or equipment.get("code") or resource_id), kind=ResourceKind.EQUIPMENT, parentResourceId=_resolve_hierarchy_parent(equipment, hierarchy, default_hierarchy_parent), capabilities=capabilities, compatibleResourceIds=tuple( sorted(f"tooling:{value}" for value in equipment.get("adaptableMolds") or [] if value) ), calendarIntervals=calendar, maintenanceIntervals=_maintenance_intervals(world, equipment), cumulativeCapacity=float(_positive_number(equipment.get("cumulativeCapacity"), default=1.0)), dailyCapacityMinutes=_positive_number( equipment.get("capacityMinutesPerDay"), equipment.get("capacityPerDayMin"), ), **_source_fields(source_revision, f"equipment:{equipment.get('code') or key}"), ) resources.append(resource) resource_by_id[resource_id] = resource for lookup_value in (equipment.get("id"), equipment.get("code"), key): if lookup_value not in (None, ""): equipment_resource_by_code[str(lookup_value)] = resource_id for capability in capabilities: resource_ids_by_capability[capability].append(resource_id) team_rows_present = bool(world.get("flexTeams")) for team in world.get("flexTeams") or []: if not isinstance(team, Mapping) or not _active_resource_row(team): continue code = str(team.get("code") or team.get("id") or "").strip() capacity = _positive_number(team.get("memberCount"), team.get("cumulativeCapacity")) if not code or capacity is None: continue resource_id = f"team:{code}" capabilities = tuple(sorted({str(value) for value in team.get("supportOps") or team.get("capabilities") or [] if value})) resource = Resource( resourceId=resource_id, code=code, name=str(team.get("name") or code), kind=ResourceKind.TEAM, parentResourceId=_resolve_hierarchy_parent(team, hierarchy, default_hierarchy_parent), capabilities=capabilities, calendarIntervals=calendar, maintenanceIntervals=_maintenance_intervals(world, team), cumulativeCapacity=float(capacity), **_source_fields(source_revision, f"team:{code}"), ) resources.append(resource) resource_by_id[resource_id] = resource for capability in capabilities: team_ids_by_capability[capability].append(resource_id) tooling_rows_present = bool(world.get("flexMolds")) for mold in world.get("flexMolds") or []: if not isinstance(mold, Mapping): continue status = str(mold.get("status") or "AVAILABLE").upper() if status not in {"ACTIVE", "AVAILABLE", "RUNNING"}: continue code = str(mold.get("code") or mold.get("id") or "").strip() if not code: continue resource_id = f"tooling:{code}" capabilities = tuple(sorted({ str(value) for value in ( list(mold.get("capabilities") or []) + ([mold.get("operationCode")] if mold.get("operationCode") else []) ) if value })) compatible_equipment = tuple(sorted({ equipment_resource_by_code[str(value)] for value in mold.get("adaptableEquipment") or [] if str(value) in equipment_resource_by_code })) life_total = _positive_number(mold.get("lifeTotal"), mold.get("ratedLife")) life_used = max(0.0, float(mold.get("lifeUsed") or 0.0)) resource = Resource( resourceId=resource_id, code=code, name=str(mold.get("name") or code), kind=ResourceKind.TOOLING, parentResourceId=_resolve_hierarchy_parent(mold, hierarchy, default_hierarchy_parent), capabilities=capabilities, compatibleResourceIds=compatible_equipment, calendarIntervals=calendar, maintenanceIntervals=_maintenance_intervals(world, mold), cumulativeCapacity=float(_positive_number(mold.get("quantity"), mold.get("cumulativeCapacity"), default=1.0)), lifeTotal=life_total, lifeUsed=life_used, **_source_fields(source_revision, f"tooling:{code}"), ) resources.append(resource) resource_by_id[resource_id] = resource if life_total is None or life_used < life_total: for capability in capabilities: tooling_ids_by_capability[capability].append(resource_id) w1_requirements = {item.requirement_id: item for item in closed_loop.requirements} demand_by_requirement = {item.requirement_id: item for item in closed_loop.manufacturing_demands} activities_by_requirement: dict[str, list[dict[str, Any]]] = defaultdict(list) for demand in closed_loop.manufacturing_demands: if demand.release_status not in {"READY", "READY_FOR_SCHEDULING"} or demand.routing_status != "READY" or demand.resource_status != "READY": continue route = _routing_rows(world, demand.product_code, demand.product_id) previous_id: str | None = None for step in route: sequence = int(step.get("seq") or step.get("sequenceNo") or 0) operation_code = str(step.get("operationCode") or "") if sequence <= 0 or not operation_code: continue activity_id = f"ACT:{demand.demand_id}:{sequence}" eligible_equipment = tuple(sorted(resource_ids_by_capability.get(operation_code, ()))) resource_requirements: list[ActivityResourceRequirement] = [ ActivityResourceRequirement( kind=ResourceKind.EQUIPMENT, eligibleResourceIds=eligible_equipment, requiredCapabilities=(operation_code,), units=1.0, ) ] if team_rows_present: resource_requirements.append(ActivityResourceRequirement( kind=ResourceKind.TEAM, eligibleResourceIds=tuple(sorted(team_ids_by_capability.get(operation_code, ()))), requiredCapabilities=(operation_code,), units=float(_positive_number( step.get("requiredTeamMembers"), step.get("teamMembers"), step.get("crewSize"), default=1.0, )), )) if bool(step.get("requireMold") or step.get("requiresTooling")): eligible_tooling = tuple(sorted( resource_id for resource_id in tooling_ids_by_capability.get(operation_code, ()) if not resource_by_id[resource_id].compatibleResourceIds or bool(set(resource_by_id[resource_id].compatibleResourceIds) & set(eligible_equipment)) )) resource_requirements.append(ActivityResourceRequirement( kind=ResourceKind.TOOLING, eligibleResourceIds=eligible_tooling, requiredCapabilities=(operation_code,), units=1.0, lifeUnits=float(demand.quantity) * float(_positive_number( step.get("moldLifePerUnit"), step.get("toolingLifePerUnit"), default=1.0, )), )) raw = { "activityId": activity_id, "requirementId": demand.requirement_id, "routingId": str(step.get("routingId") or f"route:{demand.product_code}"), "routingVersion": str(step.get("routingVersion") or step.get("version") or "CONFIRMED"), "operationId": str(step.get("operationId") or operation_code), "operationCode": operation_code, "sequence": sequence, "durationMin": max(0.01, float(step.get("stdTimePerUnit") or step.get("runTimePerUnit") or 1.0) * float(demand.quantity)), "predecessorActivityIds": tuple([previous_id] if previous_id else []), # BOM inputs are consumed once by the first routing activity unless # a future consume-operation mapping explicitly assigns them later. # Repeating all child requirements on every step makes the strict V2 # validator report ACTIVITY_INPUT_CONSUMER_MISMATCH. "inputRequirementIds": tuple(demand.child_requirement_ids) if previous_id is None else (), "eligibleResourceIds": eligible_equipment, "requiredCapabilities": (operation_code,), "requiredResourceUnits": 1.0, "resourceRequirements": tuple(resource_requirements), "continuous": bool(step.get("continuous")), **_source_fields(source_revision, f"routing:{demand.product_code}:{sequence}"), } raw["activityIdentity"] = operation_activity_identity(raw) activities_by_requirement[demand.requirement_id].append(raw) previous_id = activity_id # Add MAKE child completion as a predecessor of the parent's first activity. for demand in closed_loop.manufacturing_demands: parent_rows = activities_by_requirement.get(demand.requirement_id) if not parent_rows: continue extra_predecessors: list[str] = [] for child_requirement_id in demand.child_requirement_ids: child_demand = demand_by_requirement.get(child_requirement_id) if child_demand is None: continue child_rows = activities_by_requirement.get(child_requirement_id) or [] if child_rows: extra_predecessors.append(str(child_rows[-1]["activityId"])) if extra_predecessors: first = parent_rows[0] first["predecessorActivityIds"] = tuple(sorted(set(first["predecessorActivityIds"]) | set(extra_predecessors))) first["activityIdentity"] = operation_activity_identity(first) requirements: list[Requirement] = [] parent_map: dict[str, list[str]] = {} for item in closed_loop.requirements: activity_ids = tuple(str(row["activityId"]) for row in activities_by_requirement.get(item.requirement_id, ())) parent_ids = list(item.parent_requirement_ids) parent_map[item.requirement_id] = parent_ids routing_status = { "READY": RoutingStatus.CONFIRMED, "TEMPLATE": RoutingStatus.TEMPLATE, "MISSING": RoutingStatus.MISSING, "NOT_APPLICABLE": RoutingStatus.CONFIRMED, }[item.routing_status] demand = demand_by_requirement.get(item.requirement_id) required_by_activity_ids = tuple( str(activities_by_requirement[parent_id][0]["activityId"]) for parent_id in parent_ids if activities_by_requirement.get(parent_id) ) requirements.append(Requirement( requirementId=item.requirement_id, orderId=item.sales_order_no or item.sales_order_id, orderLineId=item.sales_order_line_id, materialId=str(item.material_id if item.material_id is not None else item.material_key), quantity=float(item.quantity), sourcingType=SourcingType(item.sourcing_type), requiredAt=_required_at(item.required_at), parentRequirementId=parent_ids[0] if parent_ids else None, parentRequirementIds=tuple(parent_ids), requiredByActivityId=required_by_activity_ids[0] if required_by_activity_ids else None, requiredByActivityIds=required_by_activity_ids, routingStatus=routing_status, routingId=(f"route:{demand.product_code}" if demand and activity_ids else None), routingVersion=("CONFIRMED" if activity_ids else None), activityIds=activity_ids, **_source_fields(source_revision, f"requirement:{item.requirement_id}"), )) supply_events: list[SupplyEvent] = [] allowed_sources = {value.value for value in SupplySource} for event in closed_loop.supply_events: if event.source not in allowed_sources or not event.available_at: continue for index, allocation in enumerate(event.allocations): source_requirement = w1_requirements.get(allocation.requirement_id) if source_requirement is None: continue supply_events.append(SupplyEvent( supplyEventId=f"{event.event_id}:{index}:{allocation.requirement_id}", requirementId=allocation.requirement_id, materialId=str( source_requirement.material_id if source_requirement.material_id is not None else source_requirement.material_key ), source=SupplySource(event.source), quantity=float(allocation.quantity), availableAt=_required_at(event.available_at, end_of_day=False), **_source_fields(source_revision, f"supply:{event.event_id}"), )) activities = tuple(OperationActivity.model_validate(row) for rows in activities_by_requirement.values() for row in rows) objective_weights = dict((world.get("flexParams") or {}).get("weights") or {"weightedTardiness": 1.0}) constraint_profile = (world.get("constraintProfile") or {}).get("constraints") or {} enabled = {str(key): bool(value.get("enabled", True)) for key, value in constraint_profile.items() if isinstance(value, Mapping)} hard = tuple(sorted(str(key) for key, value in constraint_profile.items() if isinstance(value, Mapping) and value.get("enabled", True) and str(value.get("kind") or "hard") == "hard")) source_refs = sorted({ record.sourceRef for record in (*requirements, *supply_events, *activities, *resources) }) fingerprints = tuple( SourceFingerprint(sourceId=source_ref, revision=source_revision, sha256=source_fields["sourceHash"]) for source_ref in source_refs ) return SchedulingProblemV2( problemId=closed_loop.problem_id, businessDate=business_day, planningStart=planning_start, planningEnd=planning_end, requirements=tuple(requirements), supplyEvents=tuple(supply_events), activities=activities, resources=tuple(resources), objectivePolicy=ObjectivePolicy(weights=objective_weights), constraintPolicy=ConstraintPolicy(enabled=enabled, hardConstraints=hard), sourceRevision=source_revision, sourceFingerprints=fingerprints, metadata={ "closedLoopSchemaVersion": closed_loop.schema_version, "blockerCounts": dict(closed_loop.stats.get("blockerCounts") or {}), "parentRequirementIds": parent_map, "manufacturingDemandCount": len(closed_loop.manufacturing_demands), "strictResourceHierarchy": bool(default_hierarchy_parent), "teamResourceMasterPresent": team_rows_present, "toolingResourceMasterPresent": tooling_rows_present, "resourceCountsByKind": { kind.value: sum(1 for resource in resources if resource.kind is kind) for kind in ResourceKind }, }, ) def persist_closed_loop_projection(world: World, closed_loop: ClosedLoopProblem, problem_v2: SchedulingProblemV2) -> dict[str, Any]: """Persist only planning projections; no production order or work order is created.""" payload = closed_loop.to_dict() world["manufacturingDemands"] = list(payload["manufacturingDemands"]) world["supplyEvents"] = list(payload["supplyEvents"]) world["closedLoopProblems"] = [{ "problemId": closed_loop.problem_id, "sourceHash": closed_loop.source_revision, "businessDate": closed_loop.business_date, "summary": dict(closed_loop.stats), "blockers": list(payload["blockers"]), "schedulingProblemV2": problem_v2.model_dump(mode="json"), }] return { "problemId": closed_loop.problem_id, "sourceHash": closed_loop.source_revision, "manufacturingDemandCount": len(closed_loop.manufacturing_demands), "supplyEventCount": len(closed_loop.supply_events), "blockerCount": len(closed_loop.blockers), "readyDemandCount": sum(1 for demand in closed_loop.manufacturing_demands if demand.release_status in {"READY", "READY_FOR_SCHEDULING"}), } def record_blocked_flex_version( world: World, next_id, closed_loop: ClosedLoopProblem, *, engine_type: str = "CLOSED_LOOP", ) -> dict[str, Any]: """Record an honest zero-WO DRAFT version when manufacturing admission is blocked.""" world.setdefault("flexScheduleVersions", []) world.setdefault("flexVirtualLines", []) world.setdefault("flexWorkOrders", []) world.setdefault("flexConflicts", []) version_id = next_id("flexScheduleVersion") normalized_engine = ( "OPTIMIZE" if str(engine_type or "CLOSED_LOOP").strip().upper() == "OPTIMIZE" else "CLOSED_LOOP" ) version_no = f"FV{closed_loop.business_date.replace('-', '')}-{len(world['flexScheduleVersions']) + 1:03d}" version = { "id": version_id, "versionNo": version_no, "versionName": f"闭环排产准入阻断 {closed_loop.business_date}", "sortMode": "CLOSED_LOOP", "engineType": normalized_engine, "status": "DRAFT", "solveStatus": "BLOCKED", "planningProblemId": closed_loop.problem_id, "planningSourceHash": closed_loop.source_revision, "orderCount": int(closed_loop.stats.get("orderCount") or 0), "demandCount": len(closed_loop.manufacturing_demands), "admittedDemandCount": 0, "unscheduledDemandCount": len(closed_loop.manufacturing_demands), "vlCount": 0, "woCount": 0, "conflictCount": len(closed_loop.blockers), "totalTardiness": 0.0, "avgUtilization": 0.0, "createdBy": "agent", "createdAt": f"{closed_loop.business_date} 00:00", "publishedAt": None, } world["flexScheduleVersions"].append(version) conflict_map = { "MISSING_ROUTING": "NO_ROUTING", "MISSING_RESOURCE_CAPABILITY": "NO_CAPABILITY", "SUPPLY_SHORTAGE": "MATERIAL_SHORTAGE", "UNTRUSTED_DRAFT_SUPPLY": "MATERIAL_SHORTAGE", "UNTRUSTED_SUPPLY_DATE": "MATERIAL_SHORTAGE", "TEMPLATE_ROUTING_UNCONFIRMED": "ROUTING_TEMPLATE_UNCONFIRMED", "BOM_CYCLE": "BOM_CYCLE", } for blocker in closed_loop.blockers: world["flexConflicts"].append({ "id": next_id("flexConflict"), "versionId": version_id, "conflictType": conflict_map.get(blocker.code, blocker.code), "severity": "CRITICAL" if blocker.severity == "HARD" else "MINOR", "resourceType": blocker.entity_type, "resourceName": blocker.entity_id, "description": blocker.message, "suggestedSolution": "补齐并确认主数据或可信供应后重新排产", "isResolved": False, "planningBlockerCode": blocker.code, }) return { "versionId": version_id, "versionNo": version_no, "engineType": normalized_engine, "status": "DRAFT", "solveStatus": "BLOCKED", "orderCount": version["orderCount"], "demandCount": version["demandCount"], "admittedDemandCount": 0, "unscheduledDemandCount": version["unscheduledDemandCount"], "vlCount": 0, "woCount": 0, "conflictCount": version["conflictCount"], "totalTardiness": 0.0, "avgUtilization": 0.0, "planningProblemId": closed_loop.problem_id, "planningSourceHash": closed_loop.source_revision, } def project_admitted_demands_to_flex_orders( world: World, closed_loop: ClosedLoopProblem, ) -> dict[str, Any]: """Create idempotent flex-order inputs only for admitted MAKE demands.""" flex_orders = world.setdefault("flexOrders", []) active_demands = [ demand for demand in closed_loop.manufacturing_demands if demand.release_status in {"READY", "READY_FOR_SCHEDULING"} and demand.routing_status == "READY" and demand.resource_status == "READY" ] active_ids = {demand.demand_id for demand in active_demands} flex_orders[:] = [ row for row in flex_orders if str(row.get("source") or "").upper() != "CLOSED_LOOP" or row.get("closedLoopDemandId") in active_ids ] existing = { str(row.get("closedLoopDemandId")): row for row in flex_orders if str(row.get("source") or "").upper() == "CLOSED_LOOP" and row.get("closedLoopDemandId") } next_id = max( (int(row.get("id")) for row in flex_orders if isinstance(row.get("id"), int)), default=0, ) + 1 requirement_by_id = {item.requirement_id: item for item in closed_loop.requirements} route_table = world.setdefault("flexRoutings", []) rows: list[dict[str, Any]] = [] generated = 0 updated = 0 for demand in sorted(active_demands, key=lambda row: (-row.bom_depth, row.demand_id)): row = existing.get(demand.demand_id) if row is None: row = {"id": next_id} next_id += 1 flex_orders.append(row) generated += 1 else: updated += 1 requirement = requirement_by_id[demand.requirement_id] # child_requirement_ids identify the direct BOM inputs; only MAKE child # demands become inter-order predecessors. predecessor_ids = [ child.demand_id for child in closed_loop.manufacturing_demands if child.requirement_id in set(demand.child_requirement_ids) ] if not any(str(step.get("productCode") or "") == demand.product_code for step in route_table): route_rows = _routing_rows(world, demand.product_code, demand.product_id) next_route_id = max( (int(step.get("id")) for step in route_table if isinstance(step.get("id"), int)), default=0, ) + 1 for step in route_rows: route_table.append({ "id": next_route_id, "productCode": demand.product_code, "productName": demand.product_name, "seq": int(step.get("seq") or step.get("sequenceNo") or 0), "operationCode": str(step.get("operationCode") or ""), "operationName": str(step.get("operationName") or step.get("operationCode") or ""), "requireMold": bool(step.get("requireMold")), "stdTimePerUnit": float(step.get("stdTimePerUnit") or step.get("runTimePerUnit") or 1.0), "stdTimeSource": "CONFIRMED", "sourcingType": "MAKE", "isExternal": False, "source": "CLOSED_LOOP", "closedLoopProblemId": closed_loop.problem_id, }) next_route_id += 1 row.update({ "orderNo": f"CL-{demand.sales_order_no}-{demand.product_code}", "productCode": demand.product_code, "productName": demand.product_name, "quantity": float(demand.quantity), "unit": demand.unit, "dueDate": demand.required_at[:10], "priority": 1, "status": "RELEASED", "source": "CLOSED_LOOP", "salesOrderId": demand.sales_order_id, "salesOrderNo": demand.sales_order_no, "closedLoopProblemId": closed_loop.problem_id, "closedLoopSourceHash": closed_loop.source_revision, "closedLoopDemandId": demand.demand_id, "closedLoopRequirementId": demand.requirement_id, "bomDepth": demand.bom_depth, "predecessorDemandIds": sorted(set(predecessor_ids)), "childRequirementIds": list(demand.child_requirement_ids), "routingStatus": demand.routing_status, "resourceStatus": demand.resource_status, "requiredAt": requirement.required_at, }) rows.append(row) return { "orderIds": [int(row["id"]) for row in rows], "generated": generated, "updated": updated, "count": len(rows), } def _local_datetime(value: Any, fallback_day: date) -> datetime: """Parse PoolEngine timestamps into Asia/Shanghai aware datetimes.""" if isinstance(value, datetime): return value if value.tzinfo else value.replace(tzinfo=_TZ) text = str(value or "").strip() if not text: return datetime.combine(fallback_day, time(0, 0), tzinfo=_TZ) parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) return parsed if parsed.tzinfo else parsed.replace(tzinfo=_TZ) def flex_version_to_solution_v2( world: World, closed_loop: ClosedLoopProblem, problem: SchedulingProblemV2, version_id: int, ) -> SchedulingSolutionV2: """Bind committed PoolEngine rows to the strict V2 solution contract.""" version = next( (row for row in world.get("flexScheduleVersions") or [] if row.get("id") == version_id), None, ) if version is None: raise ValueError(f"flex schedule version #{version_id} not found") flex_order_by_no = { str(row.get("orderNo")): row for row in world.get("flexOrders") or [] if row.get("orderNo") } activities_by_key = { (activity.requirementId, int(activity.sequence), activity.operationCode): activity for activity in problem.activities } requirement_by_id = {item.requirementId: item for item in problem.requirements} resource_id_by_kind_code = { (resource.kind, str(resource.code)): resource.resourceId for resource in problem.resources } resource_ids = {resource.resourceId for resource in problem.resources} scheduled: list[ScheduledActivity] = [] scheduled_ids: set[str] = set() business_day = _as_day(closed_loop.business_date) for work_order in world.get("flexWorkOrders") or []: if work_order.get("versionId") != version_id: continue flex_order = flex_order_by_no.get(str(work_order.get("flexOrderNo") or "")) or {} requirement_id = str( flex_order.get("closedLoopRequirementId") or work_order.get("closedLoopRequirementId") or "" ) key = ( requirement_id, int(work_order.get("seq") or 0), str(work_order.get("operationCode") or ""), ) activity = activities_by_key.get(key) if activity is None: continue work_order["closedLoopRequirementId"] = requirement_id work_order["activityId"] = activity.activityId work_order["activityIdentity"] = activity.activityIdentity requirements_by_kind = {requirement.kind: requirement for requirement in activity.resourceRequirements} equipment_candidate = f"equipment:{work_order.get('equipmentId')}" equipment_resource_id = ( equipment_candidate if equipment_candidate in resource_ids else resource_id_by_kind_code.get( (ResourceKind.EQUIPMENT, str(work_order.get("equipmentCode") or "")), equipment_candidate, ) ) allocations: list[ScheduledResourceAllocation] = [ScheduledResourceAllocation( resourceId=equipment_resource_id, kind=ResourceKind.EQUIPMENT, units=float( requirements_by_kind.get(ResourceKind.EQUIPMENT).units if ResourceKind.EQUIPMENT in requirements_by_kind else activity.requiredResourceUnits ), )] team_code = str(work_order.get("teamCode") or work_order.get("teamId") or "").strip() if team_code: team_requirement = requirements_by_kind.get(ResourceKind.TEAM) allocations.append(ScheduledResourceAllocation( resourceId=resource_id_by_kind_code.get( (ResourceKind.TEAM, team_code), f"team:{team_code}", ), kind=ResourceKind.TEAM, units=float(team_requirement.units if team_requirement is not None else 1.0), )) tooling_code = str(work_order.get("moldCode") or work_order.get("toolingCode") or "").strip() if tooling_code: tooling_requirement = requirements_by_kind.get(ResourceKind.TOOLING) allocations.append(ScheduledResourceAllocation( resourceId=resource_id_by_kind_code.get( (ResourceKind.TOOLING, tooling_code), f"tooling:{tooling_code}", ), kind=ResourceKind.TOOLING, units=float(tooling_requirement.units if tooling_requirement is not None else 1.0), lifeUnits=float(tooling_requirement.lifeUnits if tooling_requirement is not None else 0.0), )) scheduled.append(ScheduledActivity( activityId=activity.activityId, activityIdentity=activity.activityIdentity, requirementId=requirement_id, operationId=activity.operationId, sequence=activity.sequence, resourceId=equipment_resource_id, start=_local_datetime(work_order.get("plannedStartTime"), business_day), end=_local_datetime(work_order.get("plannedEndTime"), business_day), resourceUnits=activity.requiredResourceUnits, resourceAllocations=tuple(allocations), )) scheduled_ids.add(activity.activityId) supply_decisions: list[SupplyDecision] = [] for event in problem.supplyEvents: requirement = requirement_by_id[event.requirementId] consumed_by = requirement.requiredByActivityId if consumed_by is None and requirement.activityIds: consumed_by = requirement.activityIds[0] supply_decisions.append(SupplyDecision( decisionId=f"DEC:{event.supplyEventId}", supplyEventId=event.supplyEventId, requirementId=event.requirementId, materialId=event.materialId, source=event.source, quantity=event.quantity, availableAt=event.availableAt, consumedByActivityId=consumed_by, )) pegging = tuple( PeggingAllocation( peggingId=f"PEG:{item.requirement_id}:{index}:{ref.parent_requirement_id}", childRequirementId=item.requirement_id, parentRequirementId=ref.parent_requirement_id, quantity=float(ref.quantity), ) for item in closed_loop.requirements for index, ref in enumerate(item.pegging_refs) if float(ref.quantity) > 0 ) unscheduled = tuple( UnscheduledRequirement( requirementId=requirement.requirementId, quantity=requirement.quantity, reasonCode="UNSCHEDULED_ACTIVITY", details="存在未落到排产版本的制造活动", ) for requirement in problem.requirements if requirement.activityIds and any(activity_id not in scheduled_ids for activity_id in requirement.activityIds) ) hard_conflicts = tuple( HardViolation( code=str(row.get("conflictType") or "HARD_CONFLICT"), message=str(row.get("description") or row.get("conflictType") or "hard scheduling conflict"), entityRefs=tuple( str(value) for value in (row.get("orderNo"), row.get("resourceName"), row.get("id")) if value not in (None, "") ), ) for row in world.get("flexConflicts") or [] if row.get("versionId") == version_id and not row.get("isResolved") and ( str(row.get("severity") or "").upper() in {"CRITICAL", "HARD", "FATAL"} or row.get("hard") is True ) ) solve_status = ( SolveStatus.FEASIBLE if not unscheduled and not hard_conflicts and len(scheduled_ids) == len(problem.activities) else SolveStatus.PARTIAL ) generated_at = _local_datetime(version.get("createdAt"), business_day) return SchedulingSolutionV2( problemId=problem.problemId, solveStatus=solve_status, objectiveValues={ "totalTardiness": float(version.get("totalTardiness") or 0.0), "avgUtilization": float(version.get("avgUtilization") or 0.0), "scheduledActivities": float(len(scheduled)), }, activities=tuple(scheduled), supplyDecisions=tuple(supply_decisions), pegging=pegging, unscheduledRequirements=unscheduled, hardViolations=hard_conflicts, assumptions=(Assumption( code=("OPTIMIZE_ENGINE_V1_ADAPTER" if str(version.get("engineType") or "").upper() == "OPTIMIZE" else "POOL_ENGINE_V1_ADAPTER"), message=("OptimizeEngine 候选结果已映射到闭环 V2 契约并执行独立校验" if str(version.get("engineType") or "").upper() == "OPTIMIZE" else "PoolEngine 候选结果已映射到闭环 V2 契约并执行独立校验"), sourceRef=f"flex-version:{version_id}", confidence=1.0, ),), provenance=SolutionProvenance( runId=f"flex-version:{version_id}", solverId=str(version.get("solverId") or "pool-engine"), solverVersion=str(version.get("solverVersion") or "closed-loop-v1"), generatedAt=generated_at, businessDate=business_day, problemHash=scheduling_problem_hash(problem), sourceRevision=problem.sourceRevision, sourceFingerprints=problem.sourceFingerprints, ), ) def _record_rejected_candidate( world: World, next_id, closed_loop: ClosedLoopProblem, report: Any, candidate_result: Mapping[str, Any], *, engine_type: str = "CLOSED_LOOP", ) -> dict[str, Any]: """Persist validation diagnostics without leaking candidate WO/VL artifacts.""" world.setdefault("flexScheduleVersions", []) world.setdefault("flexVirtualLines", []) world.setdefault("flexWorkOrders", []) world.setdefault("flexConflicts", []) version_id = next_id("flexScheduleVersion") version_no = f"FV{closed_loop.business_date.replace('-', '')}-{len(world['flexScheduleVersions']) + 1:03d}" violations = list(report.hardViolations) normalized_engine = ( "OPTIMIZE" if str(engine_type or "CLOSED_LOOP").strip().upper() == "OPTIMIZE" else "CLOSED_LOOP" ) version = { "id": version_id, "versionNo": version_no, "versionName": f"闭环排产校验失败 {closed_loop.business_date}", "sortMode": "CLOSED_LOOP", "engineType": normalized_engine, "solverId": candidate_result.get("solverId"), "solverVersion": candidate_result.get("solverVersion"), "algorithmId": candidate_result.get("algorithmId"), "algorithmVersion": candidate_result.get("algorithmVersion"), "status": "DRAFT", "solveStatus": "REJECTED", "planningProblemId": closed_loop.problem_id, "planningSourceHash": closed_loop.source_revision, "orderCount": int(closed_loop.stats.get("orderCount") or 0), "demandCount": len(closed_loop.manufacturing_demands), "admittedDemandCount": int(candidate_result.get("orderCount") or 0), "unscheduledDemandCount": len(closed_loop.manufacturing_demands), "vlCount": 0, "woCount": 0, "conflictCount": len(violations), "totalTardiness": 0.0, "avgUtilization": 0.0, "createdBy": "agent", "createdAt": f"{closed_loop.business_date} 00:00", "publishedAt": None, "validationReport": report.model_dump(mode="json"), } world["flexScheduleVersions"].append(version) for violation in violations: world["flexConflicts"].append({ "id": next_id("flexConflict"), "versionId": version_id, "conflictType": violation.code, "severity": "CRITICAL", "resourceType": "VALIDATION", "resourceName": (violation.entityRefs[0] if violation.entityRefs else violation.code), "description": violation.message, "suggestedSolution": "修复候选方案或主数据后重新排产", "isResolved": False, "planningBlockerCode": "SOLUTION_VALIDATION_FAILED", }) return { "versionId": version_id, "versionNo": version_no, "engineType": normalized_engine, "solverId": version.get("solverId"), "solverVersion": version.get("solverVersion"), "algorithmId": version.get("algorithmId"), "algorithmVersion": version.get("algorithmVersion"), "status": "DRAFT", "solveStatus": "REJECTED", "orderCount": version["orderCount"], "demandCount": version["demandCount"], "admittedDemandCount": version["admittedDemandCount"], "unscheduledDemandCount": version["unscheduledDemandCount"], "vlCount": 0, "woCount": 0, "conflictCount": version["conflictCount"], "planningProblemId": closed_loop.problem_id, "planningSourceHash": closed_loop.source_revision, "validation": version["validationReport"], } def run_closed_loop_candidate( world: World, next_id, *, business_date: str, schedule_start_date: str | None = None, order_nos: Iterable[str] | None = None, sort_mode: str | None = None, window: str | None = None, name: str | None = None, engine_type: str = "CLOSED_LOOP", strict: bool = True, ) -> dict[str, Any]: """Build, solve and validate one closed-loop candidate with version-level atomicity.""" closed_loop = build_closed_loop_problem( world, business_date=business_date, order_nos=order_nos, strict=strict, ) problem = closed_loop_to_problem_v2(world, closed_loop) projection = persist_closed_loop_projection(world, closed_loop, problem) admitted = [ demand for demand in closed_loop.manufacturing_demands if demand.release_status in {"READY", "READY_FOR_SCHEDULING"} and demand.routing_status == "READY" and demand.resource_status == "READY" ] base = { "planning": { "problemId": closed_loop.problem_id, "sourceHash": closed_loop.source_revision, "businessDate": closed_loop.business_date, "summary": dict(closed_loop.stats), "projection": projection, "problem": problem.model_dump(mode="json"), } } if not admitted: result = record_blocked_flex_version( world, next_id, closed_loop, engine_type=engine_type or "CLOSED_LOOP", ) return {**result, **base, "validation": None} candidate = deepcopy(world) for key in ( "flexOrders", "flexRoutings", "flexScheduleVersions", "flexVirtualLines", "flexWorkOrders", "flexConflicts", "flexMolds", "flexParams", "flexBom", "flexMaterials", "flexEquipment", "flexOperations", "flexTeams", ): candidate.setdefault(key, [] if key != "flexParams" else {}) projected = project_admitted_demands_to_flex_orders(candidate, closed_loop) schedule_start = schedule_start_date or (_as_day(business_date) + timedelta(days=1)).isoformat() normalized_engine = ( "OPTIMIZE" if str(engine_type or "CLOSED_LOOP").strip().upper() == "OPTIMIZE" else "CLOSED_LOOP" ) if normalized_engine == "OPTIMIZE": solved = OptimizeEngine().solve_flex( candidate, next_id, dispatch_rule=sort_mode, order_ids=projected["orderIds"], start_date=schedule_start, name=name or f"Optimize 闭环排产 {business_date}", window=window, ) else: solved = PoolEngine().solve( candidate, next_id, sort_mode=sort_mode, order_ids=projected["orderIds"], start_date=schedule_start, name=name or f"闭环排产 {business_date}", window=window, ) version_id = int(solved["versionId"]) version = next(row for row in candidate["flexScheduleVersions"] if row.get("id") == version_id) version["versionNo"] = f"FV{business_date.replace('-', '')}-{len(candidate['flexScheduleVersions']):03d}" version["versionName"] = name or f"闭环排产 {business_date}" version["engineType"] = normalized_engine version["solverId"] = str(solved.get("solverId") or ("pool-engine" if normalized_engine != "OPTIMIZE" else "optimize-dispatch")) version["solverVersion"] = str(solved.get("solverVersion") or ("closed-loop-v1" if normalized_engine != "OPTIMIZE" else "1.0.0")) version["algorithmId"] = solved.get("algorithmId") version["algorithmVersion"] = solved.get("algorithmVersion") version["planningProblemId"] = closed_loop.problem_id version["planningSourceHash"] = closed_loop.source_revision version["demandCount"] = len(closed_loop.manufacturing_demands) version["admittedDemandCount"] = len(admitted) version["unscheduledDemandCount"] = max(0, len(admitted) - int(version.get("vlCount") or 0)) version["createdAt"] = f"{business_date} 00:00" solved["versionNo"] = version["versionNo"] solved["engineType"] = normalized_engine solution = flex_version_to_solution_v2(candidate, closed_loop, problem, version_id) report = validate_solution(problem, solution, world=candidate) version["schedulingSolutionV2"] = solution.model_dump(mode="json") version["validationReport"] = report.model_dump(mode="json") version["solveStatus"] = solution.solveStatus.value if report.valid else "REJECTED" solved["solveStatus"] = version["solveStatus"] solved["validation"] = version["validationReport"] solved["planningProblemId"] = closed_loop.problem_id solved["planningSourceHash"] = closed_loop.source_revision solved["demandCount"] = version["demandCount"] solved["admittedDemandCount"] = version["admittedDemandCount"] solved["unscheduledDemandCount"] = version["unscheduledDemandCount"] solved["projectedFlexOrders"] = projected if not report.valid or solution.solveStatus != SolveStatus.FEASIBLE: rejected = _record_rejected_candidate( world, next_id, closed_loop, report, solved, engine_type=normalized_engine, ) return {**rejected, **base, "projectedFlexOrders": projected} for key in ( "flexOrders", "flexRoutings", "flexScheduleVersions", "flexVirtualLines", "flexWorkOrders", "flexConflicts", "flexMolds", ): world[key] = candidate[key] return {**solved, **base}