aps-agent/server/shipyard_synthetic/orders.py

778 lines
40 KiB
Python
Raw Normal View History

from __future__ import annotations
from collections import defaultdict
from datetime import date, timedelta
from typing import Any
from .config import GeneratorConfig
from .models import DatasetBundle, stable_id
def _as_date(value: Any, fallback: date) -> date:
if isinstance(value, date):
return value
text = str(value or "")[:10]
try:
return date.fromisoformat(text)
except ValueError:
return fallback
def _selected_indexes(total: int, target: int) -> set[int]:
if target <= 0:
return set()
if target > total:
raise ValueError("outsource suggestion target exceeds operation count")
return {(offset * total) // target for offset in range(target)}
def _eligible_routing_templates(bundle: DatasetBundle) -> list[tuple[dict[str, Any], list[dict[str, Any]]]]:
resources = bundle.rows("resources")
teams = bundle.rows("teams")
resource_types = {str(row.get("resourceType") or "") for row in resources if row.get("status", "ACTIVE") == "ACTIVE"}
active_teams = [row for row in teams if row.get("status", "ACTIVE") == "ACTIVE"]
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for operation in bundle.rows("routing-operations"):
if operation.get("status", "ACTIVE") != "ACTIVE":
continue
required_type = str(operation.get("resourceType") or operation.get("requiredResourceType") or "WORKSTATION")
required_skills = {str(code) for code in operation.get("requiredSkillCodes") or []}
crew_size = int(operation.get("crewSize") or 1)
team_exists = any(
required_skills.issubset({str(code) for code in team.get("skillCodes") or []})
and int(team.get("crewSize") or 0) >= crew_size
for team in active_teams
)
if required_type in resource_types and team_exists:
grouped[str(operation["routingId"])].append(operation)
routings = {str(row["routingId"]): row for row in bundle.rows("routings") if row.get("status", "ACTIVE") == "ACTIVE"}
eligible: list[tuple[dict[str, Any], list[dict[str, Any]]]] = []
for routing_id, templates in grouped.items():
if routing_id not in routings or len(templates) < 5:
continue
ordered = sorted(templates, key=lambda row: (int(row.get("sequence") or 0), str(row.get("routingOperationId") or "")))
eligible.append((routings[routing_id], ordered[:5]))
return sorted(eligible, key=lambda item: str(item[0].get("routingCode") or item[0]["routingId"]))
def _material_ready_dates(bundle: DatasetBundle, fallback: date) -> dict[str, date]:
return {
str(row["workPackageId"]): _as_date(row.get("readyDate"), fallback)
for row in bundle.rows("kit-readiness")
if row.get("workPackageId")
}
def generate_orders(bundle: DatasetBundle, config: GeneratorConfig) -> DatasetBundle:
"""Generate exact production orders plus non-zero shipyard hard-constraint samples."""
target_orders = config.profile.production_order_count
target_operations = config.profile.operation_count
if target_operations != target_orders * 5:
raise ValueError("W69 contract requires exactly five operations per production order")
projects = sorted(
bundle.rows("ship-projects"),
key=lambda row: str(row.get("projectCode") or row.get("projectId") or ""),
)
work_packages = sorted(
bundle.rows("work-packages"),
key=lambda row: str(row.get("workPackageCode") or row.get("workPackageId") or ""),
)
routing_templates = _eligible_routing_templates(bundle)
resources = sorted(
[row for row in bundle.rows("resources") if row.get("status", "ACTIVE") == "ACTIVE"],
key=lambda row: str(row.get("resourceCode") or row.get("resourceId") or ""),
)
teams = sorted(
[row for row in bundle.rows("teams") if row.get("status", "ACTIVE") == "ACTIVE"],
key=lambda row: str(row.get("teamCode") or row.get("teamId") or ""),
)
suppliers = sorted(
[
row
for row in bundle.rows("suppliers")
if row.get("approved", True) and row.get("status", "ACTIVE") == "ACTIVE"
],
key=lambda row: str(row.get("supplierCode") or row.get("supplierId") or ""),
)
if not projects or not work_packages:
raise ValueError("order generation requires project and work-package master rows")
if not routing_templates:
raise ValueError("order generation requires a routing with five finite-resource/team-compatible operations")
if not resources or not teams:
raise ValueError("order generation requires finite resources and teams")
if not suppliers:
raise ValueError("order generation requires at least one approved finite supplier")
resources_by_type: dict[str, list[dict[str, Any]]] = defaultdict(list)
for resource_index, resource in enumerate(resources, 1):
resources_by_type[str(resource.get("resourceType") or "")].append(resource)
if not resource.get("maintenanceBlackoutDates"):
first_blackout = config.planning_base_date + timedelta(days=28 + resource_index % 17)
second_blackout = config.planning_base_date + timedelta(days=196 + resource_index % 23)
resource["maintenanceBlackoutDates"] = [
first_blackout.isoformat(),
second_blackout.isoformat(),
]
if "CRANE" in str(resource.get("resourceType") or ""):
resource.setdefault("maximumLiftRadiusM", 36.0 + float(resource_index % 6) * 4.0)
qualification_codes_by_team: dict[str, list[str]] = defaultdict(list)
for employee in bundle.rows("employees"):
if employee.get("status", "ACTIVE") != "ACTIVE" or not employee.get("qualificationCode"):
continue
qualification_codes_by_team[str(employee.get("teamId"))].append(
str(employee["qualificationCode"])
)
for codes in qualification_codes_by_team.values():
codes.sort()
ready_by_package = _material_ready_dates(bundle, config.planning_base_date)
latest_schedulable_ready = config.planning_horizon_end - timedelta(days=90)
project_packages: dict[str, list[dict[str, Any]]] = defaultdict(list)
for work_package in work_packages:
ready_date = ready_by_package.get(
str(work_package["workPackageId"]),
_as_date(work_package.get("plannedStart"), config.planning_base_date),
)
if ready_date <= latest_schedulable_ready:
project_packages[str(work_package["projectId"])].append(work_package)
usable_projects = [row for row in projects if project_packages.get(str(row["projectId"]))]
if not usable_projects:
raise ValueError("no work package is linked to an available project")
releases_by_project: dict[str, list[dict[str, Any]]] = defaultdict(list)
for release in bundle.rows("engineering-releases"):
if release.get("status") == "RELEASED":
releases_by_project[str(release.get("projectId"))].append(release)
for rows in releases_by_project.values():
rows.sort(key=lambda row: (str(row.get("effectiveDate") or ""), str(row.get("releaseId") or "")))
production_orders: list[dict[str, Any]] = []
work_orders: list[dict[str, Any]] = []
operations: list[dict[str, Any]] = []
capacity_demands: list[dict[str, Any]] = []
all_outsource_eligible_indexes = [
index for index in range(target_operations) if index % 5 in {1, 2, 3}
]
outsource_candidate_limit = max(
config.profile.outsource_suggestion_count * 5,
len(all_outsource_eligible_indexes) * 3 // 5,
)
outsource_eligible_indexes = all_outsource_eligible_indexes[:outsource_candidate_limit]
selected_outsource_positions = _selected_indexes(
len(outsource_eligible_indexes),
config.profile.outsource_suggestion_count,
)
outsource_indexes = {outsource_eligible_indexes[index] for index in selected_outsource_positions}
special_resource_types = [
resource_type
for resource_type in ("GANTRY_CRANE", "PORTAL_CRANE", "MOBILE_CRANE", "TRANSPORTER")
if resources_by_type.get(resource_type)
]
balancing_resource_types = sorted(
resource_type
for resource_type in resources_by_type
if resource_type != "WORKSTATION"
)
relation_types = ("FS", "SS", "FF", "SF")
lag_values = (-2.0, 0.0, 2.0, 4.0, -1.0)
hazard_classes = ("HOT_WORK", "PAINT_VOC", "CONFINED_SPACE")
qualification_sample_count = 0
global_operation_index = 0
for order_index in range(1, target_orders + 1):
project = usable_projects[(order_index - 1) % len(usable_projects)]
project_id = str(project["projectId"])
packages = project_packages[project_id]
work_package = packages[((order_index - 1) // len(usable_projects)) % len(packages)]
routing, templates = routing_templates[(order_index - 1) % len(routing_templates)]
code = f"PO-SYN-{order_index:05d}"
production_order_id = stable_id("production-order", code, prefix="PO")
work_order_code = f"WO-SYN-{order_index:05d}"
work_order_id = stable_id("work-order", production_order_id, prefix="WO")
need_date = _as_date(work_package.get("needDate"), config.planning_horizon_end)
material_ready = max(
config.planning_base_date,
ready_by_package.get(
str(work_package["workPackageId"]),
_as_date(work_package.get("plannedStart"), config.planning_base_date),
),
)
release_rows = releases_by_project.get(project_id) or []
release = release_rows[-1] if release_rows else None
production_orders.append(
{
"productionOrderId": production_order_id,
"productionOrderCode": code,
"projectId": project_id,
"projectCode": project.get("projectCode")
or next(
(project_code for project_code in config.project_codes if project_code in str(project)),
config.project_codes[0],
),
"wbsId": work_package["wbsId"],
"workPackageId": work_package["workPackageId"],
"routingId": routing["routingId"],
"quantity": 1 + (order_index % 3),
"unit": "SET",
"plannedReleaseDate": max(
config.planning_base_date,
material_ready - timedelta(days=2),
).isoformat(),
"materialReadyAt": material_ready.isoformat(),
"needDate": need_date.isoformat(),
"designReleaseId": release.get("releaseId") if release else None,
"designReleaseStatus": (
"RELEASED" if release or not bundle.rows("engineering-releases") else "DESIGN_PENDING"
),
"priority": int(project.get("priority") or 5),
"status": "RELEASED",
"sourceType": "SYNTHETIC",
}
)
work_orders.append(
{
"workOrderId": work_order_id,
"workOrderCode": work_order_code,
"productionOrderId": production_order_id,
"projectId": project_id,
"wbsId": work_package["wbsId"],
"workPackageId": work_package["workPackageId"],
"routingId": routing["routingId"],
"status": "ACTIVE",
"quantity": production_orders[-1]["quantity"],
"materialReadyAt": material_ready.isoformat(),
"needDate": need_date.isoformat(),
"mesDispatchProtected": order_index <= max(1, target_orders // 20),
"sourceType": "SYNTHETIC",
}
)
order_operation_ids = [
stable_id("operation", work_order_id, sequence, prefix="OP")
for sequence in (10, 20, 30, 40, 50)
]
for position, (sequence, template) in enumerate(
zip((10, 20, 30, 40, 50), templates, strict=True)
):
operation_id = order_operation_ids[position]
required_type = str(
template.get("resourceType") or template.get("requiredResourceType") or "WORKSTATION"
)
if special_resource_types and global_operation_index % 47 == 0:
required_type = special_resource_types[
(global_operation_index // 47) % len(special_resource_types)
]
elif (
required_type == "WORKSTATION"
and balancing_resource_types
and global_operation_index % 3 != 0
):
required_type = balancing_resource_types[
global_operation_index % len(balancing_resource_types)
]
candidate_resources = resources_by_type[required_type]
target_resource = candidate_resources[global_operation_index % len(candidate_resources)]
standard = float(template.get("standardHours") or 4.0)
setup = float(template.get("setupHours") or 0.0)
teardown = float(template.get("teardownHours") or 0.0)
duration = round(
max(
1.0,
min(
4.5,
(standard + setup + teardown) * 0.18
+ ((order_index + position) % 3) * 0.25,
),
),
2,
)
is_outsource = global_operation_index in outsource_indexes
hold_point = bool(template.get("holdPoint")) or (
position == 4 and order_index % 17 == 0
)
strict_erection_sample = order_index % 31 == 0
strict_spatial_sample = order_index % 37 == 0
relation_type = "FS" if position == 0 else relation_types[(order_index + position) % 4]
lag_hours = 0.0 if position == 0 else lag_values[(global_operation_index + order_index) % len(lag_values)]
if position > 0 and (strict_erection_sample or strict_spatial_sample):
relation_type = "FS"
lag_hours = 0.0
required_skills = list(template.get("requiredSkillCodes") or [])
crew_size = int(template.get("crewSize") or 1)
balancing_skill_codes = sorted(
{
str(skill_code)
for team in teams
if int(team.get("crewSize") or 0) >= crew_size
for skill_code in team.get("skillCodes") or []
}
)
if balancing_skill_codes and global_operation_index % 3 != 0:
required_skills = [
balancing_skill_codes[
global_operation_index % len(balancing_skill_codes)
]
]
qualified_teams = [
team
for team in teams
if set(required_skills).issubset(set(team.get("skillCodes") or []))
and int(team.get("crewSize") or 0) >= crew_size
and qualification_codes_by_team.get(str(team["teamId"]))
]
required_qualifications: list[str] = []
if qualified_teams and (
qualification_sample_count == 0
or global_operation_index % 197 == 0
):
qualified_team = qualified_teams[
(global_operation_index // 197) % len(qualified_teams)
]
required_qualifications = [
qualification_codes_by_team[str(qualified_team["teamId"])][0]
]
qualification_sample_count += 1
physical_sample = global_operation_index % 19 == 0 or required_type in special_resource_types
maximum_length = float(target_resource.get("maximumLength") or 45.0)
maximum_width = float(target_resource.get("maximumWidth") or 28.0)
maximum_height = float(target_resource.get("maximumHeight") or 18.0)
required_length = round(min(maximum_length * 0.65, 8.0 + global_operation_index % 9), 2) if physical_sample else None
required_width = round(min(maximum_width * 0.65, 5.0 + global_operation_index % 6), 2) if physical_sample else None
required_height = round(min(maximum_height * 0.65, 4.0 + global_operation_index % 5), 2) if physical_sample else None
maximum_weight = target_resource.get("maximumWeight")
required_weight = (
round(min(float(maximum_weight) * 0.65, 45.0 + global_operation_index % 70), 2)
if maximum_weight is not None and physical_sample
else None
)
maximum_radius = target_resource.get("maximumLiftRadiusM")
required_radius = (
round(min(float(maximum_radius) * 0.7, 16.0 + global_operation_index % 12), 2)
if maximum_radius is not None and "CRANE" in required_type
else None
)
transport_sample = required_type == "TRANSPORTER" or global_operation_index % 23 == 0
hazard_sample = global_operation_index % 9 == 0
weather_sensitive = bool(template.get("weatherSensitive")) or global_operation_index % 13 == 0
operation = {
"operationId": operation_id,
"operationCode": f"{work_order_code}-OP-{sequence:03d}",
"operationName": template.get("operationName") or f"Operation-{sequence:03d}",
"workOrderId": work_order_id,
"productionOrderId": production_order_id,
"projectId": project_id,
"wbsId": work_package["wbsId"],
"workPackageId": work_package["workPackageId"],
"blockId": work_package.get("blockId"),
"zoneId": work_package.get("zoneId"),
"routingId": routing["routingId"],
"sourceRoutingOperationId": template.get("routingOperationId")
or template.get("operationId"),
"sequence": sequence,
"sequenceNo": sequence,
"operationPosition": position + 1,
"predecessorOperationId": order_operation_ids[position - 1] if position else None,
"previousOperationId": order_operation_ids[position - 1] if position else None,
"nextOperationId": order_operation_ids[position + 1] if position < 4 else None,
"relationType": relation_type,
"lagHours": lag_hours,
"durationHours": duration,
"setupHours": round(min(1.0, setup), 2),
"requiredResourceType": required_type,
"requiredResourceGroup": target_resource.get("resourceGroupId")
or template.get("primaryResourceGroup"),
"requiredCapabilityTags": [required_type, "FINITE_CAPACITY"],
"requiredSkillCodes": required_skills,
"requiredQualificationCodes": required_qualifications,
"crewSize": crew_size,
"requiredWeightT": required_weight,
"requiredLengthM": required_length,
"requiredWidthM": required_width,
"requiredHeightM": required_height,
"requiredLiftRadiusM": required_radius,
"transportWindowStatus": "OPEN" if transport_sample else "NOT_REQUIRED",
"transportZone": target_resource.get("transportZone") if transport_sample else None,
"transportWindowStart": config.planning_base_date.isoformat() if transport_sample else None,
"transportWindowEnd": config.planning_horizon_end.isoformat() if transport_sample else None,
"hazardClass": hazard_classes[(global_operation_index // 9) % len(hazard_classes)] if hazard_sample else None,
"areaDensityUnits": 1 if hazard_sample else 0,
"maxAreaDensityUnits": 2 if hazard_sample else None,
"materialReadyAt": material_ready.isoformat(),
"needDate": need_date.isoformat(),
"drawingReleased": True,
"holdPoint": hold_point,
"holdStatus": "RELEASED" if hold_point else "NOT_REQUIRED",
"holdReleaseAt": material_ready.isoformat() if hold_point else None,
"weatherSensitive": weather_sensitive,
"weatherWindowStatus": "OPEN" if weather_sensitive else "NOT_REQUIRED",
"weatherWindowStart": config.planning_base_date.isoformat() if weather_sensitive else None,
"weatherWindowEnd": config.planning_horizon_end.isoformat() if weather_sensitive else None,
"maintenanceWindowStatus": "ENFORCED",
"dockExclusive": required_type == "DOCK",
"dockId": target_resource.get("resourceId") if required_type == "DOCK" else None,
"berthExclusive": required_type == "BERTH",
"berthId": target_resource.get("resourceId") if required_type == "BERTH" else None,
"supportFrameSiteExclusive": required_type in {"JIG", "PLATFORM", "BLOCK_YARD"},
"supportFrameSiteId": (
target_resource.get("resourceId")
if required_type in {"JIG", "PLATFORM", "BLOCK_YARD"}
else None
),
"liftingRequired": "CRANE" in required_type,
"liftWindowStatus": "OPEN" if "CRANE" in required_type else "NOT_REQUIRED",
"liftWindowStart": config.planning_base_date.isoformat() if "CRANE" in required_type else None,
"liftWindowEnd": config.planning_horizon_end.isoformat() if "CRANE" in required_type else None,
"transportPathRequired": transport_sample,
"transportPathId": (
target_resource.get("transportZone") or f"PATH-SYN-{global_operation_index % 12:02d}"
if transport_sample
else None
),
"transportPathStatus": "OPEN" if transport_sample else "NOT_REQUIRED",
"paintingEnvironmentRequired": required_type == "PAINT_BOOTH",
"paintingEnvironmentStatus": (
"ACCEPTABLE" if required_type == "PAINT_BOOTH" else "NOT_REQUIRED"
),
"paintHumidityPercent": 62.0 if required_type == "PAINT_BOOTH" else None,
"paintMaxHumidityPercent": 80.0 if required_type == "PAINT_BOOTH" else None,
"paintTemperatureC": 22.0 if required_type == "PAINT_BOOTH" else None,
"paintMinTemperatureC": 5.0 if required_type == "PAINT_BOOTH" else None,
"paintMaxTemperatureC": 35.0 if required_type == "PAINT_BOOTH" else None,
"erectionSequence": position + 1 if strict_erection_sample else None,
"erectionPredecessorOperationId": (
order_operation_ids[position - 1]
if strict_erection_sample and position > 0
else None
),
"spatialInterferenceGroup": (
f"SPATIAL-{work_order_id}"
if strict_spatial_sample and position in {0, 4}
else None
),
"criticalEquipmentRequired": order_index % 29 == 0 and position == 0,
"criticalEquipmentId": (
f"CEQ-SYN-{order_index:05d}" if order_index % 29 == 0 and position == 0 else None
),
"criticalEquipmentStatus": (
"AVAILABLE" if order_index % 29 == 0 and position == 0 else "NOT_REQUIRED"
),
"criticalEquipmentArrivalAt": (
material_ready.isoformat() if order_index % 29 == 0 and position == 0 else None
),
"witnessRequired": hold_point,
"witnessCalendarStatus": "OPEN" if hold_point else "NOT_REQUIRED",
"witnessWindowStart": config.planning_base_date.isoformat() if hold_point else None,
"witnessWindowEnd": config.planning_horizon_end.isoformat() if hold_point else None,
"launchTrialWeatherRequired": required_type in {"SLIPWAY", "COMMISSION_EQUIPMENT"},
"launchTrialWeatherStatus": (
"OPEN" if required_type in {"SLIPWAY", "COMMISSION_EQUIPMENT"} else "NOT_REQUIRED"
),
"launchTrialWeatherWindowStart": (
config.planning_base_date.isoformat()
if required_type in {"SLIPWAY", "COMMISSION_EQUIPMENT"}
else None
),
"launchTrialWeatherWindowEnd": (
config.planning_horizon_end.isoformat()
if required_type in {"SLIPWAY", "COMMISSION_EQUIPMENT"}
else None
),
"executionStatus": "PLANNED",
"startedTaskImmutable": False,
"frozenWorkOrderProtected": False,
"sourcingMode": "OUTSOURCE" if is_outsource else "MAKE",
"active": True,
"freezePolicy": "RESPECT_TIME_FENCE",
"status": "READY",
"evidenceRefs": [
f"production-order:{production_order_id}",
f"routing:{routing['routingId']}",
f"routing-operation:{template.get('routingOperationId') or template.get('operationId')}",
f"work-package:{work_package['workPackageId']}",
],
}
operations.append(operation)
capacity_demands.append(
{
"capacityDemandId": stable_id("capacity-demand", operation_id, prefix="CAP"),
"operationId": operation_id,
"resourceType": required_type,
"resourceGroupId": operation["requiredResourceGroup"],
"demandHours": duration,
"crewSize": crew_size,
"requiredSkillCodes": required_skills,
"requiredQualificationCodes": required_qualifications,
"requiredWeightT": required_weight,
"requiredLengthM": required_length,
"requiredWidthM": required_width,
"requiredHeightM": required_height,
"requiredLiftRadiusM": required_radius,
"capacityMode": "FINITE",
}
)
global_operation_index += 1
for operation in operations:
operation["sourcingMode"] = "MAKE"
outsource_candidates = sorted(
[
operation
for operation in operations
if operation.get("previousOperationId")
and operation.get("nextOperationId")
],
key=lambda operation: (
str(operation.get("materialReadyAt") or ""),
str(operation.get("needDate") or ""),
str(operation.get("operationId") or ""),
),
)
early_candidate_pool = outsource_candidates[
: max(
config.profile.outsource_suggestion_count,
config.profile.outsource_suggestion_count * 5,
)
]
selected_early_positions = _selected_indexes(
len(early_candidate_pool),
config.profile.outsource_suggestion_count,
)
for selected_position in selected_early_positions:
early_candidate_pool[selected_position]["sourcingMode"] = "OUTSOURCE"
outsource_operations = [
row for row in operations if row["sourcingMode"] == "OUTSOURCE"
]
specialist_suppliers = [row for row in suppliers if row.get("outsourceOperationCodes")]
if not specialist_suppliers and outsource_operations:
raise ValueError("outsource suggestions require approved specialist suppliers")
requirements_by_wbs: dict[str, list[dict[str, Any]]] = defaultdict(list)
requirements_by_project: dict[str, list[dict[str, Any]]] = defaultdict(list)
for requirement in sorted(
bundle.rows("material-requirements"),
key=lambda row: str(row.get("requirementId") or ""),
):
requirements_by_wbs[str(requirement.get("wbsId") or "")].append(requirement)
requirements_by_project[str(requirement.get("projectId") or "")].append(requirement)
work_orders_by_id = {str(row["workOrderId"]): row for row in work_orders}
production_orders_by_id = {
str(row["productionOrderId"]): row for row in production_orders
}
outsource_suggestions: list[dict[str, Any]] = []
monthly_assignments: dict[tuple[str, str], int] = defaultdict(int)
for index, operation in enumerate(outsource_operations, 1):
candidate_suppliers = [
supplier
for supplier in specialist_suppliers
if supplier.get("approved", True) and supplier.get("status", "ACTIVE") == "ACTIVE"
]
if not candidate_suppliers:
raise ValueError(f"no approved specialist supplier for {operation['operationId']}")
supplier = candidate_suppliers[(index - 1) % len(candidate_suppliers)]
operation_code = str(supplier.get("outsourceOperationCodes")[0])
send_date = _as_date(operation["materialReadyAt"], config.planning_base_date) + timedelta(
days=3 + index % 5
)
supplier_blackouts = {str(value)[:10] for value in supplier.get("blackoutDates") or []}
while send_date.isoformat() in supplier_blackouts:
send_date += timedelta(days=1)
transport_days_each_way = int(supplier.get("transportLeadTimeDays") or 2)
process_days = max(3, min(21, int(supplier.get("standardLeadTimeDays") or 8) // 2))
inspection_days = max(1, int(supplier.get("inspectionLeadTimeDays") or 2))
round_trip_transport_days = transport_days_each_way * 2
return_date = send_date + timedelta(
days=round_trip_transport_days + process_days + inspection_days
)
capacity_bucket = send_date.strftime("%Y-%m")
capacity_key = (str(supplier["supplierId"]), capacity_bucket)
monthly_assignments[capacity_key] += 1
monthly_capacity = float(supplier.get("monthlyCapacity") or 0.0)
capacity_pass = monthly_capacity <= 0 or monthly_assignments[capacity_key] <= monthly_capacity
operation.update(
{
"outsourceSupplierId": supplier["supplierId"],
"outsourceOperationCode": operation_code,
"outsourceSendAt": send_date.isoformat(),
"outsourceSendDate": send_date.isoformat(),
"outsourceReturnAt": return_date.isoformat(),
"outsourceTransportDays": round_trip_transport_days,
"outsourceProcessingDays": process_days,
"outsourceInspectionDays": inspection_days,
}
)
candidate_requirements = requirements_by_wbs.get(str(operation["wbsId"])) or requirements_by_project.get(
str(operation["projectId"]),
[],
)
if not candidate_requirements:
raise ValueError(
f"outsource operation {operation['operationId']} has no traceable material requirement"
)
material_requirement = next(
(
row
for row in candidate_requirements
if row.get("sourcingMode") == "OUTSOURCE"
),
candidate_requirements[0],
)
work_order = work_orders_by_id[str(operation["workOrderId"])]
production_order = production_orders_by_id[str(work_order["productionOrderId"])]
outsource_suggestion_id = stable_id(
"outsource",
operation["operationId"],
prefix="OUT",
)
required_certificate = f"SHIP-OUTSOURCE-{operation_code}-APPROVAL"
outsource_suggestions.append(
{
"outsourceSuggestionId": outsource_suggestion_id,
"outsourceOrderId": outsource_suggestion_id,
"documentType": "OUTSOURCE_SUGGESTION",
"recordType": "SUGGESTION",
"isActualOrder": False,
"projectId": operation["projectId"],
"wbsId": operation["wbsId"],
"materialId": material_requirement["materialId"],
"operationId": operation["operationId"],
"operationCode": operation_code,
"supplierId": supplier["supplierId"],
"previousOperationId": operation["previousOperationId"],
"nextOperationId": operation["nextOperationId"],
"sendDate": send_date.isoformat(),
"expectedReturnDate": return_date.isoformat(),
"returnDate": return_date.isoformat(),
"transportOutboundDays": transport_days_each_way,
"transportReturnDays": transport_days_each_way,
"transportDays": round_trip_transport_days,
"processDays": process_days,
"processingDays": process_days,
"inspectionDays": inspection_days,
"quantity": float(production_order.get("quantity") or 1.0),
"quantityUnit": production_order.get("unit") or "SET",
"qualityStatus": "PENDING_INSPECTION",
"requiredCertificate": required_certificate,
"riskLevel": supplier.get("riskLevel") or "MEDIUM",
"capacityBucket": capacity_bucket,
"capacityUnit": supplier.get("monthlyCapacityUnit") or "OPERATION",
"supplierCapacityCheck": "PASS" if capacity_pass else "FAIL",
"status": "SUGGESTED",
"evidenceRefs": [
f"operation:{operation['operationId']}",
f"supplier:{supplier['supplierId']}",
f"material:{material_requirement['materialId']}",
f"requirement:{material_requirement['requirementId']}",
],
}
)
operations_by_work_order: dict[str, list[dict[str, Any]]] = defaultdict(list)
for operation in operations:
operations_by_work_order[str(operation["workOrderId"])].append(operation)
frozen_work_order_count = max(1, min(3, target_orders // 20 or 1))
frozen_work_orders: list[dict[str, Any]] = []
for work_order in work_orders:
order_operations = operations_by_work_order[str(work_order["workOrderId"])]
if any(row.get("sourcingMode") == "OUTSOURCE" for row in order_operations):
continue
frozen_work_orders.append(work_order)
if len(frozen_work_orders) >= frozen_work_order_count:
break
if not frozen_work_orders:
raise AssertionError("at least one MAKE-only work order is required for the real frozen baseline")
production_order_by_id = {str(row["productionOrderId"]): row for row in production_orders}
for frozen_index, work_order in enumerate(frozen_work_orders):
frozen_ready = config.planning_base_date + timedelta(days=frozen_index)
work_order.update(
{
"materialReadyAt": frozen_ready.isoformat(),
"mesDispatchProtected": True,
"frozenOrderProtected": True,
"freezeReason": "PLANNING_BASELINE_WITHIN_14_DAYS",
}
)
production_order = production_order_by_id[str(work_order["productionOrderId"])]
production_order.update(
{
"materialReadyAt": frozen_ready.isoformat(),
"plannedReleaseDate": config.planning_base_date.isoformat(),
"freezeStatus": "FROZEN",
}
)
for operation in operations_by_work_order[str(work_order["workOrderId"])]:
operation["materialReadyAt"] = frozen_ready.isoformat()
operation["frozenWorkOrderProtected"] = True
if operation.get("holdPoint"):
operation["holdReleaseAt"] = frozen_ready.isoformat()
if operation.get("criticalEquipmentRequired"):
operation["criticalEquipmentArrivalAt"] = frozen_ready.isoformat()
if int(operation.get("sequence") or 0) == 10:
operation["executionStatus"] = "STARTED"
operation["startedTaskImmutable"] = True
for readiness in bundle.rows("kit-readiness"):
if str(readiness.get("workPackageId")) == str(work_order.get("workPackageId")):
readiness["readyDate"] = frozen_ready.isoformat()
readiness["status"] = "READY"
if len(production_orders) != target_orders or len(work_orders) != target_orders:
raise AssertionError("production/work order cardinality contract failed")
if (
len(operations) != target_operations
or len(outsource_suggestions) != config.profile.outsource_suggestion_count
):
raise AssertionError("operation/outsource cardinality contract failed")
if any(
sum(1 for operation in operations if operation["workOrderId"] == row["workOrderId"]) != 5
for row in work_orders
):
raise AssertionError("every work order must contain exactly five operations")
if any(
row.get("previousOperationId") is None or row.get("nextOperationId") is None
for row in outsource_suggestions
):
raise AssertionError("every outsource suggestion must retain predecessor and successor context")
bundle.set_rows("production-orders", production_orders)
bundle.set_rows("work-orders", work_orders)
bundle.set_rows("operations", operations)
bundle.set_rows("capacity-demands", capacity_demands)
bundle.set_rows("outsource-suggestions", outsource_suggestions)
bundle.artifacts["order-generation"] = {
"productionOrderCount": len(production_orders),
"workOrderCount": len(work_orders),
"operationCount": len(operations),
"operationsPerOrder": 5,
"outsourceSuggestionCount": len(outsource_suggestions),
"relationTypeCounts": {
relation_type: sum(row.get("relationType") == relation_type for row in operations)
for relation_type in relation_types
},
"positiveLagCount": sum(float(row.get("lagHours") or 0.0) > 0 for row in operations),
"negativeLagCount": sum(float(row.get("lagHours") or 0.0) < 0 for row in operations),
"qualificationRequirementCount": sum(bool(row.get("requiredQualificationCodes")) for row in operations),
"physicalRequirementCount": sum(bool(row.get("requiredLengthM")) for row in operations),
"transportRequirementCount": sum(row.get("transportWindowStatus") == "OPEN" for row in operations),
"hazardRequirementCount": sum(bool(row.get("hazardClass")) for row in operations),
"weatherRequirementCount": sum(bool(row.get("weatherSensitive")) for row in operations),
"frozenWorkOrderCount": len(frozen_work_orders),
"startedImmutableTaskCount": sum(bool(row.get("startedTaskImmutable")) for row in operations),
"dockExclusiveSampleCount": sum(bool(row.get("dockExclusive")) for row in operations),
"berthExclusiveSampleCount": sum(bool(row.get("berthExclusive")) for row in operations),
"supportFrameSiteSampleCount": sum(bool(row.get("supportFrameSiteExclusive")) for row in operations),
"liftingWindowSampleCount": sum(bool(row.get("liftingRequired")) for row in operations),
"paintingEnvironmentSampleCount": sum(bool(row.get("paintingEnvironmentRequired")) for row in operations),
"erectionSequenceSampleCount": sum(bool(row.get("erectionPredecessorOperationId")) for row in operations),
"spatialInterferenceSampleCount": sum(bool(row.get("spatialInterferenceGroup")) for row in operations),
"criticalEquipmentSampleCount": sum(bool(row.get("criticalEquipmentRequired")) for row in operations),
"witnessCalendarSampleCount": sum(bool(row.get("witnessRequired")) for row in operations),
"launchTrialWeatherSampleCount": sum(bool(row.get("launchTrialWeatherRequired")) for row in operations),
"deterministic": True,
"businessDate": config.planning_base_date.isoformat(),
}
return bundle