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}