1192 lines
53 KiB
Python
1192 lines
53 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
|
|
|
|
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 []),
|
|
"inputRequirementIds": tuple(demand.child_requirement_ids),
|
|
"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) -> 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")
|
|
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": "CLOSED_LOOP",
|
|
"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": "CLOSED_LOOP",
|
|
"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="POOL_ENGINE_V1_ADAPTER",
|
|
message="PoolEngine 候选结果已映射到闭环 V2 契约并执行独立校验",
|
|
sourceRef=f"flex-version:{version_id}",
|
|
confidence=1.0,
|
|
),),
|
|
provenance=SolutionProvenance(
|
|
runId=f"flex-version:{version_id}",
|
|
solverId="pool-engine",
|
|
solverVersion="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],
|
|
) -> 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)
|
|
version = {
|
|
"id": version_id,
|
|
"versionNo": version_no,
|
|
"versionName": f"闭环排产校验失败 {closed_loop.business_date}",
|
|
"sortMode": "CLOSED_LOOP",
|
|
"engineType": "CLOSED_LOOP",
|
|
"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": "CLOSED_LOOP",
|
|
"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,
|
|
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)
|
|
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()
|
|
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"] = "CLOSED_LOOP"
|
|
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"] = "CLOSED_LOOP"
|
|
|
|
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)
|
|
return {**rejected, **base, "projectedFlexOrders": projected}
|
|
|
|
for key in (
|
|
"flexOrders", "flexRoutings", "flexScheduleVersions", "flexVirtualLines",
|
|
"flexWorkOrders", "flexConflicts", "flexMolds",
|
|
):
|
|
world[key] = candidate[key]
|
|
return {**solved, **base}
|