dodil/erp-brick-inventory

DODIL ERP brick: inventory

src/brick_inventory/domain/removal.py

"""Which lot to take: first-expiring first (FEFO), oldest received first (FIFO), or the walking order (manual).

An expired lot is never proposed, picked or promised from its expiry date; a lot with less shelf life left than the customer
accepts is not proposed either (a warehouse manager may allow it, with a reason, recorded). Stock in quality, blocked,
consignment-at-a-customer or subcontractor places is never proposed.
"""
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from typing import Optional

from brick_inventory.domain.stock import expired, shelf_days_left
from brick_inventory.domain.values import ZERO

FAR = date(9999, 12, 31)
LONG_AGO = date(1900, 1, 1)


@dataclass(frozen=True)
class Candidate:
    location_id: str
    lot_code: str
    stock_type: str
    owner: str
    quantity_base: Decimal
    expires_on: Optional[date] = None
    received_on: Optional[date] = None
    pick_sequence: int = 0


def eligible(c: Candidate, today: date, min_shelf_life_days: Optional[int], allow_expired: bool = False) -> bool:
    if c.stock_type != "unrestricted" or c.quantity_base <= 0:
        return False
    if expired(c.expires_on, today) and not allow_expired:
        return False
    if min_shelf_life_days and c.expires_on is not None:
        left = shelf_days_left(c.expires_on, today)
        if left is not None and left < min_shelf_life_days:
            return False
    return True


def order_key(strategy: str):
    if strategy == "fefo":
        return lambda c: (c.expires_on or FAR, c.received_on or LONG_AGO, c.pick_sequence, c.location_id, c.lot_code)
    if strategy == "fifo":
        return lambda c: (c.received_on or LONG_AGO, c.pick_sequence, c.location_id, c.lot_code)
    return lambda c: (c.pick_sequence, c.location_id, c.lot_code)


def propose(candidates: list, need: Decimal, strategy: str, today: date, min_shelf_life_days: Optional[int] = None,
            allow_expired: bool = False) -> tuple:
    """([(candidate, quantity)], short): the lots and bins to take `need` from, in order, and what could not be found."""
    pool = sorted((c for c in candidates if eligible(c, today, min_shelf_life_days, allow_expired)), key=order_key(strategy))
    out, left = [], need
    for c in pool:
        if left <= 0:
            break
        take = min(left, c.quantity_base)
        out.append((c, take))
        left -= take
    return out, max(left, ZERO)