dodil/erp-brick-inventory
DODIL ERP brick: inventory
src/brick_inventory/domain/stock.py
"""Stock rows, derived from moves: on hand per (product, location, lot, stock type, owner) = moves in less moves out.
Nothing here is stored. A caller hands in the moves' sums; the rules of availability are decided from them:
- available counts only UNRESTRICTED stock, at an INTERNAL location, owned by us or by a consigning supplier
(we may use it), never expired, never in quality, blocked, customer-consignment or subcontractor stock;
- on hand counts everything physically in the warehouse's internal locations.
"""
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from typing import Optional
from brick_inventory.domain.values import BLOCKED, OURS, QUALITY, UNRESTRICTED, ZERO
Key = tuple # (product_id, location_id, lot_code, stock_type, owner)
def key(product_id: str, location_id: str, lot_code: str = "", stock_type: str = UNRESTRICTED, owner: str = OURS) -> Key:
return (product_id, location_id, lot_code or "", stock_type or UNRESTRICTED, owner or OURS)
def expired(expires_on: Optional[date], today: date) -> bool:
"""An expired lot is excluded from availability, ATP and picking from its expiry date (today in business_timezone)."""
return expires_on is not None and expires_on <= today
def shelf_days_left(expires_on: Optional[date], on: date) -> Optional[int]:
return None if expires_on is None else (expires_on - on).days
@dataclass(frozen=True)
class Row:
product_id: str
location_id: str
lot_code: str
stock_type: str
owner: str
quantity_base: Decimal
kind: str = "internal" # the location's kind
warehouse_id: str = ""
expires_on: Optional[date] = None
receiving: bool = False # goods-in with two-step receipts: on hand, not available until put away (INV-50)
def usable(self, today: date, *, allow_expired: bool = False) -> bool:
"""Could this stock be promised, reserved or picked? (INV-36, INV-55, INV-45)"""
return (self.kind == "internal" and not self.receiving and self.stock_type == UNRESTRICTED
and (allow_expired or not expired(self.expires_on, today)))
def summarise(rows: list, today: date) -> dict:
"""on hand, by type and owner, and available, from stock rows of ONE warehouse (internal locations only)."""
on_hand = sum((r.quantity_base for r in rows if r.kind == "internal"), ZERO)
by_type = {t: sum((r.quantity_base for r in rows if r.kind == "internal" and r.stock_type == t), ZERO)
for t in (UNRESTRICTED, QUALITY, BLOCKED)}
consigned = sum((r.quantity_base for r in rows if r.kind == "internal" and r.owner != OURS), ZERO)
available = sum((r.quantity_base for r in rows if r.usable(today)), ZERO)
return {"on_hand": on_hand, "by_type": by_type, "consignment": consigned, "available_before_reservations": available}