571 lines
23 KiB
Python
571 lines
23 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import Counter, defaultdict
|
|
from copy import deepcopy
|
|
from datetime import datetime, timedelta
|
|
from math import ceil
|
|
from typing import Any
|
|
|
|
from .alternatives import _build_schedule_variant, _slot_deltas
|
|
from .config import GeneratorConfig
|
|
from .constraints import validate_schedule_constraints
|
|
from .models import DatasetBundle, stable_id
|
|
|
|
EVENT_TYPES = (
|
|
"KEY_STEEL_DELAY_14D",
|
|
"MAIN_ENGINE_DELAY_30D",
|
|
"GANTRY_CRANE_FAILURE_7D",
|
|
"PAINT_SHOP_OUTAGE_5D",
|
|
"STRONG_WIND_LIFT_SUSPENSION_3D",
|
|
"OUTSOURCE_CAPACITY_REDUCTION_40PCT",
|
|
"WELDER_TEAM_SHORTAGE_20PCT",
|
|
"CRITICAL_SECTION_REWORK",
|
|
"OWNER_DESIGN_CHANGE",
|
|
"SISTER_SHIP_PRIORITY_INCREASE",
|
|
"DOCK_RELEASE_DELAY_10D",
|
|
"SEA_TRIAL_WINDOW_DELAY",
|
|
"CABLE_SHORTAGE",
|
|
"PIPE_PRESSURE_TEST_FAILURE",
|
|
"URGENT_REPAIR_PROJECT_INSERT",
|
|
)
|
|
|
|
EVENT_NAMES_ZH: dict[str, str] = {
|
|
"KEY_STEEL_DELAY_14D": "关键钢材晚到14天",
|
|
"MAIN_ENGINE_DELAY_30D": "主机晚到30天",
|
|
"GANTRY_CRANE_FAILURE_7D": "大型龙门吊故障7天",
|
|
"PAINT_SHOP_OUTAGE_5D": "涂装房停机5天",
|
|
"STRONG_WIND_LIFT_SUSPENSION_3D": "连续强风导致吊装暂停3天",
|
|
"OUTSOURCE_CAPACITY_REDUCTION_40PCT": "委外供应商产能下降40%",
|
|
"WELDER_TEAM_SHORTAGE_20PCT": "焊工班组缺员20%",
|
|
"CRITICAL_SECTION_REWORK": "关键分段返工",
|
|
"OWNER_DESIGN_CHANGE": "船东临时设计变更",
|
|
"SISTER_SHIP_PRIORITY_INCREASE": "姊妹船优先级提高",
|
|
"DOCK_RELEASE_DELAY_10D": "船坞释放晚10天",
|
|
"SEA_TRIAL_WINDOW_DELAY": "试航窗口推迟",
|
|
"CABLE_SHORTAGE": "电缆到货不齐",
|
|
"PIPE_PRESSURE_TEST_FAILURE": "管系压力试验失败",
|
|
"URGENT_REPAIR_PROJECT_INSERT": "新增紧急修船项目占用部分资源",
|
|
}
|
|
|
|
CANONICAL_FILES = {
|
|
"material-delay": "KEY_STEEL_DELAY_14D",
|
|
"crane-failure": "GANTRY_CRANE_FAILURE_7D",
|
|
"dock-delay": "DOCK_RELEASE_DELAY_10D",
|
|
"design-change": "OWNER_DESIGN_CHANGE",
|
|
}
|
|
|
|
_EVENT_PARAMETERS: dict[str, dict[str, Any]] = {
|
|
"KEY_STEEL_DELAY_14D": {"delayDays": 14, "materialCategory": "KEY_STEEL", "affectedMaterialRatioPercent": 20},
|
|
"MAIN_ENGINE_DELAY_30D": {"delayDays": 30, "equipmentCategory": "MAIN_ENGINE", "affectedEquipmentCount": 1},
|
|
"GANTRY_CRANE_FAILURE_7D": {"outageDays": 7, "resourceCategory": "GANTRY_CRANE", "capacityLossPercent": 100},
|
|
"PAINT_SHOP_OUTAGE_5D": {"outageDays": 5, "resourceCategory": "PAINT_SHOP", "capacityLossPercent": 100},
|
|
"STRONG_WIND_LIFT_SUSPENSION_3D": {"suspensionDays": 3, "weatherType": "STRONG_WIND", "liftCapacityLossPercent": 100},
|
|
"OUTSOURCE_CAPACITY_REDUCTION_40PCT": {"capacityReductionPercent": 40, "affectedSupplyRatioPercent": 20, "alternativeSupplierQualificationDays": 14},
|
|
"WELDER_TEAM_SHORTAGE_20PCT": {"workforceReductionPercent": 20, "teamCategory": "WELDER", "durationDays": 14},
|
|
"CRITICAL_SECTION_REWORK": {"reworkRatioPercent": 20, "entityType": "CRITICAL_SECTION", "reinspectionHours": 8},
|
|
"OWNER_DESIGN_CHANGE": {"engineeringReworkDays": 14, "affectedBomRatioPercent": 20, "approvalLeadTimeDays": 14},
|
|
"SISTER_SHIP_PRIORITY_INCREASE": {"priorityIncrease": 2, "affectedProjectCount": 1, "milestoneProtectionDays": 7},
|
|
"DOCK_RELEASE_DELAY_10D": {"delayDays": 10, "dockCapacityLossPercent": 100, "recoveryWindowDays": 14},
|
|
"SEA_TRIAL_WINDOW_DELAY": {"windowDelayDays": 7, "windowType": "SEA_TRIAL", "rebookingNoticeHours": 48},
|
|
"CABLE_SHORTAGE": {"shortageCategory": "CABLE", "affectedMaterialRatioPercent": 20, "expediteLeadTimeDays": 7},
|
|
"PIPE_PRESSURE_TEST_FAILURE": {"testType": "PIPE_PRESSURE", "reworkDays": 7, "reinspectionHours": 8},
|
|
"URGENT_REPAIR_PROJECT_INSERT": {"projectType": "URGENT_REPAIR", "priority": 10, "requestedStartWithinDays": 7},
|
|
}
|
|
|
|
_EVENT_ACTIONS: dict[str, list[str]] = {
|
|
"KEY_STEEL_DELAY_14D": ["核对关键钢材缺口", "评估替代料和跨项目调拨", "局部修复受影响工序"],
|
|
"MAIN_ENGINE_DELAY_30D": ["重算主机到货齐套日", "保护主机安装前置窗口", "重排安装和联调工序"],
|
|
"GANTRY_CRANE_FAILURE_7D": ["切换兼容起重资源", "复核吊装窗口", "修复相关总组工序"],
|
|
"PAINT_SHOP_OUTAGE_5D": ["锁定涂装房停机窗口", "评估替代涂装资源", "重排涂装及后续工序"],
|
|
"STRONG_WIND_LIFT_SUSPENSION_3D": ["暂停受风力影响吊装", "保护已冻结作业", "天气窗口恢复后局部重排"],
|
|
"OUTSOURCE_CAPACITY_REDUCTION_40PCT": ["重算委外供应商产能", "切换已批准备选供应商", "修复回厂后续工序"],
|
|
"WELDER_TEAM_SHORTAGE_20PCT": ["补充持证焊工", "调配兼容班组", "调整班次和焊接顺序"],
|
|
"CRITICAL_SECTION_REWORK": ["创建关键分段返工", "预留独立返工产能", "复检合格后释放"],
|
|
"OWNER_DESIGN_CHANGE": ["登记船东设计变更", "重算图纸和BOM影响", "评估返工与交期"],
|
|
"SISTER_SHIP_PRIORITY_INCREASE": ["提高姊妹船项目优先级", "评估跨项目资源冲突", "保护关键里程碑"],
|
|
"DOCK_RELEASE_DELAY_10D": ["锁定船坞占用窗口", "评估替代船坞", "调整搭载和下水日期"],
|
|
"SEA_TRIAL_WINDOW_DELAY": ["重订试航窗口", "冻结试航依赖工序", "协调码头和船级社日历"],
|
|
"CABLE_SHORTAGE": ["核对电缆齐套缺口", "执行替代或催料", "修复敷设和接线工序"],
|
|
"PIPE_PRESSURE_TEST_FAILURE": ["登记压力试验失败", "生成管系返工任务", "复检通过后释放后续工序"],
|
|
"URGENT_REPAIR_PROJECT_INSERT": ["评估冻结区影响", "隔离紧急修船资源", "执行跨项目局部重排"],
|
|
}
|
|
|
|
_COMPARE_FIELDS = ("start", "end", "resourceId", "teamId")
|
|
_INFEASIBLE_EVENTS = {"DOCK_RELEASE_DELAY_10D", "OWNER_DESIGN_CHANGE"}
|
|
|
|
|
|
def _as_datetime(value: Any) -> datetime:
|
|
return datetime.fromisoformat(str(value))
|
|
|
|
|
|
def _scenario_slots_from_patch(
|
|
bundle: DatasetBundle,
|
|
patch_slots: list[dict[str, Any]],
|
|
schedule_version_id: str,
|
|
scenario_id: str,
|
|
) -> list[dict[str, Any]]:
|
|
patch_by_operation = {
|
|
str(row["operationId"]): row for row in patch_slots
|
|
}
|
|
slots: list[dict[str, Any]] = []
|
|
for baseline in bundle.rows("schedule-slots"):
|
|
operation_id = str(baseline["operationId"])
|
|
row = deepcopy(baseline)
|
|
patch = patch_by_operation.get(operation_id)
|
|
if patch:
|
|
for field in _COMPARE_FIELDS:
|
|
row[field] = patch.get(field)
|
|
row["timeFence"] = patch.get("timeFence", row.get("timeFence"))
|
|
row["scheduleVersionId"] = schedule_version_id
|
|
row["scenarioId"] = scenario_id
|
|
row["scheduleSlotId"] = stable_id(
|
|
"schedule-slot",
|
|
schedule_version_id,
|
|
operation_id,
|
|
prefix="SLOT",
|
|
)
|
|
row["baselineStart"] = baseline.get("start")
|
|
row["changeAuthorized"] = False
|
|
row["explanation"] = (
|
|
f"Scenario {scenario_id} applies a local repair to the baseline; "
|
|
f"resource {row.get('resourceId')} and team {row.get('teamId')} changes are recorded, "
|
|
"then freeze-zone and hard constraints are revalidated."
|
|
)
|
|
row["evidenceRefs"] = [
|
|
f"operation:{operation_id}",
|
|
f"baseline-slot:{baseline.get('scheduleSlotId')}",
|
|
f"scenario:{scenario_id}",
|
|
]
|
|
slots.append(row)
|
|
return slots
|
|
|
|
|
|
def _build_infeasible_attempt(
|
|
bundle: DatasetBundle,
|
|
config: GeneratorConfig,
|
|
event_type: str,
|
|
scenario_id: str,
|
|
schedule_version_id: str,
|
|
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
baseline_slots = bundle.rows("schedule-slots")
|
|
operations = {
|
|
str(row["operationId"]): row for row in bundle.rows("operations")
|
|
}
|
|
resources = {
|
|
str(row["resourceId"]): row for row in bundle.rows("resources")
|
|
}
|
|
candidate_slots = _scenario_slots_from_patch(
|
|
bundle,
|
|
[],
|
|
schedule_version_id,
|
|
scenario_id,
|
|
)
|
|
candidate_by_operation = {
|
|
str(row["operationId"]): row for row in candidate_slots
|
|
}
|
|
|
|
target: dict[str, Any] | None = None
|
|
replacement_start: datetime | None = None
|
|
if event_type == "DOCK_RELEASE_DELAY_10D":
|
|
by_resource: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
for row in baseline_slots:
|
|
if row.get("timeFence") == "FROZEN":
|
|
continue
|
|
resource = resources.get(str(row.get("resourceId")), {})
|
|
if resource.get("resourceType") == "DOCK":
|
|
by_resource[str(row.get("resourceId"))].append(row)
|
|
timelines = [
|
|
sorted(rows, key=lambda row: str(row["start"]))
|
|
for rows in by_resource.values()
|
|
if len(rows) > 1
|
|
]
|
|
if timelines:
|
|
timeline = max(timelines, key=len)
|
|
target = timeline[1]
|
|
replacement_start = _as_datetime(timeline[0]["start"])
|
|
|
|
if target is None:
|
|
for row in baseline_slots:
|
|
if row.get("timeFence") == "FROZEN":
|
|
continue
|
|
operation = operations[str(row["operationId"])]
|
|
predecessor_id = operation.get("predecessorOperationId")
|
|
if predecessor_id and str(predecessor_id) in candidate_by_operation:
|
|
target = row
|
|
replacement_start = _as_datetime(
|
|
candidate_by_operation[str(predecessor_id)]["start"]
|
|
)
|
|
break
|
|
|
|
if target is None or replacement_start is None:
|
|
target = next(
|
|
row
|
|
for row in reversed(baseline_slots)
|
|
if row.get("timeFence") != "FROZEN"
|
|
)
|
|
replacement_start = datetime.combine(
|
|
config.planning_horizon_end,
|
|
datetime.min.time(),
|
|
tzinfo=_as_datetime(target["start"]).tzinfo,
|
|
) + timedelta(hours=23)
|
|
|
|
attempted = candidate_by_operation[str(target["operationId"])]
|
|
duration = timedelta(hours=float(attempted.get("durationHours") or 0.0))
|
|
attempted["start"] = replacement_start.isoformat()
|
|
attempted["end"] = (replacement_start + duration).isoformat()
|
|
attempted["explanation"] = (
|
|
f"Event {event_type} attempts a move outside the hard-constraint boundary; "
|
|
"this candidate exists only as an infeasibility proof and must be rejected with a conflict core."
|
|
)
|
|
check_tables = dict(bundle.tables)
|
|
check_tables["schedule-slots"] = candidate_slots
|
|
check_bundle = DatasetBundle(
|
|
metadata=bundle.metadata,
|
|
tables=check_tables,
|
|
artifacts=bundle.artifacts,
|
|
diagnostics=bundle.diagnostics,
|
|
)
|
|
validation = validate_schedule_constraints(check_bundle, config)
|
|
if validation["valid"]:
|
|
replacement_start = datetime.combine(
|
|
config.planning_horizon_end,
|
|
datetime.min.time(),
|
|
tzinfo=_as_datetime(target["start"]).tzinfo,
|
|
) + timedelta(hours=23)
|
|
attempted["start"] = replacement_start.isoformat()
|
|
attempted["end"] = (replacement_start + duration).isoformat()
|
|
check_tables["schedule-slots"] = candidate_slots
|
|
validation = validate_schedule_constraints(check_bundle, config)
|
|
if validation["valid"]:
|
|
raise RuntimeError(f"{event_type} infeasible fixture was not rejected")
|
|
return candidate_slots, validation
|
|
|
|
|
|
def _actual_diff(
|
|
deltas: list[dict[str, Any]],
|
|
validation: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
field_counts = Counter(
|
|
field for delta in deltas for field in delta["changedFields"]
|
|
)
|
|
frozen_changes = [
|
|
delta
|
|
for delta in deltas
|
|
if delta["before"].get("timeFence") == "FROZEN"
|
|
]
|
|
illegal_frozen = sum(
|
|
row.get("constraint") == "FROZEN_ZONE"
|
|
for row in validation.get("violations", [])
|
|
)
|
|
delay_hours = [
|
|
(
|
|
_as_datetime(delta["after"]["end"])
|
|
- _as_datetime(delta["before"]["end"])
|
|
).total_seconds()
|
|
/ 3600
|
|
for delta in deltas
|
|
]
|
|
changed_hours = sum(
|
|
float(delta["after"].get("durationHours") or 0.0) for delta in deltas
|
|
)
|
|
return {
|
|
"changedSlotCount": len(deltas),
|
|
"beforeRecordCount": len(deltas),
|
|
"afterRecordCount": len(deltas),
|
|
"changedFields": sorted(field_counts),
|
|
"changedFieldCounts": dict(sorted(field_counts.items())),
|
|
"frozenZoneTouched": bool(frozen_changes),
|
|
"frozenChangedSlotIds": [
|
|
delta["before"].get("scheduleSlotId") for delta in frozen_changes
|
|
],
|
|
"unauthorizedFrozenChangeCount": illegal_frozen,
|
|
"milestoneImpactDays": max(
|
|
0,
|
|
ceil(max(delay_hours, default=0.0) / 24),
|
|
),
|
|
"costImpactCny": round(changed_hours * 1850.0, 2),
|
|
"hardConstraintViolationCount": validation["hardViolationCount"],
|
|
"constraintCheckCount": validation["checkCount"],
|
|
}
|
|
|
|
|
|
def _event_input(
|
|
event_type: str,
|
|
index: int,
|
|
base_schedule_version_id: str | None,
|
|
deltas: list[dict[str, Any]],
|
|
diff: dict[str, Any],
|
|
baseline_frozen_count: int,
|
|
) -> dict[str, Any]:
|
|
before = [delta["before"] for delta in deltas]
|
|
affected_projects = sorted(
|
|
{str(row.get("projectId")) for row in before if row.get("projectId")}
|
|
)
|
|
affected_wbs = sorted(
|
|
{str(row.get("wbsId")) for row in before if row.get("wbsId")}
|
|
)
|
|
affected_orders = sorted(
|
|
{
|
|
str(row.get("productionOrderId"))
|
|
for row in before
|
|
if row.get("productionOrderId")
|
|
}
|
|
)
|
|
affected_resources = sorted(
|
|
{str(row.get("resourceId")) for row in before if row.get("resourceId")}
|
|
)
|
|
affected_teams = sorted(
|
|
{str(row.get("teamId")) for row in before if row.get("teamId")}
|
|
)
|
|
earliest = min(_as_datetime(row["start"]) for row in before)
|
|
latest = max(_as_datetime(row["end"]) for row in before)
|
|
occurred_at = earliest - timedelta(hours=6 + index)
|
|
return {
|
|
"synthetic": True,
|
|
"datasetType": "SYNTHETIC",
|
|
"occurredAt": occurred_at.isoformat(),
|
|
"severity": "HIGH" if index % 4 == 0 else "MEDIUM",
|
|
"affectedProjectIds": affected_projects,
|
|
"affectedWbsIds": affected_wbs,
|
|
"affectedProductionOrderIds": affected_orders,
|
|
"affectedResourceIds": affected_resources,
|
|
"affectedTeamIds": affected_teams,
|
|
"parameters": deepcopy(_EVENT_PARAMETERS[event_type]),
|
|
"originalImpact": {
|
|
"baseScheduleVersionId": base_schedule_version_id,
|
|
"affectedWindowStart": earliest.isoformat(),
|
|
"affectedWindowEnd": latest.isoformat(),
|
|
"affectedSlotCount": len(deltas),
|
|
"estimatedCapacityLossHours": round(
|
|
sum(
|
|
float(row.get("durationHours") or 0.0)
|
|
for row in before
|
|
),
|
|
2,
|
|
),
|
|
"estimatedMilestoneDelayDays": diff["milestoneImpactDays"],
|
|
},
|
|
"recommendedActions": deepcopy(_EVENT_ACTIONS[event_type]),
|
|
"requiresReschedule": True,
|
|
"frozenImpact": {
|
|
"timeFenceField": "timeFence",
|
|
"protectedFrozenSlotCount": baseline_frozen_count,
|
|
"touchesFrozenZone": diff["frozenZoneTouched"],
|
|
"affectedFrozenSlotIds": diff["frozenChangedSlotIds"],
|
|
"changeAuthorized": False,
|
|
"unauthorizedFrozenChangeCount": diff[
|
|
"unauthorizedFrozenChangeCount"
|
|
],
|
|
"policy": "NO_UNAUTHORIZED_FROZEN_CHANGE",
|
|
},
|
|
}
|
|
|
|
|
|
def generate_scenarios(
|
|
bundle: DatasetBundle,
|
|
config: GeneratorConfig,
|
|
) -> DatasetBundle:
|
|
baseline_slots = bundle.rows("schedule-slots")
|
|
version = (bundle.rows("schedule-versions") or [{}])[0]
|
|
alternatives = bundle.artifacts.get("schedule-alternatives", {}).get(
|
|
"alternatives", []
|
|
)
|
|
feasible_variant_cursor = 0
|
|
baseline_frozen_count = sum(
|
|
row.get("timeFence") == "FROZEN" for row in baseline_slots
|
|
)
|
|
events: list[dict[str, Any]] = []
|
|
|
|
for index, event_type in enumerate(EVENT_TYPES, 1):
|
|
scenario_id = stable_id(
|
|
"scenario",
|
|
event_type,
|
|
config.seed,
|
|
prefix="SCN",
|
|
)
|
|
schedule_version_id = stable_id(
|
|
"schedule-version",
|
|
"scenario",
|
|
event_type,
|
|
config.seed,
|
|
config.scale,
|
|
prefix="SCHV",
|
|
)
|
|
if event_type in _INFEASIBLE_EVENTS:
|
|
candidate_slots, validation = _build_infeasible_attempt(
|
|
bundle,
|
|
config,
|
|
event_type,
|
|
scenario_id,
|
|
schedule_version_id,
|
|
)
|
|
accepted = False
|
|
repair_algorithm = "FAIL_CLOSED_CONFLICT_CORE"
|
|
else:
|
|
if feasible_variant_cursor < len(alternatives):
|
|
source = alternatives[feasible_variant_cursor]
|
|
candidate_slots = _scenario_slots_from_patch(
|
|
bundle,
|
|
source["alternativeSlots"],
|
|
schedule_version_id,
|
|
scenario_id,
|
|
)
|
|
else:
|
|
source = _build_schedule_variant(
|
|
bundle,
|
|
config,
|
|
f"{event_type}-REPAIR",
|
|
100 + index,
|
|
)
|
|
candidate_slots = _scenario_slots_from_patch(
|
|
bundle,
|
|
[delta["after"] for delta in source["slotDeltas"]],
|
|
schedule_version_id,
|
|
scenario_id,
|
|
)
|
|
feasible_variant_cursor += 1
|
|
validation = deepcopy(source["constraintValidation"])
|
|
if not validation["valid"]:
|
|
raise RuntimeError(
|
|
f"scenario {event_type} repair is not feasible: "
|
|
+ "; ".join(validation["blockingIssues"][:3])
|
|
)
|
|
accepted = True
|
|
repair_algorithm = "FINITE_CAPACITY_LOCAL_REPAIR"
|
|
|
|
deltas = _slot_deltas(baseline_slots, candidate_slots)
|
|
if not deltas:
|
|
raise RuntimeError(f"scenario {event_type} produced no actual slot change")
|
|
diff = _actual_diff(deltas, validation)
|
|
if not (
|
|
diff["changedSlotCount"]
|
|
== diff["beforeRecordCount"]
|
|
== diff["afterRecordCount"]
|
|
== len(deltas)
|
|
):
|
|
raise AssertionError(
|
|
f"scenario {event_type} changed slot count is inconsistent"
|
|
)
|
|
if accepted and diff["unauthorizedFrozenChangeCount"]:
|
|
raise AssertionError(
|
|
f"scenario {event_type} changed the frozen zone without authorization"
|
|
)
|
|
|
|
scenario_input = _event_input(
|
|
event_type,
|
|
index,
|
|
version.get("scheduleVersionId"),
|
|
deltas,
|
|
diff,
|
|
baseline_frozen_count,
|
|
)
|
|
conflict_core = [
|
|
{
|
|
"constraint": row.get("constraint"),
|
|
"entityId": row.get("entityId"),
|
|
"message": row.get("message"),
|
|
}
|
|
for row in validation.get("violations", [])
|
|
]
|
|
events.append(
|
|
{
|
|
"scenarioId": scenario_id,
|
|
"eventType": event_type,
|
|
"eventName": EVENT_NAMES_ZH[event_type],
|
|
"eventSequence": index,
|
|
"baseScheduleVersionId": version.get("scheduleVersionId"),
|
|
"afterScheduleVersionId": schedule_version_id,
|
|
"scheduleVersion": {
|
|
"scheduleVersionId": schedule_version_id,
|
|
"baseScheduleVersionId": version.get("scheduleVersionId"),
|
|
"versionNo": f"SCENARIO-{index:03d}",
|
|
"scenarioId": scenario_id,
|
|
"accepted": accepted,
|
|
"slotInheritance": "PATCH_OVER_BASELINE",
|
|
},
|
|
"input": scenario_input,
|
|
"before": [delta["before"] for delta in deltas],
|
|
"after": [delta["after"] for delta in deltas],
|
|
"diff": diff,
|
|
"repairAlgorithm": repair_algorithm,
|
|
"repairAlgorithmVersion": "2.0.0",
|
|
"solveStatus": validation["solveStatus"],
|
|
"accepted": accepted,
|
|
"conflictCore": conflict_core,
|
|
"reliefSuggestions": (
|
|
deepcopy(_EVENT_ACTIONS[event_type])
|
|
if not accepted
|
|
else []
|
|
),
|
|
"expectedConflictCount": len(conflict_core),
|
|
"hardConstraintViolationCount": validation[
|
|
"hardViolationCount"
|
|
],
|
|
"unmarkedHardViolationCount": 0,
|
|
"constraintValidation": {
|
|
"valid": validation["valid"],
|
|
"solveStatus": validation["solveStatus"],
|
|
"checkCount": validation["checkCount"],
|
|
"hardViolationCount": validation["hardViolationCount"],
|
|
"blockingIssues": validation["blockingIssues"],
|
|
},
|
|
"evidenceRefs": [
|
|
"skill:ship-schedule-repair",
|
|
"KNO-SYN-20-001"
|
|
],
|
|
}
|
|
)
|
|
|
|
baseline = {
|
|
"scenarioId": "BASELINE",
|
|
"scheduleVersionId": version.get("scheduleVersionId"),
|
|
"solveStatus": version.get("solveStatus", "FEASIBLE"),
|
|
"hardConstraintViolations": 0,
|
|
"unmarkedConflictCount": 0,
|
|
}
|
|
bundle.artifacts["scenario-events"] = events
|
|
bundle.artifacts["baseline"] = baseline
|
|
for file_name, event_type in CANONICAL_FILES.items():
|
|
bundle.artifacts[file_name] = next(
|
|
row for row in events if row["eventType"] == event_type
|
|
)
|
|
bundle.artifacts["scenario-comparison"] = {
|
|
"baseline": baseline,
|
|
"scenarioCount": len(events),
|
|
"independentScheduleVersionCount": len(
|
|
{row["afterScheduleVersionId"] for row in events}
|
|
),
|
|
"feasibleCount": sum(
|
|
row["solveStatus"] == "FEASIBLE" for row in events
|
|
),
|
|
"infeasibleCount": sum(
|
|
row["solveStatus"] == "INFEASIBLE" for row in events
|
|
),
|
|
"allChangedSlotCountsConsistent": all(
|
|
row["diff"]["changedSlotCount"]
|
|
== len(row["before"])
|
|
== len(row["after"])
|
|
for row in events
|
|
),
|
|
"unauthorizedFrozenChangeCount": sum(
|
|
row["diff"]["unauthorizedFrozenChangeCount"] for row in events
|
|
),
|
|
"scenarios": [
|
|
{
|
|
"scenarioId": row["scenarioId"],
|
|
"eventType": row["eventType"],
|
|
"afterScheduleVersionId": row["afterScheduleVersionId"],
|
|
"solveStatus": row["solveStatus"],
|
|
"accepted": row["accepted"],
|
|
"changedSlotCount": row["diff"]["changedSlotCount"],
|
|
"changedFields": row["diff"]["changedFields"],
|
|
"milestoneImpactDays": row["diff"][
|
|
"milestoneImpactDays"
|
|
],
|
|
"costImpactCny": row["diff"]["costImpactCny"],
|
|
}
|
|
for row in events
|
|
],
|
|
}
|
|
return bundle
|