aps-agent/server/aps_domain/supply_netting.py

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",
]