1005 lines
45 KiB
Python
1005 lines
45 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from datetime import date, datetime, time, timedelta
|
|
from itertools import pairwise
|
|
from typing import Any
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from .config import GeneratorConfig
|
|
from .models import DatasetBundle
|
|
|
|
|
|
def _as_datetime(value: Any, timezone: ZoneInfo) -> datetime:
|
|
parsed = datetime.fromisoformat(str(value))
|
|
return parsed.replace(tzinfo=timezone) if parsed.tzinfo is None else parsed.astimezone(timezone)
|
|
|
|
|
|
def _as_date(value: Any, fallback: date) -> date:
|
|
try:
|
|
return date.fromisoformat(str(value or "")[:10])
|
|
except ValueError:
|
|
return fallback
|
|
|
|
|
|
def _clock(value: Any, fallback: time) -> time:
|
|
try:
|
|
return time.fromisoformat(str(value or ""))
|
|
except ValueError:
|
|
return fallback
|
|
|
|
|
|
def _calendar_span(
|
|
bundle: DatasetBundle,
|
|
calendar_id: Any,
|
|
day: date,
|
|
timezone: ZoneInfo,
|
|
) -> tuple[datetime, datetime] | None:
|
|
calendar = next((row for row in bundle.rows("calendars") if str(row.get("calendarId")) == str(calendar_id)), None)
|
|
working_days = {int(value) for value in (calendar or {}).get("workingDays") or [1, 2, 3, 4, 5]}
|
|
if day.isoweekday() not in working_days:
|
|
return None
|
|
shifts = [
|
|
row for row in bundle.rows("shifts")
|
|
if str(row.get("calendarId")) == str(calendar_id) and row.get("status", "ACTIVE") == "ACTIVE"
|
|
]
|
|
if not shifts:
|
|
shifts = [{"startTime": "08:00", "endTime": "16:00"}]
|
|
starts: list[datetime] = []
|
|
ends: list[datetime] = []
|
|
for shift in shifts:
|
|
start = datetime.combine(day, _clock(shift.get("startTime"), time(8)), tzinfo=timezone)
|
|
end = datetime.combine(day, _clock(shift.get("endTime"), time(16)), tzinfo=timezone)
|
|
if end <= start:
|
|
end += timedelta(days=1)
|
|
starts.append(start)
|
|
ends.append(end)
|
|
return min(starts), max(ends)
|
|
|
|
|
|
def _append_violation(
|
|
violations: list[dict[str, Any]],
|
|
constraint: str,
|
|
entity_id: str,
|
|
message: str,
|
|
) -> None:
|
|
violations.append(
|
|
{
|
|
"constraint": constraint,
|
|
"entityId": entity_id,
|
|
"message": message,
|
|
"severity": "HARD",
|
|
"markedExpected": False,
|
|
}
|
|
)
|
|
|
|
|
|
def _optional_datetime(
|
|
value: Any,
|
|
timezone: ZoneInfo,
|
|
*,
|
|
end_of_day: bool = False,
|
|
) -> datetime | None:
|
|
if value in (None, ""):
|
|
return None
|
|
text = str(value)
|
|
parsed = datetime.fromisoformat(text)
|
|
if "T" not in text and " " not in text and end_of_day:
|
|
parsed = datetime.combine(parsed.date(), time(23, 59, 59, 999999))
|
|
return parsed.replace(tzinfo=timezone) if parsed.tzinfo is None else parsed.astimezone(timezone)
|
|
|
|
|
|
def _resource_limit(resource: dict[str, Any], *keys: str) -> float | None:
|
|
for key in keys:
|
|
value = resource.get(key)
|
|
if value not in (None, ""):
|
|
return float(value)
|
|
return None
|
|
|
|
|
|
def _overlaps(
|
|
first_start: datetime,
|
|
first_end: datetime,
|
|
second_start: datetime,
|
|
second_end: datetime,
|
|
) -> bool:
|
|
return first_start < second_end and second_start < first_end
|
|
|
|
|
|
def _hazards_incompatible(first: str, second: str) -> bool:
|
|
if not first or not second:
|
|
return False
|
|
if first == second:
|
|
return False
|
|
incompatible_pairs = {
|
|
frozenset(("HOT_WORK", "PAINT_VOC")),
|
|
frozenset(("HOT_WORK", "CONFINED_SPACE")),
|
|
frozenset(("PAINT_VOC", "CONFINED_SPACE")),
|
|
}
|
|
return frozenset((first, second)) in incompatible_pairs
|
|
|
|
|
|
def _date_span(start: datetime, end: datetime) -> set[str]:
|
|
dates: set[str] = set()
|
|
cursor = start.date()
|
|
while cursor <= end.date():
|
|
dates.add(cursor.isoformat())
|
|
cursor += timedelta(days=1)
|
|
return dates
|
|
|
|
|
|
def validate_schedule_constraints(bundle: DatasetBundle, config: GeneratorConfig) -> dict:
|
|
"""Independently validate finite shipyard resource, network and execution constraints."""
|
|
timezone = ZoneInfo(config.timezone)
|
|
all_operations = {
|
|
str(row["operationId"]): row for row in bundle.rows("operations")
|
|
}
|
|
operations = {
|
|
operation_id: row
|
|
for operation_id, row in all_operations.items()
|
|
if row.get("active", True)
|
|
}
|
|
work_orders = {
|
|
str(row["workOrderId"]): row for row in bundle.rows("work-orders")
|
|
}
|
|
slots = bundle.rows("schedule-slots")
|
|
resources = {str(row["resourceId"]): row for row in bundle.rows("resources")}
|
|
teams = {str(row["teamId"]): row for row in bundle.rows("teams")}
|
|
suppliers = {str(row["supplierId"]): row for row in bundle.rows("suppliers")}
|
|
employees_by_team: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
for employee in bundle.rows("employees"):
|
|
if employee.get("status", "ACTIVE") == "ACTIVE":
|
|
employees_by_team[str(employee.get("teamId"))].append(employee)
|
|
violations: list[dict[str, Any]] = []
|
|
checks = 0
|
|
|
|
slot_ids: set[str] = set()
|
|
slots_by_operation: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
parsed_slots: list[tuple[dict[str, Any], datetime, datetime]] = []
|
|
for slot in slots:
|
|
slot_id = str(slot.get("scheduleSlotId") or "")
|
|
operation_id = str(slot.get("operationId") or "")
|
|
checks += 2
|
|
if slot_id in slot_ids:
|
|
_append_violation(violations, "UNIQUE_SLOT_ID", slot_id, "duplicate schedule slot id")
|
|
slot_ids.add(slot_id)
|
|
slots_by_operation[operation_id].append(slot)
|
|
try:
|
|
start = _as_datetime(slot.get("start"), timezone)
|
|
end = _as_datetime(slot.get("end"), timezone)
|
|
except (TypeError, ValueError):
|
|
_append_violation(violations, "VALID_INTERVAL", slot_id, "slot has an invalid ISO datetime")
|
|
continue
|
|
parsed_slots.append((slot, start, end))
|
|
if end <= start:
|
|
_append_violation(violations, "VALID_INTERVAL", slot_id, "slot end must be after start")
|
|
|
|
for operation_id in sorted(operations):
|
|
checks += 1
|
|
count = len(slots_by_operation.get(operation_id, []))
|
|
if count != 1:
|
|
_append_violation(
|
|
violations,
|
|
"ACTIVE_OPERATION_COVERAGE",
|
|
operation_id,
|
|
f"expected exactly one slot, found {count}",
|
|
)
|
|
for operation_id in sorted(set(slots_by_operation) - set(operations)):
|
|
checks += 1
|
|
_append_violation(
|
|
violations,
|
|
"NO_ORPHAN_SLOT",
|
|
operation_id,
|
|
"slot references a missing or inactive operation",
|
|
)
|
|
checks += 1
|
|
if len(slots) != config.profile.schedule_slot_count:
|
|
_append_violation(
|
|
violations,
|
|
"EXACT_SLOT_COUNT",
|
|
"baseline",
|
|
f"expected {config.profile.schedule_slot_count} slots, found {len(slots)}",
|
|
)
|
|
|
|
for work_order_id, work_order in sorted(work_orders.items()):
|
|
if not work_order.get("mesDispatchProtected"):
|
|
continue
|
|
protected_operations = [
|
|
row
|
|
for row in all_operations.values()
|
|
if str(row.get("workOrderId")) == work_order_id
|
|
]
|
|
checks += len(protected_operations)
|
|
for operation in protected_operations:
|
|
operation_id = str(operation["operationId"])
|
|
if not operation.get("active", True) or len(slots_by_operation.get(operation_id, [])) != 1:
|
|
_append_violation(
|
|
violations,
|
|
"MES_DISPATCH_DELETE",
|
|
operation_id,
|
|
"a MES-dispatched operation cannot be deactivated or removed from the schedule",
|
|
)
|
|
|
|
timelines: dict[tuple[str, str], list[tuple[datetime, datetime, str]]] = defaultdict(list)
|
|
zone_intervals: dict[
|
|
str,
|
|
list[tuple[datetime, datetime, str, str, int, int]],
|
|
] = defaultdict(list)
|
|
exclusive_intervals: dict[
|
|
tuple[str, str],
|
|
list[tuple[datetime, datetime, str]],
|
|
] = defaultdict(list)
|
|
slot_interval_by_operation: dict[str, tuple[datetime, datetime]] = {}
|
|
slot_by_operation: dict[str, dict[str, Any]] = {}
|
|
|
|
for slot, start, end in parsed_slots:
|
|
slot_id = str(slot["scheduleSlotId"])
|
|
operation_id = str(slot["operationId"])
|
|
operation = operations.get(operation_id)
|
|
resource = resources.get(str(slot.get("resourceId")))
|
|
team = teams.get(str(slot.get("teamId")))
|
|
checks += 24
|
|
if operation is None:
|
|
continue
|
|
slot_interval_by_operation[operation_id] = (start, end)
|
|
slot_by_operation[operation_id] = slot
|
|
if resource is None:
|
|
_append_violation(violations, "RESOURCE_EXISTS", slot_id, "assigned resource does not exist")
|
|
if team is None:
|
|
_append_violation(violations, "TEAM_EXISTS", slot_id, "assigned team does not exist")
|
|
if resource is None or team is None:
|
|
continue
|
|
|
|
timelines[("resource", str(resource["resourceId"]))].append((start, end, slot_id))
|
|
timelines[("team", str(team["teamId"]))].append((start, end, slot_id))
|
|
for kind, enabled_field, owner_field in (
|
|
("DOCK", "dockExclusive", "dockId"),
|
|
("BERTH", "berthExclusive", "berthId"),
|
|
("SUPPORT_FRAME_SITE", "supportFrameSiteExclusive", "supportFrameSiteId"),
|
|
("SPATIAL_INTERFERENCE", "spatialInterferenceGroup", "spatialInterferenceGroup"),
|
|
):
|
|
if not operation.get(enabled_field):
|
|
continue
|
|
owner_id = str(operation.get(owner_field) or "")
|
|
checks += 1
|
|
if not owner_id:
|
|
_append_violation(
|
|
violations,
|
|
f"{kind}_IDENTITY",
|
|
slot_id,
|
|
f"{kind.lower()} constraint lacks an occupancy identity",
|
|
)
|
|
else:
|
|
exclusive_intervals[(kind, owner_id)].append((start, end, slot_id))
|
|
required_type = str(operation.get("requiredResourceType") or "")
|
|
if str(resource.get("resourceType") or "") != required_type:
|
|
_append_violation(
|
|
violations,
|
|
"RESOURCE_TYPE",
|
|
slot_id,
|
|
f"resource type does not match {required_type}",
|
|
)
|
|
required_group = operation.get("requiredResourceGroup")
|
|
if required_group and str(resource.get("resourceGroupId") or "") != str(required_group):
|
|
_append_violation(
|
|
violations,
|
|
"RESOURCE_GROUP",
|
|
slot_id,
|
|
"resource does not belong to the required resource group",
|
|
)
|
|
required_tags = {str(value) for value in operation.get("requiredCapabilityTags") or []}
|
|
resource_tags = {str(value) for value in resource.get("capabilityTags") or []} | {
|
|
required_type,
|
|
"FINITE_CAPACITY",
|
|
}
|
|
if not required_tags.issubset(resource_tags):
|
|
_append_violation(
|
|
violations,
|
|
"RESOURCE_CAPABILITY",
|
|
slot_id,
|
|
"resource capability tags are incomplete",
|
|
)
|
|
required_skills = {str(value) for value in operation.get("requiredSkillCodes") or []}
|
|
team_skills = {str(value) for value in team.get("skillCodes") or []}
|
|
if not required_skills.issubset(team_skills):
|
|
_append_violation(violations, "TEAM_SKILLS", slot_id, "team lacks required skills")
|
|
if int(team.get("crewSize") or 0) < int(operation.get("crewSize") or 1):
|
|
_append_violation(
|
|
violations,
|
|
"TEAM_CREW_SIZE",
|
|
slot_id,
|
|
"team crew size is below demand",
|
|
)
|
|
required_qualifications = {
|
|
str(value) for value in operation.get("requiredQualificationCodes") or []
|
|
}
|
|
valid_qualifications = {
|
|
str(employee.get("qualificationCode"))
|
|
for employee in employees_by_team.get(str(team["teamId"]), [])
|
|
if employee.get("qualificationCode")
|
|
and _as_date(employee.get("qualificationValidTo"), start.date()) >= start.date()
|
|
}
|
|
if not required_qualifications.issubset(valid_qualifications):
|
|
_append_violation(
|
|
violations,
|
|
"TEAM_QUALIFICATION",
|
|
slot_id,
|
|
"team lacks a valid required qualification",
|
|
)
|
|
|
|
dimension_checks = (
|
|
("requiredWeightT", _resource_limit(resource, "maximumWeight", "maximumWeightT"), "RESOURCE_WEIGHT"),
|
|
("requiredLengthM", _resource_limit(resource, "maximumLength", "maximumLengthM"), "RESOURCE_LENGTH"),
|
|
("requiredWidthM", _resource_limit(resource, "maximumWidth", "maximumWidthM"), "RESOURCE_WIDTH"),
|
|
("requiredHeightM", _resource_limit(resource, "maximumHeight", "maximumHeightM"), "RESOURCE_HEIGHT"),
|
|
("requiredLiftRadiusM", _resource_limit(resource, "maximumLiftRadiusM", "maximumLiftRadius"), "CRANE_LIFT_RADIUS"),
|
|
)
|
|
for requirement_key, limit, constraint_name in dimension_checks:
|
|
required_value = operation.get(requirement_key)
|
|
if required_value in (None, ""):
|
|
continue
|
|
if limit is None or float(required_value) > limit + 1e-9:
|
|
_append_violation(
|
|
violations,
|
|
constraint_name,
|
|
slot_id,
|
|
f"resource limit does not cover {requirement_key}",
|
|
)
|
|
|
|
expected_duration = float(operation.get("durationHours") or 0.0)
|
|
elapsed = (end - start).total_seconds() / 3600
|
|
if abs(elapsed - expected_duration) > 0.01:
|
|
_append_violation(
|
|
violations,
|
|
"DURATION",
|
|
slot_id,
|
|
"slot duration does not equal operation duration",
|
|
)
|
|
if start.date() < config.planning_base_date or end.date() > config.planning_horizon_end:
|
|
_append_violation(
|
|
violations,
|
|
"PLANNING_HORIZON",
|
|
slot_id,
|
|
"slot is outside the fixed planning horizon",
|
|
)
|
|
|
|
resource_span = _calendar_span(bundle, resource.get("shiftCalendarId"), start.date(), timezone)
|
|
team_span = _calendar_span(bundle, team.get("shiftCalendarId"), start.date(), timezone)
|
|
if resource_span is None or not (resource_span[0] <= start and end <= resource_span[1]):
|
|
_append_violation(
|
|
violations,
|
|
"RESOURCE_CALENDAR",
|
|
slot_id,
|
|
"slot is outside the resource calendar",
|
|
)
|
|
if team_span is None or not (team_span[0] <= start and end <= team_span[1]):
|
|
_append_violation(
|
|
violations,
|
|
"TEAM_CALENDAR",
|
|
slot_id,
|
|
"slot is outside the team calendar",
|
|
)
|
|
maintenance_blackouts = {
|
|
str(value)[:10] for value in resource.get("maintenanceBlackoutDates") or []
|
|
}
|
|
if _date_span(start, end) & maintenance_blackouts:
|
|
_append_violation(
|
|
violations,
|
|
"MAINTENANCE_BLACKOUT",
|
|
slot_id,
|
|
"slot overlaps a resource maintenance blackout date",
|
|
)
|
|
|
|
material_ready = max(
|
|
_as_date(operation.get("materialReadyAt"), config.planning_base_date),
|
|
_as_date(operation.get("outsourceReturnAt"), config.planning_base_date),
|
|
)
|
|
if start.date() < material_ready:
|
|
_append_violation(
|
|
violations,
|
|
"MATERIAL_READY",
|
|
slot_id,
|
|
"slot starts before material/outsource readiness",
|
|
)
|
|
if operation.get("holdPoint"):
|
|
if operation.get("holdStatus") != "RELEASED":
|
|
_append_violation(violations, "QUALITY_HOLD", slot_id, "hold point is not released")
|
|
hold_release = _as_date(operation.get("holdReleaseAt"), material_ready)
|
|
if start.date() < hold_release:
|
|
_append_violation(
|
|
violations,
|
|
"QUALITY_HOLD",
|
|
slot_id,
|
|
"slot starts before hold release",
|
|
)
|
|
if not operation.get("drawingReleased", False):
|
|
_append_violation(
|
|
violations,
|
|
"DESIGN_RELEASE",
|
|
slot_id,
|
|
"formal work is scheduled before design release",
|
|
)
|
|
|
|
if operation.get("weatherSensitive"):
|
|
if operation.get("weatherWindowStatus") != "OPEN":
|
|
_append_violation(
|
|
violations,
|
|
"WEATHER_WINDOW",
|
|
slot_id,
|
|
"weather-sensitive work lacks an open window",
|
|
)
|
|
weather_start = _optional_datetime(operation.get("weatherWindowStart"), timezone)
|
|
weather_end = _optional_datetime(
|
|
operation.get("weatherWindowEnd"),
|
|
timezone,
|
|
end_of_day=True,
|
|
)
|
|
if (weather_start and start < weather_start) or (weather_end and end > weather_end):
|
|
_append_violation(
|
|
violations,
|
|
"WEATHER_WINDOW",
|
|
slot_id,
|
|
"weather-sensitive slot is outside the open weather window",
|
|
)
|
|
|
|
if operation.get("transportWindowStatus") == "OPEN":
|
|
if str(resource.get("transportZone") or "") != str(operation.get("transportZone") or ""):
|
|
_append_violation(
|
|
violations,
|
|
"TRANSPORT_ZONE",
|
|
slot_id,
|
|
"resource transport zone does not match the operation",
|
|
)
|
|
transport_start = _optional_datetime(operation.get("transportWindowStart"), timezone)
|
|
transport_end = _optional_datetime(
|
|
operation.get("transportWindowEnd"),
|
|
timezone,
|
|
end_of_day=True,
|
|
)
|
|
if (transport_start and start < transport_start) or (transport_end and end > transport_end):
|
|
_append_violation(
|
|
violations,
|
|
"TRANSPORT_WINDOW",
|
|
slot_id,
|
|
"slot is outside the transport window",
|
|
)
|
|
elif operation.get("transportZone"):
|
|
_append_violation(
|
|
violations,
|
|
"TRANSPORT_WINDOW",
|
|
slot_id,
|
|
"transport-zone demand is not in an open transport window",
|
|
)
|
|
|
|
if operation.get("liftingRequired"):
|
|
lift_start = _optional_datetime(operation.get("liftWindowStart"), timezone)
|
|
lift_end = _optional_datetime(
|
|
operation.get("liftWindowEnd"), timezone, end_of_day=True
|
|
)
|
|
if (
|
|
operation.get("liftWindowStatus") != "OPEN"
|
|
or "CRANE" not in str(resource.get("resourceType") or "")
|
|
or (lift_start and start < lift_start)
|
|
or (lift_end and end > lift_end)
|
|
):
|
|
_append_violation(
|
|
violations,
|
|
"LIFT_WINDOW",
|
|
slot_id,
|
|
"lifting work lacks a compatible crane and open lifting window",
|
|
)
|
|
|
|
if operation.get("transportPathRequired") and (
|
|
operation.get("transportPathStatus") != "OPEN"
|
|
or not operation.get("transportPathId")
|
|
):
|
|
_append_violation(
|
|
violations,
|
|
"TRANSPORT_PATH",
|
|
slot_id,
|
|
"block transport path is missing or unavailable",
|
|
)
|
|
|
|
if operation.get("paintingEnvironmentRequired"):
|
|
humidity = float(operation.get("paintHumidityPercent") or 0.0)
|
|
max_humidity = float(operation.get("paintMaxHumidityPercent") or 0.0)
|
|
temperature = float(operation.get("paintTemperatureC") or 0.0)
|
|
min_temperature = float(operation.get("paintMinTemperatureC") or 0.0)
|
|
max_temperature = float(operation.get("paintMaxTemperatureC") or 0.0)
|
|
if (
|
|
operation.get("paintingEnvironmentStatus") != "ACCEPTABLE"
|
|
or max_humidity <= 0
|
|
or humidity > max_humidity
|
|
or temperature < min_temperature
|
|
or temperature > max_temperature
|
|
):
|
|
_append_violation(
|
|
violations,
|
|
"PAINT_ENVIRONMENT",
|
|
slot_id,
|
|
"coating temperature or humidity is outside the qualified range",
|
|
)
|
|
|
|
if operation.get("criticalEquipmentRequired"):
|
|
equipment_arrival = _optional_datetime(
|
|
operation.get("criticalEquipmentArrivalAt"), timezone
|
|
)
|
|
if (
|
|
operation.get("criticalEquipmentStatus") != "AVAILABLE"
|
|
or equipment_arrival is None
|
|
or start < equipment_arrival
|
|
):
|
|
_append_violation(
|
|
violations,
|
|
"CRITICAL_EQUIPMENT_ARRIVAL",
|
|
slot_id,
|
|
"critical equipment is not available before operation start",
|
|
)
|
|
|
|
if operation.get("witnessRequired"):
|
|
witness_start = _optional_datetime(operation.get("witnessWindowStart"), timezone)
|
|
witness_end = _optional_datetime(
|
|
operation.get("witnessWindowEnd"), timezone, end_of_day=True
|
|
)
|
|
if (
|
|
operation.get("witnessCalendarStatus") != "OPEN"
|
|
or (witness_start and start < witness_start)
|
|
or (witness_end and end > witness_end)
|
|
):
|
|
_append_violation(
|
|
violations,
|
|
"WITNESS_CALENDAR",
|
|
slot_id,
|
|
"owner or classification-society witness calendar is unavailable",
|
|
)
|
|
|
|
if operation.get("launchTrialWeatherRequired"):
|
|
marine_start = _optional_datetime(
|
|
operation.get("launchTrialWeatherWindowStart"), timezone
|
|
)
|
|
marine_end = _optional_datetime(
|
|
operation.get("launchTrialWeatherWindowEnd"), timezone, end_of_day=True
|
|
)
|
|
if (
|
|
operation.get("launchTrialWeatherStatus") != "OPEN"
|
|
or (marine_start and start < marine_start)
|
|
or (marine_end and end > marine_end)
|
|
):
|
|
_append_violation(
|
|
violations,
|
|
"LAUNCH_TRIAL_WEATHER",
|
|
slot_id,
|
|
"launch or sea-trial task lacks an open marine weather window",
|
|
)
|
|
|
|
immutable_fields = (
|
|
("start", "startedBaselineStart"),
|
|
("end", "startedBaselineEnd"),
|
|
("resourceId", "startedBaselineResourceId"),
|
|
("teamId", "startedBaselineTeamId"),
|
|
)
|
|
if operation.get("startedTaskImmutable") and any(
|
|
str(slot.get(slot_field)) != str(operation.get(baseline_field))
|
|
for slot_field, baseline_field in immutable_fields
|
|
):
|
|
_append_violation(
|
|
violations,
|
|
"STARTED_TASK_IMMUTABLE",
|
|
slot_id,
|
|
"an already-started task cannot be migrated or rescheduled",
|
|
)
|
|
|
|
frozen_order_fields = (
|
|
("start", "frozenOrderBaselineStart"),
|
|
("end", "frozenOrderBaselineEnd"),
|
|
("resourceId", "frozenOrderBaselineResourceId"),
|
|
("teamId", "frozenOrderBaselineTeamId"),
|
|
)
|
|
if operation.get("frozenWorkOrderProtected") and any(
|
|
str(slot.get(slot_field)) != str(operation.get(baseline_field))
|
|
for slot_field, baseline_field in frozen_order_fields
|
|
):
|
|
_append_violation(
|
|
violations,
|
|
"FROZEN_WORK_ORDER_RESCHEDULE",
|
|
slot_id,
|
|
"a frozen work order cannot be automatically rescheduled",
|
|
)
|
|
|
|
if slot.get("timeFence") == "FROZEN":
|
|
baseline_start = str(slot.get("baselineStart") or "")
|
|
if baseline_start != str(slot.get("start")) and not slot.get("changeAuthorized"):
|
|
_append_violation(
|
|
violations,
|
|
"FROZEN_ZONE",
|
|
slot_id,
|
|
"unauthorized frozen-zone date change",
|
|
)
|
|
|
|
zone_id = str(operation.get("zoneId") or "")
|
|
hazard_class = str(operation.get("hazardClass") or "")
|
|
area_units = int(operation.get("areaDensityUnits") or 0)
|
|
max_area_units = int(operation.get("maxAreaDensityUnits") or 0)
|
|
if zone_id and (hazard_class or area_units):
|
|
zone_intervals[zone_id].append(
|
|
(start, end, slot_id, hazard_class, area_units, max_area_units)
|
|
)
|
|
|
|
for (kind, owner_id), timeline in sorted(timelines.items()):
|
|
ordered = sorted(timeline)
|
|
for previous, current in pairwise(ordered):
|
|
checks += 1
|
|
if previous[1] > current[0]:
|
|
_append_violation(
|
|
violations,
|
|
"FINITE_CAPACITY_OVERLAP",
|
|
owner_id,
|
|
f"{kind} timeline overlaps between {previous[2]} and {current[2]}",
|
|
)
|
|
|
|
exclusivity_codes = {
|
|
"DOCK": "DOCK_EXCLUSIVITY",
|
|
"BERTH": "BERTH_EXCLUSIVITY",
|
|
"SUPPORT_FRAME_SITE": "SUPPORT_FRAME_SITE_OCCUPANCY",
|
|
"SPATIAL_INTERFERENCE": "GRAND_BLOCK_SPATIAL_INTERFERENCE",
|
|
}
|
|
for (kind, owner_id), timeline in sorted(exclusive_intervals.items()):
|
|
ordered = sorted(timeline)
|
|
for previous, current in pairwise(ordered):
|
|
checks += 1
|
|
if previous[1] > current[0]:
|
|
_append_violation(
|
|
violations,
|
|
exclusivity_codes[kind],
|
|
owner_id,
|
|
f"{kind.lower()} occupancy overlaps between {previous[2]} and {current[2]}",
|
|
)
|
|
|
|
for zone_id, intervals in sorted(zone_intervals.items()):
|
|
ordered = sorted(intervals)
|
|
for index, current in enumerate(ordered):
|
|
for other in ordered[index + 1 :]:
|
|
if other[0] >= current[1]:
|
|
break
|
|
checks += 1
|
|
if _overlaps(current[0], current[1], other[0], other[1]) and _hazards_incompatible(
|
|
current[3],
|
|
other[3],
|
|
):
|
|
_append_violation(
|
|
violations,
|
|
"HAZARD_INCOMPATIBILITY",
|
|
zone_id,
|
|
f"incompatible hazards overlap between {current[2]} and {other[2]}",
|
|
)
|
|
events: list[tuple[datetime, int, int, str]] = []
|
|
zone_limit = min((row[5] for row in intervals if row[5] > 0), default=0)
|
|
for start, end, slot_id, _hazard, units, _limit in intervals:
|
|
if units <= 0:
|
|
continue
|
|
events.append((start, 1, units, slot_id))
|
|
events.append((end, 0, -units, slot_id))
|
|
active_units = 0
|
|
for _moment, event_order, delta, slot_id in sorted(events):
|
|
checks += 1
|
|
active_units += delta
|
|
if event_order == 1 and zone_limit > 0 and active_units > zone_limit:
|
|
_append_violation(
|
|
violations,
|
|
"AREA_DENSITY",
|
|
zone_id,
|
|
f"area density exceeds {zone_limit} at {slot_id}",
|
|
)
|
|
|
|
for operation_id, operation in sorted(operations.items()):
|
|
predecessor_id = operation.get("predecessorOperationId")
|
|
if not predecessor_id:
|
|
continue
|
|
checks += 1
|
|
current = slot_interval_by_operation.get(operation_id)
|
|
predecessor = slot_interval_by_operation.get(str(predecessor_id))
|
|
if current is None or predecessor is None:
|
|
continue
|
|
lag = timedelta(hours=float(operation.get("lagHours") or 0.0))
|
|
relation_type = str(operation.get("relationType") or "FS").upper()
|
|
relation_valid = {
|
|
"FS": current[0] >= predecessor[1] + lag,
|
|
"SS": current[0] >= predecessor[0] + lag,
|
|
"FF": current[1] >= predecessor[1] + lag,
|
|
"SF": current[1] >= predecessor[0] + lag,
|
|
}.get(relation_type)
|
|
if relation_valid is None:
|
|
_append_violation(
|
|
violations,
|
|
"PRECEDENCE_RELATION_TYPE",
|
|
operation_id,
|
|
f"unsupported relation type {relation_type}",
|
|
)
|
|
elif not relation_valid:
|
|
_append_violation(
|
|
violations,
|
|
f"{relation_type}_PRECEDENCE",
|
|
operation_id,
|
|
f"{relation_type} relation violates lag {float(operation.get('lagHours') or 0.0)}h",
|
|
)
|
|
|
|
for operation_id, operation in sorted(operations.items()):
|
|
erection_predecessor_id = operation.get("erectionPredecessorOperationId")
|
|
if not erection_predecessor_id:
|
|
continue
|
|
checks += 1
|
|
current = slot_interval_by_operation.get(operation_id)
|
|
predecessor = slot_interval_by_operation.get(str(erection_predecessor_id))
|
|
if current is None or predecessor is None or current[0] < predecessor[1]:
|
|
_append_violation(
|
|
violations,
|
|
"SECTION_ERECTION_SEQUENCE",
|
|
operation_id,
|
|
"section erection starts before its declared erection predecessor completes",
|
|
)
|
|
|
|
outsource_suggestions = bundle.rows("outsource-suggestions")
|
|
suggestions_by_operation: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
supplier_monthly_count: dict[tuple[str, str], int] = defaultdict(int)
|
|
for suggestion in outsource_suggestions:
|
|
suggestions_by_operation[str(suggestion.get("operationId"))].append(suggestion)
|
|
for operation_id, operation in sorted(operations.items()):
|
|
suggestions = suggestions_by_operation.get(operation_id, [])
|
|
is_outsource = operation.get("sourcingMode") == "OUTSOURCE"
|
|
checks += 1
|
|
if is_outsource and len(suggestions) != 1:
|
|
_append_violation(
|
|
violations,
|
|
"OUTSOURCE_SUGGESTION_COVERAGE",
|
|
operation_id,
|
|
f"expected one outsource suggestion, found {len(suggestions)}",
|
|
)
|
|
if not is_outsource and suggestions:
|
|
_append_violation(
|
|
violations,
|
|
"OUTSOURCE_SUGGESTION_SCOPE",
|
|
operation_id,
|
|
"MAKE operation unexpectedly has an outsource suggestion",
|
|
)
|
|
if not is_outsource or len(suggestions) != 1:
|
|
continue
|
|
suggestion = suggestions[0]
|
|
supplier_id = str(suggestion.get("supplierId") or "")
|
|
supplier = suppliers.get(supplier_id)
|
|
checks += 12
|
|
if supplier is None or not supplier.get("approved", False) or supplier.get("status", "ACTIVE") != "ACTIVE":
|
|
_append_violation(
|
|
violations,
|
|
"OUTSOURCE_SUPPLIER_APPROVAL",
|
|
operation_id,
|
|
"outsource supplier is missing, inactive or unapproved",
|
|
)
|
|
continue
|
|
operation_code = str(suggestion.get("operationCode") or operation.get("outsourceOperationCode") or "")
|
|
supplier_codes = {str(value) for value in supplier.get("outsourceOperationCodes") or []}
|
|
if not operation_code or operation_code not in supplier_codes:
|
|
_append_violation(
|
|
violations,
|
|
"OUTSOURCE_SUPPLIER_CAPABILITY",
|
|
operation_id,
|
|
"supplier does not support the outsource operation code",
|
|
)
|
|
if str(operation.get("outsourceSupplierId") or "") != supplier_id:
|
|
_append_violation(
|
|
violations,
|
|
"OUTSOURCE_SUPPLIER_LINK",
|
|
operation_id,
|
|
"operation and suggestion supplier ids differ",
|
|
)
|
|
if (
|
|
str(suggestion.get("previousOperationId") or "")
|
|
!= str(operation.get("previousOperationId") or "")
|
|
or str(suggestion.get("nextOperationId") or "")
|
|
!= str(operation.get("nextOperationId") or "")
|
|
or not suggestion.get("previousOperationId")
|
|
or not suggestion.get("nextOperationId")
|
|
):
|
|
_append_violation(
|
|
violations,
|
|
"OUTSOURCE_ROUTE_CONTEXT",
|
|
operation_id,
|
|
"outsource suggestion lacks matching predecessor/successor context",
|
|
)
|
|
try:
|
|
send_at = _as_datetime(suggestion.get("sendDate"), timezone)
|
|
return_at = _as_datetime(suggestion.get("returnDate"), timezone)
|
|
except (TypeError, ValueError):
|
|
_append_violation(
|
|
violations,
|
|
"OUTSOURCE_TIME_CHAIN",
|
|
operation_id,
|
|
"outsource suggestion contains invalid send/return timestamps",
|
|
)
|
|
continue
|
|
total_days = int(suggestion.get("transportDays") or 0) + int(
|
|
suggestion.get("processingDays") or 0
|
|
) + int(suggestion.get("inspectionDays") or 0)
|
|
expected_return = send_at + timedelta(days=total_days)
|
|
if abs((return_at - expected_return).total_seconds()) > 1.0:
|
|
_append_violation(
|
|
violations,
|
|
"OUTSOURCE_TIME_CHAIN",
|
|
operation_id,
|
|
"return timestamp does not include transport, processing and inspection lead time",
|
|
)
|
|
operation_send = _as_datetime(operation.get("outsourceSendAt"), timezone)
|
|
operation_return = _as_datetime(operation.get("outsourceReturnAt"), timezone)
|
|
if abs((operation_send - send_at).total_seconds()) > 1.0 or abs(
|
|
(operation_return - return_at).total_seconds()
|
|
) > 1.0:
|
|
_append_violation(
|
|
violations,
|
|
"OUTSOURCE_TIME_SOURCE",
|
|
operation_id,
|
|
"operation and suggestion do not share one outsource time source",
|
|
)
|
|
predecessor_interval = slot_interval_by_operation.get(str(operation.get("previousOperationId")))
|
|
current_interval = slot_interval_by_operation.get(operation_id)
|
|
next_interval = slot_interval_by_operation.get(str(operation.get("nextOperationId")))
|
|
if predecessor_interval and send_at < predecessor_interval[1]:
|
|
_append_violation(
|
|
violations,
|
|
"OUTSOURCE_SEND_AFTER_PREDECESSOR",
|
|
operation_id,
|
|
"outsource send occurs before predecessor completion",
|
|
)
|
|
if current_interval and current_interval[0] < return_at:
|
|
_append_violation(
|
|
violations,
|
|
"OUTSOURCE_RETURN_BEFORE_SLOT",
|
|
operation_id,
|
|
"outsource operation starts before return",
|
|
)
|
|
if next_interval and next_interval[0] < return_at:
|
|
_append_violation(
|
|
violations,
|
|
"OUTSOURCE_RETURN_BEFORE_SUCCESSOR",
|
|
operation_id,
|
|
"successor starts before outsource return",
|
|
)
|
|
supplier_blackouts = {str(value)[:10] for value in supplier.get("blackoutDates") or []}
|
|
if send_at.date().isoformat() in supplier_blackouts or return_at.date().isoformat() in supplier_blackouts:
|
|
_append_violation(
|
|
violations,
|
|
"SUPPLIER_BLACKOUT",
|
|
operation_id,
|
|
"outsource send or return occurs on a supplier blackout date",
|
|
)
|
|
capacity_bucket = send_at.strftime("%Y-%m")
|
|
supplier_monthly_count[(supplier_id, capacity_bucket)] += 1
|
|
|
|
for (supplier_id, capacity_bucket), assigned_count in sorted(supplier_monthly_count.items()):
|
|
checks += 1
|
|
supplier = suppliers[supplier_id]
|
|
capacity = float(supplier.get("monthlyCapacity") or 0.0)
|
|
capacity_unit = str(supplier.get("monthlyCapacityUnit") or "OPERATION")
|
|
if capacity_unit == "OPERATION" and capacity > 0 and assigned_count > capacity:
|
|
_append_violation(
|
|
violations,
|
|
"SUPPLIER_MONTHLY_CAPACITY",
|
|
f"{supplier_id}:{capacity_bucket}",
|
|
f"assigned {assigned_count} operations above capacity {capacity}",
|
|
)
|
|
|
|
conflicts = bundle.rows("conflicts")
|
|
checks += len(conflicts) + 1
|
|
if len(conflicts) != config.profile.risk_conflict_count:
|
|
_append_violation(
|
|
violations,
|
|
"EXACT_CONFLICT_COUNT",
|
|
"baseline",
|
|
f"expected {config.profile.risk_conflict_count} conflict/risk rows, found {len(conflicts)}",
|
|
)
|
|
for conflict in conflicts:
|
|
if not (
|
|
conflict.get("detected") is True
|
|
and conflict.get("expected") is True
|
|
and conflict.get("resolutionStatus") == "RESOLVED"
|
|
and conflict.get("hardConstraintViolation") is False
|
|
):
|
|
_append_violation(
|
|
violations,
|
|
"MARKED_RESOLVED_RISK",
|
|
str(conflict.get("conflictId") or ""),
|
|
"baseline risk must be detected, expected, resolved and not a hard violation",
|
|
)
|
|
|
|
business_constraint_specs = (
|
|
("\u5de5\u5e8f\u524d\u540e\u5173\u7cfb", sum(bool(row.get("predecessorOperationId")) for row in operations.values()), {"FS_PRECEDENCE", "SS_PRECEDENCE", "FF_PRECEDENCE", "SF_PRECEDENCE", "PRECEDENCE_RELATION_TYPE"}),
|
|
("\u7269\u6599\u9f50\u5957", len(parsed_slots), {"MATERIAL_READY"}),
|
|
("\u8bbe\u8ba1\u56fe\u7eb8\u5df2\u91ca\u653e", len(parsed_slots), {"DESIGN_RELEASE"}),
|
|
("\u8d44\u6e90\u6709\u9650\u80fd\u529b", len(parsed_slots), {"RESOURCE_TYPE", "RESOURCE_GROUP", "RESOURCE_CAPABILITY", "RESOURCE_WEIGHT", "RESOURCE_LENGTH", "RESOURCE_WIDTH", "RESOURCE_HEIGHT", "FINITE_CAPACITY_OVERLAP"}),
|
|
("\u8bbe\u5907\u7ef4\u62a4\u7a97\u53e3", sum(bool(row.get("maintenanceBlackoutDates")) for row in resources.values()), {"MAINTENANCE_BLACKOUT"}),
|
|
("\u4eba\u5458\u6280\u80fd\u548c\u4eba\u6570", sum(bool(row.get("requiredSkillCodes")) or int(row.get("crewSize") or 0) > 0 for row in operations.values()), {"TEAM_SKILLS", "TEAM_CREW_SIZE", "TEAM_QUALIFICATION"}),
|
|
("\u8239\u575e\u72ec\u5360", sum(bool(row.get("dockExclusive")) for row in operations.values()), {"DOCK_EXCLUSIVITY", "DOCK_IDENTITY"}),
|
|
("\u7801\u5934\u6cca\u4f4d\u72ec\u5360", sum(bool(row.get("berthExclusive")) for row in operations.values()), {"BERTH_EXCLUSIVITY", "BERTH_IDENTITY"}),
|
|
("\u80ce\u67b6\u548c\u573a\u5730\u5360\u7528", sum(bool(row.get("supportFrameSiteExclusive")) for row in operations.values()), {"SUPPORT_FRAME_SITE_OCCUPANCY", "SUPPORT_FRAME_SITE_IDENTITY"}),
|
|
("\u540a\u88c5\u80fd\u529b\u548c\u540a\u88c5\u7a97\u53e3", sum(bool(row.get("liftingRequired")) for row in operations.values()), {"LIFT_WINDOW", "CRANE_LIFT_RADIUS", "RESOURCE_WEIGHT"}),
|
|
("\u5206\u6bb5\u8fd0\u8f93\u8def\u5f84", sum(bool(row.get("transportPathRequired")) for row in operations.values()), {"TRANSPORT_PATH", "TRANSPORT_WINDOW", "TRANSPORT_ZONE"}),
|
|
("\u6d82\u88c5\u73af\u5883\u6761\u4ef6", sum(bool(row.get("paintingEnvironmentRequired")) for row in operations.values()), {"PAINT_ENVIRONMENT"}),
|
|
("\u8d28\u91cf Hold Point", sum(bool(row.get("holdPoint")) for row in operations.values()), {"QUALITY_HOLD"}),
|
|
("\u59d4\u5916\u5230\u8d27", len(outsource_suggestions), {"OUTSOURCE_TIME_CHAIN", "OUTSOURCE_RETURN_BEFORE_SLOT", "OUTSOURCE_RETURN_BEFORE_SUCCESSOR", "OUTSOURCE_SEND_AFTER_PREDECESSOR"}),
|
|
("\u4f9b\u5e94\u5546\u4ea7\u80fd", len(outsource_suggestions), {"SUPPLIER_MONTHLY_CAPACITY", "SUPPLIER_BLACKOUT"}),
|
|
("\u9879\u76ee\u51bb\u7ed3\u533a", sum(row.get("timeFence") == "FROZEN" for row in slots), {"FROZEN_ZONE"}),
|
|
("\u5df2\u5f00\u5de5\u4efb\u52a1\u4e0d\u53ef\u968f\u610f\u8fc1\u79fb", sum(bool(row.get("startedTaskImmutable")) for row in operations.values()), {"STARTED_TASK_IMMUTABLE"}),
|
|
("\u5df2\u4e0b\u53d1 MES \u4efb\u52a1\u4e0d\u53ef\u81ea\u52a8\u5220\u9664", sum(bool(row.get("mesDispatchProtected")) for row in work_orders.values()), {"MES_DISPATCH_DELETE"}),
|
|
("\u51bb\u7ed3\u5de5\u5355\u4e0d\u5141\u8bb8\u81ea\u52a8\u6539\u671f", sum(bool(row.get("frozenWorkOrderProtected")) for row in operations.values()), {"FROZEN_WORK_ORDER_RESCHEDULE"}),
|
|
("\u5206\u6bb5\u642d\u8f7d\u987a\u5e8f", sum(bool(row.get("erectionPredecessorOperationId")) for row in operations.values()), {"SECTION_ERECTION_SEQUENCE"}),
|
|
("\u603b\u6bb5\u7a7a\u95f4\u548c\u5e72\u6d89\u5173\u7cfb", sum(bool(row.get("spatialInterferenceGroup")) for row in operations.values()), {"GRAND_BLOCK_SPATIAL_INTERFERENCE", "SPATIAL_INTERFERENCE_IDENTITY"}),
|
|
("\u5371\u9669\u4f5c\u4e1a\u4e92\u65a5", sum(bool(row.get("hazardClass")) for row in operations.values()), {"HAZARD_INCOMPATIBILITY"}),
|
|
("\u540c\u533a\u57df\u591a\u4e13\u4e1a\u65bd\u5de5\u5bc6\u5ea6", sum(int(row.get("areaDensityUnits") or 0) > 0 for row in operations.values()), {"AREA_DENSITY"}),
|
|
("\u5173\u952e\u8bbe\u5907\u5230\u8d27\u6761\u4ef6", sum(bool(row.get("criticalEquipmentRequired")) for row in operations.values()), {"CRITICAL_EQUIPMENT_ARRIVAL"}),
|
|
("\u8239\u4e1c\u548c\u8239\u7ea7\u793e\u89c1\u8bc1\u65e5\u5386", sum(bool(row.get("witnessRequired")) for row in operations.values()), {"WITNESS_CALENDAR"}),
|
|
("\u4e0b\u6c34\u548c\u8bd5\u822a\u5929\u6c14\u7a97\u53e3", sum(bool(row.get("launchTrialWeatherRequired")) for row in operations.values()), {"LAUNCH_TRIAL_WEATHER"}),
|
|
)
|
|
business_constraint_coverage = {}
|
|
for business_name, sample_count, violation_codes in business_constraint_specs:
|
|
checks += 1
|
|
violation_count = sum(
|
|
row.get("constraint") in violation_codes for row in violations
|
|
)
|
|
business_constraint_coverage[business_name] = {
|
|
"positiveSampleCount": int(sample_count),
|
|
"violationCount": int(violation_count),
|
|
"violationCodes": sorted(violation_codes),
|
|
}
|
|
if sample_count <= 0:
|
|
_append_violation(
|
|
violations,
|
|
"BUSINESS_CONSTRAINT_COVERAGE",
|
|
business_name,
|
|
"baseline lacks a positive sample for this named business constraint",
|
|
)
|
|
|
|
blocking = [
|
|
f"{row['constraint']}:{row['entityId']}:{row['message']}" for row in violations
|
|
]
|
|
check_summary = {
|
|
"activeOperationCoverage": len(operations),
|
|
"scheduleSlotCount": len(slots),
|
|
"resourceTimelineCount": sum(1 for key in timelines if key[0] == "resource"),
|
|
"teamTimelineCount": sum(1 for key in timelines if key[0] == "team"),
|
|
"precedenceArcCount": sum(
|
|
bool(row.get("predecessorOperationId")) for row in operations.values()
|
|
),
|
|
"precedenceRelationCounts": {
|
|
relation_type: sum(row.get("relationType") == relation_type for row in operations.values())
|
|
for relation_type in ("FS", "SS", "FF", "SF")
|
|
},
|
|
"positiveLagCount": sum(float(row.get("lagHours") or 0.0) > 0 for row in operations.values()),
|
|
"negativeLagCount": sum(float(row.get("lagHours") or 0.0) < 0 for row in operations.values()),
|
|
"qualificationCheckCount": sum(bool(row.get("requiredQualificationCodes")) for row in operations.values()),
|
|
"physicalDimensionCheckCount": sum(bool(row.get("requiredLengthM")) for row in operations.values()),
|
|
"weightCheckCount": sum(bool(row.get("requiredWeightT")) for row in operations.values()),
|
|
"liftRadiusCheckCount": sum(bool(row.get("requiredLiftRadiusM")) for row in operations.values()),
|
|
"transportWindowCheckCount": sum(row.get("transportWindowStatus") == "OPEN" for row in operations.values()),
|
|
"hazardCheckCount": sum(bool(row.get("hazardClass")) for row in operations.values()),
|
|
"areaDensityCheckCount": sum(int(row.get("areaDensityUnits") or 0) > 0 for row in operations.values()),
|
|
"maintenanceBlackoutResourceCount": sum(bool(row.get("maintenanceBlackoutDates")) for row in resources.values()),
|
|
"weatherWindowCheckCount": sum(bool(row.get("weatherSensitive")) for row in operations.values()),
|
|
"outsourceChainCheckCount": len(outsource_suggestions),
|
|
"materialReadyCheckCount": len(parsed_slots),
|
|
"holdPointCheckCount": sum(bool(row.get("holdPoint")) for row in operations.values()),
|
|
"frozenSlotCheckCount": sum(row.get("timeFence") == "FROZEN" for row in slots),
|
|
"expectedResolvedRiskCount": len(conflicts),
|
|
"businessConstraintCoverage": business_constraint_coverage,
|
|
}
|
|
return {
|
|
"valid": not violations,
|
|
"solveStatus": "FEASIBLE" if not violations else "INFEASIBLE",
|
|
"checkCount": checks,
|
|
"hardViolationCount": len(violations),
|
|
"unmarkedHardViolationCount": len(violations),
|
|
"blockingIssues": blocking,
|
|
"violations": violations,
|
|
"checks": check_summary,
|
|
"businessConstraintCoverage": business_constraint_coverage,
|
|
"planningBaseDate": config.planning_base_date.isoformat(),
|
|
"planningHorizonEnd": config.planning_horizon_end.isoformat(),
|
|
}
|