dodil/erp-brick-manufacturing

DODIL ERP brick: manufacturing

src/brick_manufacturing/domain/mrp.py

"""MRP-lite (MFG-08, MFG-17): net what is needed week by week, round the shortfall up to a lot, and say what each order needs.

Pure and deterministic - the same demand, stock and supply give the same planned orders. Demand is netted by week (Monday to Sunday):
projected stock starts at what is on hand less the safety stock; a week whose need is not covered gets one planned order, its shortfall
rounded up through product (`round_up`), and that order's quantity then covers the following weeks until the stock runs short again. A
planned order is due one lead time (a function of ITS quantity) before the first need in its week, never before today.
"""
from datetime import date, timedelta
from decimal import Decimal
from typing import Callable


def monday(d: date) -> date:
    return d - timedelta(days=d.weekday())


def low_level_codes(edges: dict) -> dict:
    """product -> its low-level code: 0 for a product no BOM uses as a component, else 1 + the deepest parent's. `edges`: parent -> {components}.
    Iterative (a product is final once every parent is final); a cycle cannot reach here (the BOM write refuses it)."""
    parents: dict = {}
    for parent, comps in edges.items():
        parents.setdefault(parent, set())
        for c in comps:
            parents.setdefault(c, set()).add(parent)
    code: dict = {}
    pending = set(parents)
    while pending:
        progressed = False
        for p in sorted(pending):
            if all(q in code for q in parents[p]):
                code[p] = 1 + max((code[q] for q in parents[p]), default=-1)
                pending.discard(p)
                progressed = True
        if not progressed:                       # defensive: never loop forever on bad data
            for p in pending:
                code[p] = 0
            break
    return code


def plan_product(needs: list, supply: list, on_hand: Decimal, safety: Decimal, today: date, lead_days: Callable[[Decimal, date], int],
                 round_up: Callable[[Decimal], Decimal]) -> list:
    """needs/supply: [(date, quantity)]. Returns [{need_date, due, quantity, covers: [{week, need}], short}] in week order."""
    weeks: dict = {}
    for d, q in needs:
        w = weeks.setdefault(monday(d), {"need": Decimal("0"), "first": d, "supply": Decimal("0")})
        w["need"] += q
        w["first"] = min(w["first"], d)
    for d, q in supply:
        w = weeks.setdefault(monday(d), {"need": Decimal("0"), "first": d, "supply": Decimal("0")})
        w["supply"] += q
    projected = on_hand - safety
    out = []
    for wk in sorted(weeks):
        w = weeks[wk]
        projected += w["supply"]
        short = w["need"] - projected
        if short > 0 and w["need"] > 0:
            qty = round_up(short)
            due = max(today, w["first"] - timedelta(days=lead_days(qty, w["first"])))
            out.append({"need_date": due, "due": due, "first_need": w["first"], "quantity": qty, "short": short,
                        "week": wk, "need": w["need"]})
            projected += qty
        projected -= w["need"]
    return out