"""Pure, deterministic global supply netting for the closed-loop APS kernel. This module deliberately has no dependency on the mutable world/store layer. It receives normalized requirements and candidate supply, allocates each supply quantity at most once across all orders, and returns immutable evidence records. """ from __future__ import annotations from dataclasses import asdict, dataclass, field from datetime import date, datetime from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Any, Iterable, Literal, Mapping, Sequence SourcingType = Literal["MAKE", "BUY", "SUBCONTRACT"] SupplySource = Literal[ "STOCK", "IN_TRANSIT", "PURCHASE", "SUBCONTRACT", "MAKE", "SHORTAGE", ] _QUANT = Decimal("0.000000001") _REJECTED_STATUSES = {"CANCELLED", "CANCELED", "REJECTED", "VOID", "CLOSED"} _DRAFT_STATUSES = {"DRAFT", "SUGGESTED", "PROPOSED", "PENDING_APPROVAL"} _SOURCE_ORDER = { "STOCK": 0, "IN_TRANSIT": 1, "PURCHASE": 2, "SUBCONTRACT": 3, "MAKE": 4, "SHORTAGE": 9, } def normalize_quantity(value: Any) -> float: """Return a stable non-negative quantity rounded to APS netting precision.""" try: number = Decimal(str(value or 0)).quantize(_QUANT, rounding=ROUND_HALF_UP) except (InvalidOperation, ValueError, TypeError) as exc: raise ValueError(f"invalid quantity: {value!r}") from exc if number < 0: raise ValueError(f"quantity must be non-negative: {value!r}") return float(number) def normalize_date(value: Any) -> str | None: """Normalize supported date/datetime values to ``YYYY-MM-DD``.""" if value in (None, ""): return None if isinstance(value, datetime): return value.date().isoformat() if isinstance(value, date): return value.isoformat() text = str(value).strip() if not text: return None candidate = text[:10] try: return date.fromisoformat(candidate).isoformat() except ValueError: return None @dataclass(frozen=True, slots=True) class NettingRequirement: """A time-phased requirement submitted to the global allocation pass.""" requirement_id: str material_key: str quantity: float required_at: str sourcing_type: SourcingType priority: int = 0 sales_order_no: str = "" @dataclass(frozen=True, slots=True) class SupplyCandidate: """Normalized source record before allocation.""" event_id: str material_key: str quantity: float available_at: str | None source: SupplySource status: str trusted: bool reference_id: str = "" metadata: Mapping[str, Any] = field(default_factory=dict) @dataclass(frozen=True, slots=True) class SupplyAllocation: requirement_id: str quantity: float @dataclass(frozen=True, slots=True) class SupplyEvent: """Time-phased supply with immutable allocation evidence.""" event_id: str material_key: str quantity: float available_at: str | None source: SupplySource status: str trusted: bool reference_id: str allocations: tuple[SupplyAllocation, ...] unallocated_quantity: float metadata: Mapping[str, Any] = field(default_factory=dict) @dataclass(frozen=True, slots=True) class DemandBalance: requirement_id: str required_quantity: float allocated_quantity: float shortage_quantity: float @dataclass(frozen=True, slots=True) class NettingResult: supply_events: tuple[SupplyEvent, ...] demand_balances: tuple[DemandBalance, ...] def to_dict(self) -> dict[str, Any]: return asdict(self) def _requirement_sort_key(req: NettingRequirement) -> tuple[Any, ...]: return ( normalize_date(req.required_at) or "9999-12-31", -int(req.priority or 0), str(req.sales_order_no or ""), req.requirement_id, ) def _candidate_sort_key(candidate: SupplyCandidate) -> tuple[Any, ...]: return ( normalize_date(candidate.available_at) or "9999-12-31", _SOURCE_ORDER.get(candidate.source, 8), candidate.event_id, ) def _source_allowed(requirement: NettingRequirement, candidate: SupplyCandidate) -> bool: if requirement.sourcing_type == "BUY": return candidate.source in {"STOCK", "IN_TRANSIT", "PURCHASE"} if requirement.sourcing_type == "SUBCONTRACT": return candidate.source == "SUBCONTRACT" return candidate.source == "MAKE" def _is_allocatable(candidate: SupplyCandidate) -> bool: status = str(candidate.status or "").strip().upper() return ( candidate.trusted and status not in _REJECTED_STATUSES and status not in _DRAFT_STATUSES and normalize_date(candidate.available_at) is not None and normalize_quantity(candidate.quantity) > 0 ) def net_supply_requirements( requirements: Sequence[NettingRequirement] | Iterable[NettingRequirement], candidates: Sequence[SupplyCandidate] | Iterable[SupplyCandidate], *, business_date: str | date, ) -> NettingResult: """Globally net supply once across all requirements. Allocation is deterministic: earliest due requirement first, then higher priority, order number and requirement ID. A candidate must be trusted, non-draft, dated, type-compatible and available no later than the demand's required date. Untrusted/late candidates remain visible but unallocated. Shortage is represented as an explicit ``SHORTAGE`` event so that quantity conservation is machine-checkable without creating a purchase/work order. """ business_day = normalize_date(business_date) if business_day is None: raise ValueError(f"invalid business_date: {business_date!r}") reqs = tuple(requirements) supplies = tuple(candidates) req_ids: set[str] = set() event_ids: set[str] = set() for req in reqs: if not req.requirement_id or req.requirement_id in req_ids: raise ValueError(f"duplicate/empty requirement_id: {req.requirement_id!r}") req_ids.add(req.requirement_id) normalize_quantity(req.quantity) if normalize_date(req.required_at) is None: raise ValueError(f"invalid required_at for {req.requirement_id}: {req.required_at!r}") for candidate in supplies: if not candidate.event_id or candidate.event_id in event_ids: raise ValueError(f"duplicate/empty event_id: {candidate.event_id!r}") event_ids.add(candidate.event_id) normalize_quantity(candidate.quantity) remaining = {candidate.event_id: normalize_quantity(candidate.quantity) for candidate in supplies} allocations: dict[str, list[SupplyAllocation]] = {candidate.event_id: [] for candidate in supplies} balances: list[DemandBalance] = [] shortage_events: list[SupplyEvent] = [] candidates_by_material: dict[str, list[SupplyCandidate]] = {} for candidate in supplies: candidates_by_material.setdefault(candidate.material_key, []).append(candidate) for rows in candidates_by_material.values(): rows.sort(key=_candidate_sort_key) for req in sorted(reqs, key=_requirement_sort_key): required_quantity = normalize_quantity(req.quantity) outstanding = required_quantity required_day = normalize_date(req.required_at) or business_day for candidate in candidates_by_material.get(req.material_key, ()): if outstanding <= 0: break if not _source_allowed(req, candidate) or not _is_allocatable(candidate): continue available_day = normalize_date(candidate.available_at) if available_day is None or available_day > required_day: continue available = remaining[candidate.event_id] if available <= 0: continue allocated = normalize_quantity(min(outstanding, available)) if allocated <= 0: continue allocations[candidate.event_id].append( SupplyAllocation(requirement_id=req.requirement_id, quantity=allocated) ) remaining[candidate.event_id] = normalize_quantity(available - allocated) outstanding = normalize_quantity(outstanding - allocated) allocated_total = normalize_quantity(required_quantity - outstanding) balances.append( DemandBalance( requirement_id=req.requirement_id, required_quantity=required_quantity, allocated_quantity=allocated_total, shortage_quantity=outstanding, ) ) if outstanding > 0: shortage_events.append( SupplyEvent( event_id=f"SHORTAGE:{req.requirement_id}", material_key=req.material_key, quantity=outstanding, available_at=None, source="SHORTAGE", status="UNMET", trusted=False, reference_id=req.requirement_id, allocations=( SupplyAllocation( requirement_id=req.requirement_id, quantity=outstanding, ), ), unallocated_quantity=0.0, metadata={"businessDate": business_day, "sourcingType": req.sourcing_type}, ) ) events = [ SupplyEvent( event_id=candidate.event_id, material_key=candidate.material_key, quantity=normalize_quantity(candidate.quantity), available_at=normalize_date(candidate.available_at), source=candidate.source, status=str(candidate.status or "UNKNOWN").strip().upper(), trusted=bool(candidate.trusted), reference_id=str(candidate.reference_id or ""), allocations=tuple(allocations[candidate.event_id]), unallocated_quantity=remaining[candidate.event_id], metadata=dict(candidate.metadata), ) for candidate in supplies ] events.extend(shortage_events) events.sort( key=lambda event: ( event.material_key, normalize_date(event.available_at) or "9999-12-31", _SOURCE_ORDER.get(event.source, 8), event.event_id, ) ) balances.sort(key=lambda balance: balance.requirement_id) return NettingResult(tuple(events), tuple(balances)) __all__ = [ "DemandBalance", "NettingRequirement", "NettingResult", "SourcingType", "SupplyAllocation", "SupplyCandidate", "SupplyEvent", "SupplySource", "net_supply_requirements", "normalize_date", "normalize_quantity", ]