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