317 lines
11 KiB
Python
317 lines
11 KiB
Python
|
|
"""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",
|
|||
|
|
]
|