aps-agent/server/shipyard_synthetic/sourcing.py

726 lines
31 KiB
Python

from __future__ import annotations
from datetime import date, datetime, timedelta
from math import ceil
from .config import GeneratorConfig
from .models import DatasetBundle, stable_id
def _round_order(quantity: float, moq: int, multiple: int) -> int:
requested = max(ceil(quantity), int(moq))
return int(ceil(requested / max(1, multiple)) * max(1, multiple))
def generate_sourcing(bundle: DatasetBundle, config: GeneratorConfig) -> DatasetBundle:
"""Generate purchase and capacity-checked outsource suggestions.
Outsource suggestions are the canonical source of truth for the operation-level
supplier and transport timeline fields consumed by the finite scheduler.
"""
materials = {str(row["materialId"]): row for row in bundle.rows("materials")}
requirements = bundle.rows("material-requirements")
suppliers = bundle.rows("suppliers")
operations = bundle.rows("operations")
requirements_by_wbs: dict[str, list[dict]] = {}
requirements_by_project: dict[str, list[dict]] = {}
for requirement in sorted(
requirements,
key=lambda row: str(row.get("requirementId") or ""),
):
requirements_by_wbs.setdefault(str(requirement.get("wbsId") or ""), []).append(
requirement
)
requirements_by_project.setdefault(
str(requirement.get("projectId") or ""), []
).append(requirement)
work_orders = {
str(row["workOrderId"]): row for row in bundle.rows("work-orders")
}
production_orders = {
str(row["productionOrderId"]): row
for row in bundle.rows("production-orders")
}
if not suppliers:
raise ValueError("sourcing requires suppliers")
buy = [
row
for row in requirements
if row.get("sourcingMode") == "BUY"
and float(row.get("netRequirement") or 0) > 0
]
purchase_suppliers = sorted(
[
row
for row in suppliers
if row.get("approved") is True
and row.get("status") == "ACTIVE"
and row.get("monthlyCapacityUnit") == "PCS"
and float(row.get("monthlyCapacity") or 0) > 0
],
key=lambda row: str(row.get("supplierCode") or row["supplierId"]),
)
if not purchase_suppliers:
raise ValueError(
"sourcing requires an approved material supplier with PCS capacity"
)
purchase_rows: list[dict] = []
purchase_allocations: dict[tuple[str, str], float] = {}
target = min(config.profile.purchase_suggestion_count, len(buy))
for index, req in enumerate(buy[:target], 1):
material = materials[str(req["materialId"])]
need_date = date.fromisoformat(str(req["needDate"])[:10])
lead = int(material.get("leadTimeDays") or 30)
latest = need_date - timedelta(days=lead)
quantity = _round_order(
float(req["netRequirement"]),
int(material.get("moq") or 1),
int(material.get("orderMultiple") or 1),
)
capacity_month = need_date.strftime("%Y-%m")
supplier: dict | None = None
projected = 0.0
for offset in range(len(purchase_suppliers)):
candidate = purchase_suppliers[
(index - 1 + offset) % len(purchase_suppliers)
]
allocation_key = (str(candidate["supplierId"]), capacity_month)
candidate_projected = purchase_allocations.get(allocation_key, 0.0) + float(
quantity
)
if candidate_projected <= float(candidate["monthlyCapacity"]) + 0.001:
supplier = candidate
projected = candidate_projected
purchase_allocations[allocation_key] = candidate_projected
break
if supplier is None:
raise ValueError(
f"no purchase supplier capacity for requirement {req['requirementId']}"
)
suggested_order_date = max(
config.planning_base_date, latest - timedelta(days=5)
)
blackout_dates = {
str(value)[:10] for value in supplier.get("blackoutDates") or []
}
while suggested_order_date.isoformat() in blackout_dates:
suggested_order_date += timedelta(days=1)
monthly_capacity = float(supplier["monthlyCapacity"])
purchase_rows.append(
{
"purchaseSuggestionId": stable_id(
"purchase", req["requirementId"], prefix="PUR"
),
"projectId": req["projectId"],
"wbsId": req["wbsId"],
"requirementId": req["requirementId"],
"materialId": req["materialId"],
"supplierId": supplier["supplierId"],
"grossRequirement": req["grossRequirement"],
"stockUsed": req["stockUsed"],
"releasedAllocationUsed": req["releasedAllocationUsed"],
"plannedReceiptUsed": req["plannedReceiptUsed"],
"safetyStockRequirement": req["safetyStockRequirement"],
"scrapRequirement": req["scrapRequirement"],
"netRequirement": req["netRequirement"],
"suggestedQuantity": quantity,
"capacityQuantity": quantity,
"capacityMonth": capacity_month,
"capacityBucket": capacity_month,
"capacityUnit": "PCS",
"unit": "PCS",
"supplierMonthlyCapacity": monthly_capacity,
"supplierMonthlyAllocated": projected,
"supplierMonthlyRemaining": monthly_capacity - projected,
"supplierCapacityCheck": (
"CALCULATED_PASS"
if projected <= monthly_capacity + 0.001
else "CAPACITY_EXCEEDED"
),
"suggestedOrderDate": suggested_order_date.isoformat(),
"latestOrderDate": latest.isoformat(),
"needDate": need_date.isoformat(),
"riskLevel": (
"HIGH"
if latest < config.planning_base_date
else "MEDIUM"
if lead > 60
else "LOW"
),
"evidenceRefs": (
f"requirement:{req['requirementId']}|material:{req['materialId']}"
),
}
)
outsource_target = config.profile.outsource_suggestion_count
operation_outsource_fields = (
"outsourceSuggestionId",
"supplierId",
"outsourceSupplierId",
"sendDate",
"outsourceSendDate",
"returnDate",
"outsourceReturnAt",
"outboundTransportDays",
"inboundTransportDays",
"transportDays",
"processingDays",
"inspectionDays",
"outsourceCapacityMonth",
"outsourceCapacityUnit",
)
for operation in operations:
operation["sourcingMode"] = "MAKE"
for field in operation_outsource_fields:
operation.pop(field, None)
bundle.set_rows("operations", operations)
# The provisional finite schedule exposes the exact predecessor boundary and
# resource/team lane. Selecting the latest internal operation from distinct
# lanes avoids serialising every outsource cycle onto one synthetic lane.
from .scheduler import generate_schedule
try:
generate_schedule(bundle, config)
except ValueError as exc:
# This pass is only a lane/boundary projection. The authoritative pipeline
# pass still enforces every hard constraint after outsource dates are bound.
if (
not str(exc).startswith(
"generated baseline schedule violates hard constraints:"
)
or len(bundle.rows("schedule-slots")) != config.profile.schedule_slot_count
):
raise
slot_by_operation = {
str(row["operationId"]): row for row in bundle.rows("schedule-slots")
}
operation_by_id = {str(row["operationId"]): row for row in operations}
outsource_candidates = sorted(
[
row
for row in operations
if row.get("previousOperationId")
and row.get("nextOperationId")
and str(row.get("operationId")) in slot_by_operation
and date.fromisoformat(
str(slot_by_operation[str(row["operationId"])]["end"])[:10]
)
<= config.planning_horizon_end - timedelta(days=240)
and str(
operation_by_id.get(str(row.get("nextOperationId")), {}).get(
"relationType", "FS"
)
).upper()
== "FS"
and float(
operation_by_id.get(str(row.get("nextOperationId")), {}).get(
"lagHours", 0
)
or 0
)
>= 0
],
key=lambda row: (
str(slot_by_operation[str(row["operationId"])].get("start") or ""),
str(row.get("operationId") or ""),
),
reverse=True,
)
selected_operations: list[dict] = []
selected_lanes: set[tuple[str, str]] = set()
selected_work_orders: set[str] = set()
for operation in outsource_candidates:
slot = slot_by_operation[str(operation["operationId"])]
lane = (str(slot.get("resourceId") or ""), str(slot.get("teamId") or ""))
work_order_id = str(operation.get("workOrderId") or "")
if lane in selected_lanes or work_order_id in selected_work_orders:
continue
selected_operations.append(operation)
selected_lanes.add(lane)
selected_work_orders.add(work_order_id)
if len(selected_operations) == outsource_target:
break
if len(selected_operations) < outsource_target:
selected_ids = {str(row["operationId"]) for row in selected_operations}
for operation in outsource_candidates:
operation_id = str(operation["operationId"])
work_order_id = str(operation.get("workOrderId") or "")
if operation_id in selected_ids or work_order_id in selected_work_orders:
continue
selected_operations.append(operation)
selected_ids.add(operation_id)
selected_work_orders.add(work_order_id)
if len(selected_operations) == outsource_target:
break
if len(selected_operations) != outsource_target:
raise ValueError("sourcing requires enough eligible internal operations")
for operation in selected_operations:
operation["sourcingMode"] = "OUTSOURCE"
outsource_suppliers = sorted(
[
row
for row in suppliers
if row.get("approved") is True
and row.get("status") == "ACTIVE"
and row.get("monthlyCapacityUnit") == "OPERATION"
and float(row.get("monthlyCapacity") or 0) > 0
and row.get("outsourceCapabilityCodes")
],
key=lambda row: str(row.get("supplierCode") or row["supplierId"]),
)
if not outsource_suppliers:
raise ValueError(
"sourcing requires an approved active outsource supplier with finite capacity"
)
capacity_allocations: dict[tuple[str, str], float] = {}
outsource_rows: list[dict] = []
for index, operation in enumerate(selected_operations, 1):
previous_id = str(operation["previousOperationId"])
previous_slot = slot_by_operation.get(previous_id)
if previous_slot is None:
raise ValueError(
f"outsource operation {operation['operationId']} has no predecessor slot"
)
earliest_send = datetime.fromisoformat(str(previous_slot["end"]))
assignment: (
tuple[dict, datetime, datetime, int, int, int, int, str, float] | None
) = None
for offset in range(len(outsource_suppliers)):
supplier = outsource_suppliers[
(index - 1 + offset) % len(outsource_suppliers)
]
outbound_days = int(supplier.get("transportOutboundDays") or 1)
inbound_days = int(supplier.get("transportInboundDays") or 1)
processing_days = max(
1, min(2, int(supplier.get("standardLeadTimeDays") or 2))
)
inspection_days = max(1, int(supplier.get("inspectionLeadTimeDays") or 2))
blackout_dates = {
str(value)[:10] for value in supplier.get("blackoutDates") or []
}
cycle_days = (
outbound_days + processing_days + inspection_days + inbound_days
)
send_date = earliest_send
for _ in range(366):
return_date = send_date + timedelta(days=cycle_days)
blackout_overlap = [
(send_date.date() + timedelta(days=day_offset)).isoformat()
for day_offset in range(cycle_days + 1)
if (send_date.date() + timedelta(days=day_offset)).isoformat()
in blackout_dates
]
if not blackout_overlap:
capacity_month = send_date.strftime("%Y-%m")
allocation_key = (str(supplier["supplierId"]), capacity_month)
allocated = capacity_allocations.get(allocation_key, 0.0)
projected = allocated + 1.0
monthly_capacity = float(supplier["monthlyCapacity"])
if projected <= monthly_capacity + 0.001:
assignment = (
supplier,
send_date,
return_date,
outbound_days,
inbound_days,
processing_days,
inspection_days,
capacity_month,
projected,
)
break
send_date += timedelta(days=1)
if assignment is not None:
break
if assignment is None:
raise ValueError(
f"no approved supplier capacity/blackout window for {operation['operationId']}"
)
(
supplier,
send_date,
return_date,
outbound_days,
inbound_days,
processing_days,
inspection_days,
capacity_month,
projected,
) = assignment
supplier_id = str(supplier["supplierId"])
operation_code = str(operation["operationCode"])
monthly_capacity = float(supplier["monthlyCapacity"])
capacity_allocations[(supplier_id, capacity_month)] = projected
supplier["outsourceOperationCodes"] = sorted(
{
*(
str(value)
for value in supplier.get("outsourceOperationCodes") or []
),
operation_code,
}
)
suggestion_id = stable_id("outsource", operation["operationId"], prefix="OUT")
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[str(operation["workOrderId"])]
production_order = production_orders[str(work_order["productionOrderId"])]
required_certificate = f"SHIP-OUTSOURCE-{operation_code}-APPROVAL"
operation.update(
{
"outsourceSuggestionId": suggestion_id,
"supplierId": supplier_id,
"outsourceSupplierId": supplier_id,
"sendDate": send_date.isoformat(),
"outsourceSendAt": send_date.isoformat(),
"outsourceSendDate": send_date.isoformat(),
"returnDate": return_date.isoformat(),
"outsourceReturnAt": return_date.isoformat(),
"outboundTransportDays": outbound_days,
"inboundTransportDays": inbound_days,
"transportOutboundDays": outbound_days,
"transportInboundDays": inbound_days,
"transportReturnDays": inbound_days,
"transportDays": outbound_days + inbound_days,
"processDays": processing_days,
"processingDays": processing_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",
"outsourceCapacityMonth": capacity_month,
"capacityMonth": capacity_month,
"capacityBucket": capacity_month,
"outsourceCapacityUnit": "OPERATION",
}
)
outsource_rows.append(
{
"outsourceSuggestionId": suggestion_id,
"outsourceOrderId": 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_id,
"previousOperationId": operation["previousOperationId"],
"nextOperationId": operation["nextOperationId"],
"sendDate": send_date.isoformat(),
"expectedReturnDate": return_date.isoformat(),
"returnDate": return_date.isoformat(),
"outboundTransportDays": outbound_days,
"inboundTransportDays": inbound_days,
"transportOutboundDays": outbound_days,
"transportInboundDays": inbound_days,
"transportReturnDays": inbound_days,
"transportDays": outbound_days + inbound_days,
"processDays": processing_days,
"processingDays": processing_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",
"capacityMonth": capacity_month,
"capacityBucket": capacity_month,
"capacityUnit": "OPERATION",
"capacityQuantity": 1.0,
"supplierMonthlyCapacity": monthly_capacity,
"supplierMonthlyAllocated": projected,
"supplierMonthlyRemaining": monthly_capacity - projected,
"supplierCapacityCheck": (
"CALCULATED_PASS"
if projected <= monthly_capacity + 0.001
else "CAPACITY_EXCEEDED"
),
"supplierCapacityCheckBasis": "CALCULATED_MONTHLY_CAPACITY",
"supplierBlackoutCheck": "CALCULATED_INTERVAL_PASS",
"supplierBlackoutCheckBasis": "CLOSED_INTERVAL_SEND_TO_RETURN",
"supplierBlackoutWindowStart": send_date.date().isoformat(),
"supplierBlackoutWindowEnd": return_date.date().isoformat(),
"supplierBlackoutOverlapDates": [],
"status": "SUGGESTED",
"evidenceRefs": [
f"operation:{operation['operationId']}",
f"supplier:{supplier_id}",
f"material:{material_requirement['materialId']}",
f"requirement:{material_requirement['requirementId']}",
f"schedule-slot:{previous_slot['scheduleSlotId']}",
],
}
)
if len(outsource_rows) != outsource_target:
raise AssertionError("outsource suggestion cardinality contract failed")
bundle.set_rows("suppliers", suppliers)
bundle.set_rows("operations", operations)
bundle.set_rows("purchase-suggestions", purchase_rows)
bundle.set_rows("outsource-suggestions", outsource_rows)
# Re-run the finite scheduler until its predecessor-derived outsource timestamps
# and supplier assignment reach a fixed point. For every candidate supplier we
# reproduce the scheduler's endpoint shift, then independently reject any
# blackout date inside the complete closed send..return interval.
for _ in range(12):
before = {
str(row["operationId"]): (
row.get("supplierId"),
row.get("sendDate"),
row.get("returnDate"),
)
for row in bundle.rows("outsource-suggestions")
}
try:
generate_schedule(bundle, config)
except ValueError as exc:
if (
not str(exc).startswith(
"generated baseline schedule violates hard constraints:"
)
or len(bundle.rows("schedule-slots"))
!= config.profile.schedule_slot_count
):
raise
synchronized_suggestions = bundle.rows("outsource-suggestions")
synchronized_operations = {
str(row["operationId"]): row for row in bundle.rows("operations")
}
actual_allocations: dict[tuple[str, str], float] = {}
for suggestion in synchronized_suggestions:
operation = synchronized_operations[str(suggestion["operationId"])]
operation_code = str(operation["operationCode"])
scheduled_send = datetime.fromisoformat(str(suggestion["sendDate"]))
current_supplier_id = str(suggestion["supplierId"])
quantity = float(suggestion.get("capacityQuantity") or 1.0)
ordered_suppliers = sorted(
outsource_suppliers,
key=lambda row: (
str(row["supplierId"]) != current_supplier_id,
str(row.get("supplierCode") or row["supplierId"]),
),
)
synchronized_assignment: (
tuple[dict, datetime, datetime, int, int, int, int, str, float]
| None
) = None
for supplier in ordered_suppliers:
outbound_days = int(supplier.get("transportOutboundDays") or 1)
inbound_days = int(supplier.get("transportInboundDays") or 1)
processing_days = max(
1, min(2, int(supplier.get("standardLeadTimeDays") or 2))
)
inspection_days = max(
1, int(supplier.get("inspectionLeadTimeDays") or 2)
)
cycle_days = (
outbound_days
+ processing_days
+ inspection_days
+ inbound_days
)
blackout_dates = {
str(value)[:10]
for value in supplier.get("blackoutDates") or []
}
candidate_send = scheduled_send
for _ in range(366):
candidate_return = candidate_send + timedelta(days=cycle_days)
send_blackout = (
candidate_send.date().isoformat() in blackout_dates
)
return_blackout = (
candidate_return.date().isoformat() in blackout_dates
)
if send_blackout or return_blackout:
candidate_send += timedelta(days=1)
continue
blackout_overlap = [
(
candidate_send.date() + timedelta(days=day_offset)
).isoformat()
for day_offset in range(cycle_days + 1)
if (
candidate_send.date() + timedelta(days=day_offset)
).isoformat()
in blackout_dates
]
capacity_month = candidate_send.strftime("%Y-%m")
allocation_key = (
str(supplier["supplierId"]),
capacity_month,
)
projected = (
actual_allocations.get(allocation_key, 0.0) + quantity
)
monthly_capacity = float(supplier["monthlyCapacity"])
if (
not blackout_overlap
and projected <= monthly_capacity + 0.001
):
synchronized_assignment = (
supplier,
candidate_send,
candidate_return,
outbound_days,
inbound_days,
processing_days,
inspection_days,
capacity_month,
projected,
)
break
if synchronized_assignment is not None:
break
if synchronized_assignment is None:
raise ValueError(
"no approved supplier with a scheduler-stable full-cycle "
"blackout/capacity window for "
f"{suggestion['outsourceSuggestionId']}"
)
(
supplier,
send_date,
return_date,
outbound_days,
inbound_days,
processing_days,
inspection_days,
capacity_month,
allocated,
) = synchronized_assignment
supplier_id = str(supplier["supplierId"])
monthly_capacity = float(supplier["monthlyCapacity"])
allocation_key = (supplier_id, capacity_month)
actual_allocations[allocation_key] = allocated
supplier["outsourceOperationCodes"] = sorted(
{
*(
str(value)
for value in supplier.get("outsourceOperationCodes") or []
),
operation_code,
}
)
suggestion.update(
{
"supplierId": supplier_id,
"sendDate": send_date.isoformat(),
"expectedReturnDate": return_date.isoformat(),
"returnDate": return_date.isoformat(),
"outboundTransportDays": outbound_days,
"inboundTransportDays": inbound_days,
"transportOutboundDays": outbound_days,
"transportInboundDays": inbound_days,
"transportReturnDays": inbound_days,
"transportDays": outbound_days + inbound_days,
"processDays": processing_days,
"processingDays": processing_days,
"inspectionDays": inspection_days,
"capacityMonth": capacity_month,
"capacityBucket": capacity_month,
"supplierMonthlyCapacity": monthly_capacity,
"supplierMonthlyAllocated": allocated,
"supplierMonthlyRemaining": monthly_capacity - allocated,
"supplierCapacityCheck": "CALCULATED_PASS",
"supplierCapacityCheckBasis": "CALCULATED_MONTHLY_CAPACITY",
"supplierBlackoutCheck": "CALCULATED_INTERVAL_PASS",
"supplierBlackoutCheckBasis": "CLOSED_INTERVAL_SEND_TO_RETURN",
"supplierBlackoutWindowStart": send_date.date().isoformat(),
"supplierBlackoutWindowEnd": return_date.date().isoformat(),
"supplierBlackoutOverlapDates": [],
}
)
operation.update(
{
"outsourceSuggestionId": suggestion["outsourceSuggestionId"],
"supplierId": supplier_id,
"outsourceSupplierId": supplier_id,
"sendDate": suggestion["sendDate"],
"outsourceSendAt": suggestion["sendDate"],
"outsourceSendDate": str(suggestion["sendDate"])[:10],
"returnDate": suggestion["returnDate"],
"outsourceReturnAt": suggestion["returnDate"],
"outboundTransportDays": outbound_days,
"inboundTransportDays": inbound_days,
"transportOutboundDays": outbound_days,
"transportInboundDays": inbound_days,
"transportDays": outbound_days + inbound_days,
"processingDays": processing_days,
"inspectionDays": inspection_days,
"outsourceCapacityMonth": capacity_month,
"capacityMonth": capacity_month,
"capacityBucket": capacity_month,
"outsourceCapacityUnit": "OPERATION",
}
)
after = {
str(row["operationId"]): (
row.get("supplierId"),
row.get("sendDate"),
row.get("returnDate"),
)
for row in synchronized_suggestions
}
bundle.set_rows("suppliers", suppliers)
bundle.set_rows("operations", synchronized_operations.values())
bundle.set_rows("outsource-suggestions", synchronized_suggestions)
if before == after:
break
else:
raise ValueError(
"outsource supplier/blackout timeline did not converge within 12 passes"
)
cleaned_operations = bundle.rows("operations")
for operation in cleaned_operations:
operation.pop("timeFence", None)
operation.pop("frozenBaselineStart", None)
operation.pop("frozenChangeAuthorized", None)
bundle.set_rows("operations", cleaned_operations)
for table_name in (
"schedule-versions",
"schedule-slots",
"resource-loads",
"conflicts",
"kpis",
):
bundle.set_rows(table_name, [])
for artifact_name in (
"algorithmEvidence",
"baseline-results",
"baseline-kpis",
"expected-conflicts",
"expected-explanations",
):
bundle.artifacts.pop(artifact_name, None)
return bundle