778 lines
40 KiB
Python
778 lines
40 KiB
Python
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
|