dodil/erp-brick-manufacturing

DODIL ERP brick: manufacturing

src/brick_manufacturing/application/inputs.py

"""The bounded inputs of this brick's use cases (BRICKS.md: an unbounded int or str is a promise the engine cannot keep).
A quantity is an exact decimal with at most four places, as a string on the wire; a date is parsed INSIDE the use case so a
caller without the permission is told so (403) rather than that their date is malformed (422)."""
from decimal import Decimal, InvalidOperation
from typing import Annotated, Any, Literal, Optional

from erp_platform.types import text
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, WithJsonSchema

LIMIT = Decimal("1e15")
Code = text(64, min_length=1)
Reason = text(500)
Day = Annotated[str, WithJsonSchema({"type": "string", "format": "date", "description": "a date like 2026-11-20"})]


def _num(v: Any, what: str, places: int, zero_ok: bool) -> Decimal:
    if isinstance(v, bool) or isinstance(v, float):
        raise ValueError(f"{what} is an exact number: give it as a string such as \"100.000\"")
    try:
        d = v if isinstance(v, Decimal) else Decimal(str(v).strip())
    except (InvalidOperation, ValueError):
        raise ValueError(f"{v!r} is not {what}") from None
    if not d.is_finite():
        raise ValueError(f"{what} is a finite number")
    if d.as_tuple().exponent < -places:
        raise ValueError(f"{what} has at most {places} decimal places")
    if not ((Decimal(0) <= d if zero_ok else Decimal(0) < d) and d < LIMIT):
        raise ValueError(f"{what} is {'zero or more' if zero_ok else 'more than zero'} and below 1,000,000,000,000,000")
    return d


_SCHEMA = {"type": "string", "pattern": r"^[0-9]{1,15}(\.[0-9]{1,4})?$"}
Qty = Annotated[Decimal, BeforeValidator(lambda v: _num(v, "a quantity", 4, False)),
                WithJsonSchema({**_SCHEMA, "description": "an exact quantity as a string (\"100.000\"), more than zero, up to 4 places"})]
Rate0 = Annotated[Decimal, BeforeValidator(lambda v: _num(v, "a rate", 4, True)),
                  WithJsonSchema({**_SCHEMA, "description": "an exact rate or unit cost as a string, zero or more, up to 4 places"})]


class _In(BaseModel):
    model_config = ConfigDict(extra="forbid")


class BomLineIn(_In):
    """One line of a BOM version: a component (goes in), a by-product (comes out, at its net realisable value) or, on a usage-sale
    BOM, a modifier (a till option that replaces a component)."""
    product_id: Code
    quantity: Qty
    unit_code: Code
    kind: Literal["component", "by_product", "modifier"] = "component"
    nrv_unit_cost: Optional[Rate0] = None
    replaces_product_id: Optional[Code] = None
    modifier_id: Optional[Code] = None


class IssueLineIn(_In):
    """One component taken from stock into the order: by product (and its lot, scanned)."""
    product_id: Code
    quantity: Qty
    unit_code: Optional[Code] = None
    lot_code: Optional[Code] = None


class ByProductIn(_In):
    """One by-product that came out (chaff, trim), by BOM line product."""
    product_id: Code
    quantity: Qty
    lot_code: Optional[Code] = None


class OperationIn(_In):
    """One step of a routing. With a work_centre_id it takes time (setup once, run_minutes per per_quantity); without one it is waiting
    time (wait_hours): it moves the date and costs nothing. basis says what a batch is counted in: the product made (output) or what
    goes into it (input: the BOM's components in unit_code)."""
    op_no: int = Field(ge=1, le=9999)
    description: text(200) = ""
    work_centre_id: Optional[Code] = None
    setup_minutes: Rate0 = Decimal("0")
    run_minutes: Rate0 = Decimal("0")
    per_quantity: Qty = Decimal("1")
    basis: Literal["output", "input"] = "output"
    unit_code: Optional[Code] = None
    wait_hours: Rate0 = Decimal("0")


class ForecastIn(_In):
    """The demand the planner enters for one product in one week (the shops' use, until pos publishes its history)."""
    product_id: Code
    week_start: Day
    quantity: Qty
    unit_code: Optional[Code] = None