421 lines
22 KiB
Python
421 lines
22 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
|
|
from .config import GeneratorConfig
|
|
from .models import DatasetBundle, stable_id
|
|
|
|
|
|
def generate_execution_quality(bundle: DatasetBundle, config: GeneratorConfig) -> DatasetBundle:
|
|
"""Populate execution and quality records, including finite-capacity rework ledgers."""
|
|
work_orders = sorted(bundle.rows("work-orders"), key=lambda row: str(row.get("workOrderCode") or row["workOrderId"]))
|
|
operations = sorted(
|
|
bundle.rows("operations"),
|
|
key=lambda row: (str(row.get("workOrderId") or ""), int(row.get("sequence") or 0), str(row["operationId"])),
|
|
)
|
|
slots = sorted(bundle.rows("schedule-slots"), key=lambda row: (str(row.get("start") or ""), str(row["scheduleSlotId"])))
|
|
materials = sorted(bundle.rows("materials"), key=lambda row: str(row.get("materialCode") or row["materialId"]))
|
|
if not work_orders or not operations or not slots or not materials:
|
|
raise ValueError("execution and quality generation requires orders, scheduled operations and materials")
|
|
|
|
baseline_operation_count = len(operations)
|
|
baseline_schedule_slot_count = len(slots)
|
|
work_order_by_id = {str(row["workOrderId"]): row for row in work_orders}
|
|
material_by_id = {str(row["materialId"]): row for row in materials}
|
|
slot_by_operation = {str(row["operationId"]): row for row in slots}
|
|
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)
|
|
requirements_by_package: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
for requirement in bundle.rows("material-requirements"):
|
|
requirements_by_package[str(requirement.get("workPackageId") or "")].append(requirement)
|
|
|
|
occupied_by_resource: dict[str, list[tuple[datetime, datetime]]] = defaultdict(list)
|
|
occupied_by_team: dict[str, list[tuple[datetime, datetime]]] = defaultdict(list)
|
|
for slot in slots:
|
|
start = datetime.fromisoformat(str(slot["start"]))
|
|
end = datetime.fromisoformat(str(slot["end"]))
|
|
if end <= start:
|
|
raise AssertionError(f"baseline schedule slot must have positive duration: {slot['scheduleSlotId']}")
|
|
occupied_by_resource[str(slot["resourceId"])].append((start, end))
|
|
occupied_by_team[str(slot["teamId"])].append((start, end))
|
|
for intervals in (*occupied_by_resource.values(), *occupied_by_team.values()):
|
|
intervals.sort()
|
|
|
|
mes_orders: list[dict[str, Any]] = []
|
|
for work_order in work_orders:
|
|
work_order_id = str(work_order["workOrderId"])
|
|
assigned_slots = [slot_by_operation[str(op["operationId"])] for op in operations_by_work_order[work_order_id]]
|
|
first_slot = min(assigned_slots, key=lambda row: str(row["start"]))
|
|
dispatch_at = datetime.fromisoformat(str(first_slot["start"])) - timedelta(days=1)
|
|
frozen = any(bool(row.get("frozen")) for row in assigned_slots)
|
|
mes_orders.append(
|
|
{
|
|
"mesOrderId": stable_id("mes-order", work_order_id, prefix="MES"),
|
|
"workOrderId": work_order_id,
|
|
"productionOrderId": work_order["productionOrderId"],
|
|
"projectId": work_order["projectId"],
|
|
"dispatchStatus": "DISPATCHED",
|
|
"dispatchAt": dispatch_at.isoformat(),
|
|
"frozen": frozen,
|
|
"autoDeleteAllowed": False,
|
|
"autoRescheduleAllowed": not frozen,
|
|
"protectedReason": "MES_DISPATCHED_AND_FROZEN" if frozen else "MES_DISPATCHED",
|
|
"evidenceRefs": [f"work-order:{work_order_id}", f"schedule-slot:{first_slot['scheduleSlotId']}"],
|
|
}
|
|
)
|
|
|
|
report_target = max(1, min(len(operations), config.profile.operation_count // 6))
|
|
reported_operations = operations[:report_target]
|
|
operation_reports: list[dict[str, Any]] = []
|
|
for index, operation in enumerate(reported_operations, 1):
|
|
slot = slot_by_operation[str(operation["operationId"])]
|
|
operation_reports.append(
|
|
{
|
|
"operationReportId": stable_id("operation-report", operation["operationId"], index, prefix="RPT"),
|
|
"operationId": operation["operationId"],
|
|
"workOrderId": operation["workOrderId"],
|
|
"reportStatus": "COMPLETED",
|
|
"reportedQuantity": 1,
|
|
"reportedHours": operation["durationHours"],
|
|
"reportedAt": slot["end"],
|
|
"resourceId": slot["resourceId"],
|
|
"teamId": slot["teamId"],
|
|
"evidenceRefs": [f"schedule-slot:{slot['scheduleSlotId']}", f"operation:{operation['operationId']}"],
|
|
}
|
|
)
|
|
|
|
issue_target = max(1, min(len(work_orders), config.profile.production_order_count // 5))
|
|
material_issues: list[dict[str, Any]] = []
|
|
for index, work_order in enumerate(work_orders[:issue_target], 1):
|
|
package_requirements = requirements_by_package.get(str(work_order.get("workPackageId") or "")) or []
|
|
if package_requirements:
|
|
requirement = package_requirements[(index - 1) % len(package_requirements)]
|
|
material_id = requirement["materialId"]
|
|
quantity = max(0.001, float(requirement.get("grossRequirement") or 1.0))
|
|
evidence = f"material-requirement:{requirement['requirementId']}"
|
|
else:
|
|
material = materials[(index - 1) % len(materials)]
|
|
material_id = material["materialId"]
|
|
quantity = 1.0 + index % 5
|
|
evidence = f"material:{material_id}"
|
|
first_operation = operations_by_work_order[str(work_order["workOrderId"])][0]
|
|
slot = slot_by_operation[str(first_operation["operationId"])]
|
|
material_issues.append(
|
|
{
|
|
"materialIssueId": stable_id("material-issue", work_order["workOrderId"], material_id, prefix="ISS"),
|
|
"workOrderId": work_order["workOrderId"],
|
|
"materialId": material_id,
|
|
"quantity": round(quantity, 3),
|
|
"unit": material_by_id.get(str(material_id), {}).get("unit", "PCS"),
|
|
"issueStatus": "ISSUED",
|
|
"issuedAt": slot["start"],
|
|
"negativeInventoryAllowed": False,
|
|
"evidenceRefs": [evidence, f"operation:{first_operation['operationId']}"],
|
|
}
|
|
)
|
|
|
|
last_operation_ids = {
|
|
str(work_order_operations[-1]["operationId"])
|
|
for work_order_operations in operations_by_work_order.values()
|
|
if work_order_operations
|
|
}
|
|
hold_candidates = [row for row in operations if row.get("holdPoint") and row["operationId"] in slot_by_operation]
|
|
if not hold_candidates:
|
|
hold_candidates = [row for row in operations if row["operationId"] in slot_by_operation][:1]
|
|
hold_candidates.sort(
|
|
key=lambda row: (
|
|
0 if str(row["operationId"]) in last_operation_ids else 1,
|
|
str(row.get("workOrderId") or ""),
|
|
int(row.get("sequence") or 0),
|
|
str(row["operationId"]),
|
|
)
|
|
)
|
|
inspection_target = max(2, min(len(hold_candidates), max(2, config.profile.production_order_count // 12)))
|
|
quality_inspections: list[dict[str, Any]] = []
|
|
nonconformities: list[dict[str, Any]] = []
|
|
rework_orders: list[dict[str, Any]] = []
|
|
rework_operations: list[dict[str, Any]] = []
|
|
rework_schedule_slots: list[dict[str, Any]] = []
|
|
ncr_target = max(1, inspection_target // 4)
|
|
|
|
for index, operation in enumerate(hold_candidates[:inspection_target], 1):
|
|
slot = slot_by_operation[str(operation["operationId"])]
|
|
inspection_id = stable_id("inspection", operation["operationId"], "initial", prefix="INS")
|
|
inspection_held_at = datetime.fromisoformat(str(slot["start"]))
|
|
inspection_inspected_at = datetime.fromisoformat(str(slot["end"]))
|
|
has_ncr = index <= ncr_target
|
|
if not has_ncr:
|
|
inspection_released_at = inspection_inspected_at + timedelta(minutes=5)
|
|
quality_inspections.append(
|
|
{
|
|
"inspectionId": inspection_id,
|
|
"operationId": operation["operationId"],
|
|
"inspectionType": "HOLD_POINT" if operation.get("holdPoint") else "PROCESS_INSPECTION",
|
|
"holdPoint": True,
|
|
"status": "RELEASED",
|
|
"resultStatus": "PASSED",
|
|
"stateHistory": ["HOLD", "INSPECTED", "PASSED", "RELEASED"],
|
|
"heldAt": inspection_held_at.isoformat(),
|
|
"inspectedAt": inspection_inspected_at.isoformat(),
|
|
"releasedAt": inspection_released_at.isoformat(),
|
|
"witnessType": "CLASS_AND_OWNER" if index % 3 == 0 else "INTERNAL_QA",
|
|
"evidenceRefs": [f"operation:{operation['operationId']}", f"schedule-slot:{slot['scheduleSlotId']}"],
|
|
}
|
|
)
|
|
continue
|
|
|
|
nonconformity_id = stable_id("nonconformity", inspection_id, prefix="NCR")
|
|
rework_order_id = stable_id("rework-order", nonconformity_id, prefix="RWK")
|
|
rework_operation_id = stable_id("rework-operation", rework_order_id, prefix="RWOP")
|
|
rework_schedule_slot_id = stable_id("rework-schedule-slot", rework_operation_id, prefix="RWS")
|
|
reinspection_id = stable_id("inspection", rework_operation_id, "reinspection", prefix="INS")
|
|
planned_hours = round(max(1.0, float(operation["durationHours"]) * 0.35), 2)
|
|
ncr_opened_at = inspection_inspected_at + timedelta(minutes=1)
|
|
rework_start = ncr_opened_at + timedelta(minutes=5)
|
|
rework_duration = timedelta(hours=planned_hours)
|
|
resource_id = str(slot["resourceId"])
|
|
team_id = str(slot["teamId"])
|
|
|
|
while True:
|
|
rework_end = rework_start + rework_duration
|
|
conflict_ends = [
|
|
end
|
|
for start, end in (*occupied_by_resource[resource_id], *occupied_by_team[team_id])
|
|
if start < rework_end and rework_start < end
|
|
]
|
|
if not conflict_ends:
|
|
break
|
|
rework_start = max(conflict_ends) + timedelta(minutes=5)
|
|
|
|
occupied_by_resource[resource_id].append((rework_start, rework_end))
|
|
occupied_by_team[team_id].append((rework_start, rework_end))
|
|
occupied_by_resource[resource_id].sort()
|
|
occupied_by_team[team_id].sort()
|
|
|
|
reinspection_held_at = rework_end
|
|
reinspection_inspected_at = reinspection_held_at + timedelta(minutes=30)
|
|
quality_released_at = reinspection_inspected_at + timedelta(minutes=5)
|
|
ncr_closed_at = quality_released_at + timedelta(minutes=5)
|
|
if not (
|
|
inspection_held_at
|
|
<= inspection_inspected_at
|
|
<= ncr_opened_at
|
|
< rework_start
|
|
< rework_end
|
|
<= reinspection_inspected_at
|
|
<= quality_released_at
|
|
<= ncr_closed_at
|
|
):
|
|
raise AssertionError(f"invalid NCR/rework timeline for {nonconformity_id}")
|
|
|
|
source_work_order = work_order_by_id[str(operation["workOrderId"])]
|
|
quality_inspections.append(
|
|
{
|
|
"inspectionId": inspection_id,
|
|
"operationId": operation["operationId"],
|
|
"inspectionType": "HOLD_POINT" if operation.get("holdPoint") else "PROCESS_INSPECTION",
|
|
"holdPoint": True,
|
|
"status": "RELEASED_AFTER_REWORK",
|
|
"resultStatus": "FAILED",
|
|
"stateHistory": [
|
|
"HOLD", "INSPECTED", "FAILED", "NCR_RAISED", "REWORK_COMPLETED", "REINSPECTION_PASSED", "RELEASED",
|
|
],
|
|
"heldAt": inspection_held_at.isoformat(),
|
|
"failedAt": inspection_inspected_at.isoformat(),
|
|
"inspectedAt": inspection_inspected_at.isoformat(),
|
|
"releasedAt": quality_released_at.isoformat(),
|
|
"nonconformityId": nonconformity_id,
|
|
"reworkOrderId": rework_order_id,
|
|
"reinspectionId": reinspection_id,
|
|
"witnessType": "CLASS_AND_OWNER" if index % 3 == 0 else "INTERNAL_QA",
|
|
"evidenceRefs": [f"operation:{operation['operationId']}", f"schedule-slot:{slot['scheduleSlotId']}"],
|
|
}
|
|
)
|
|
nonconformities.append(
|
|
{
|
|
"nonconformityId": nonconformity_id,
|
|
"inspectionId": inspection_id,
|
|
"operationId": operation["operationId"],
|
|
"status": "CLOSED",
|
|
"severity": ("MAJOR", "MINOR")[index % 2],
|
|
"disposition": "REWORK",
|
|
"stateHistory": ["OPEN", "REWORK_REQUIRED", "REWORK_IN_PROGRESS", "REINSPECTION", "RELEASED", "CLOSED"],
|
|
"stateTransitions": [
|
|
{"state": "OPEN", "at": ncr_opened_at.isoformat()},
|
|
{"state": "REWORK_IN_PROGRESS", "at": rework_start.isoformat()},
|
|
{"state": "REINSPECTION", "at": rework_end.isoformat()},
|
|
{"state": "RELEASED", "at": quality_released_at.isoformat()},
|
|
{"state": "CLOSED", "at": ncr_closed_at.isoformat()},
|
|
],
|
|
"reworkOrderId": rework_order_id,
|
|
"reworkOperationId": rework_operation_id,
|
|
"reworkScheduleSlotId": rework_schedule_slot_id,
|
|
"reinspectionId": reinspection_id,
|
|
"rootCause": "SYNTHETIC_PROCESS_VARIATION",
|
|
"openedAt": ncr_opened_at.isoformat(),
|
|
"reworkStartedAt": rework_start.isoformat(),
|
|
"reworkCompletedAt": rework_end.isoformat(),
|
|
"reinspectionInspectedAt": reinspection_inspected_at.isoformat(),
|
|
"releasedAt": quality_released_at.isoformat(),
|
|
"closedAt": ncr_closed_at.isoformat(),
|
|
"evidenceRefs": [f"inspection:{inspection_id}", f"operation:{operation['operationId']}"],
|
|
}
|
|
)
|
|
rework_orders.append(
|
|
{
|
|
"reworkOrderId": rework_order_id,
|
|
"orderType": "REWORK",
|
|
"nonconformityId": nonconformity_id,
|
|
"operationId": operation["operationId"],
|
|
"sourceOperationId": operation["operationId"],
|
|
"reworkOperationId": rework_operation_id,
|
|
"workOrderId": operation["workOrderId"],
|
|
"parentWorkOrderId": operation["workOrderId"],
|
|
"productionOrderId": operation.get("productionOrderId") or source_work_order.get("productionOrderId"),
|
|
"projectId": source_work_order.get("projectId"),
|
|
"status": "CLOSED",
|
|
"reinspectionRequired": True,
|
|
"resourceId": resource_id,
|
|
"teamId": team_id,
|
|
"plannedHours": planned_hours,
|
|
"scheduledHours": round((rework_end - rework_start).total_seconds() / 3600.0, 2),
|
|
"capacityReservationRequired": True,
|
|
"capacityReservationStatus": "RESERVED_AND_EXECUTED",
|
|
"stateHistory": ["CREATED", "IN_PROGRESS", "COMPLETED", "REINSPECTION_PASSED", "CLOSED"],
|
|
"stateTransitions": [
|
|
{"state": "CREATED", "at": ncr_opened_at.isoformat()},
|
|
{"state": "IN_PROGRESS", "at": rework_start.isoformat()},
|
|
{"state": "COMPLETED", "at": rework_end.isoformat()},
|
|
{"state": "REINSPECTION_PASSED", "at": reinspection_inspected_at.isoformat()},
|
|
{"state": "CLOSED", "at": ncr_closed_at.isoformat()},
|
|
],
|
|
"createdAt": ncr_opened_at.isoformat(),
|
|
"startedAt": rework_start.isoformat(),
|
|
"completedAt": rework_end.isoformat(),
|
|
"reinspectionPassedAt": reinspection_inspected_at.isoformat(),
|
|
"closedAt": ncr_closed_at.isoformat(),
|
|
"sourceScheduleSlotId": slot["scheduleSlotId"],
|
|
"sourceBaselineScheduleSlotId": slot["scheduleSlotId"],
|
|
"scheduleSlotId": rework_schedule_slot_id,
|
|
"reworkScheduleSlotId": rework_schedule_slot_id,
|
|
"evidenceRefs": [
|
|
f"nonconformity:{nonconformity_id}", f"operation:{operation['operationId']}",
|
|
f"resource:{resource_id}", f"team:{team_id}",
|
|
],
|
|
}
|
|
)
|
|
rework_operations.append(
|
|
{
|
|
"reworkOperationId": rework_operation_id,
|
|
"reworkOrderId": rework_order_id,
|
|
"sourceOperationId": operation["operationId"],
|
|
"parentWorkOrderId": operation["workOrderId"],
|
|
"operationCode": f"RW-{operation.get('operationCode') or operation['operationId']}",
|
|
"operationName": f"返工-{operation.get('operationName') or operation.get('operationCode') or operation['operationId']}",
|
|
"operationType": "REWORK",
|
|
"sequence": 1,
|
|
"durationHours": planned_hours,
|
|
"resourceId": resource_id,
|
|
"teamId": team_id,
|
|
"scheduleSlotId": rework_schedule_slot_id,
|
|
"status": "COMPLETED",
|
|
"start": rework_start.isoformat(),
|
|
"end": rework_end.isoformat(),
|
|
}
|
|
)
|
|
rework_schedule_slots.append(
|
|
{
|
|
"reworkScheduleSlotId": rework_schedule_slot_id,
|
|
"scheduleSlotId": rework_schedule_slot_id,
|
|
"slotType": "REWORK",
|
|
"reworkOperationId": rework_operation_id,
|
|
"reworkOrderId": rework_order_id,
|
|
"sourceOperationId": operation["operationId"],
|
|
"sourceBaselineScheduleSlotId": slot["scheduleSlotId"],
|
|
"resourceId": resource_id,
|
|
"teamId": team_id,
|
|
"start": rework_start.isoformat(),
|
|
"end": rework_end.isoformat(),
|
|
"durationHours": planned_hours,
|
|
"finiteCapacityReserved": True,
|
|
"capacityReservationStatus": "RESERVED_AND_EXECUTED",
|
|
"explanation": "NCR 返工独立占用原工序匹配资源与班组,并避让基准排产和其他返工槽位。",
|
|
"evidenceRefs": [
|
|
f"nonconformity:{nonconformity_id}", f"operation:{operation['operationId']}",
|
|
f"resource:{resource_id}", f"team:{team_id}",
|
|
],
|
|
}
|
|
)
|
|
quality_inspections.append(
|
|
{
|
|
"inspectionId": reinspection_id,
|
|
"operationId": operation["operationId"],
|
|
"reworkOperationId": rework_operation_id,
|
|
"inspectionType": "REINSPECTION",
|
|
"holdPoint": True,
|
|
"status": "CLOSED",
|
|
"resultStatus": "PASSED",
|
|
"stateHistory": ["HOLD", "REINSPECTION", "PASSED", "RELEASED", "CLOSED"],
|
|
"reinspectionOfId": inspection_id,
|
|
"nonconformityId": nonconformity_id,
|
|
"reworkOrderId": rework_order_id,
|
|
"reworkScheduleSlotId": rework_schedule_slot_id,
|
|
"heldAt": reinspection_held_at.isoformat(),
|
|
"inspectedAt": reinspection_inspected_at.isoformat(),
|
|
"releasedAt": quality_released_at.isoformat(),
|
|
"closedAt": ncr_closed_at.isoformat(),
|
|
"evidenceRefs": [f"rework-order:{rework_order_id}", f"nonconformity:{nonconformity_id}"],
|
|
}
|
|
)
|
|
|
|
tables = {
|
|
"mes-orders": mes_orders,
|
|
"operation-reports": operation_reports,
|
|
"material-issues": material_issues,
|
|
"quality-inspections": quality_inspections,
|
|
"nonconformities": nonconformities,
|
|
"rework-orders": rework_orders,
|
|
}
|
|
if any(not rows for rows in tables.values()):
|
|
missing = [name for name, rows in tables.items() if not rows]
|
|
raise AssertionError(f"execution/quality tables must all contain data: {missing}")
|
|
for name, rows in tables.items():
|
|
bundle.set_rows(name, rows)
|
|
bundle.set_rows("rework-operations", rework_operations)
|
|
bundle.set_rows("rework-schedule-slots", rework_schedule_slots)
|
|
|
|
if len(bundle.rows("operations")) != baseline_operation_count or len(bundle.rows("schedule-slots")) != baseline_schedule_slot_count:
|
|
raise AssertionError("rework generation must not change baseline operation or schedule-slot counts")
|
|
rework_total_planned_hours = round(sum(float(row["plannedHours"]) for row in rework_orders), 2)
|
|
rework_total_scheduled_hours = round(sum(float(row["durationHours"]) for row in rework_schedule_slots), 2)
|
|
if rework_total_planned_hours != rework_total_scheduled_hours:
|
|
raise AssertionError("rework planned hours must equal independently scheduled finite-capacity hours")
|
|
|
|
bundle.artifacts["execution-quality"] = {
|
|
"tableCounts": {name: len(rows) for name, rows in tables.items()},
|
|
"supplementalTableCounts": {
|
|
"rework-operations": len(rework_operations),
|
|
"rework-schedule-slots": len(rework_schedule_slots),
|
|
},
|
|
"baselineCountsPreserved": {
|
|
"operations": baseline_operation_count,
|
|
"schedule-slots": baseline_schedule_slot_count,
|
|
},
|
|
"holdReleaseStateMachine": ["HOLD", "INSPECTED", "PASSED_OR_FAILED", "RELEASED"],
|
|
"ncrReworkStateMachine": ["FAILED", "NCR_OPEN", "REWORK_SLOT", "REINSPECTION", "RELEASED", "CLOSED"],
|
|
"timelineRule": "heldAt <= inspectedAt <= openedAt < rework.start < rework.end <= reinspection.inspectedAt <= releasedAt <= closedAt",
|
|
"reworkUsesScheduledFiniteResources": all(
|
|
row.get("resourceId") and row.get("teamId") and row.get("reworkScheduleSlotId") for row in rework_orders
|
|
),
|
|
"reworkTotalPlannedHours": rework_total_planned_hours,
|
|
"reworkTotalScheduledHours": rework_total_scheduled_hours,
|
|
"strictTimelineSatisfied": len(nonconformities) == len(rework_orders) == len(rework_schedule_slots),
|
|
"reworkOperations": rework_operations,
|
|
"reworkScheduleSlots": rework_schedule_slots,
|
|
"syntheticBusinessDate": config.planning_base_date.isoformat(),
|
|
}
|
|
return bundle
|