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