aps-agent/server/aps_domain/closed_loop_runtime.py

1260 lines
56 KiB
Python

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