726 lines
31 KiB
Python
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
|