dodil/erp-brick-purchasing

DODIL ERP brick: purchasing

src/brick_purchasing/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). Money is NUMERIC(18,2),
a price NUMERIC(19,4), a quantity NUMERIC(19,6): every value is checked before it can overflow a column (a 422 naming the field, never a 500)."""
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Annotated, Any, Literal, Optional

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

Code = text(64, min_length=1)                 # a product code, a unit code, a cost centre, a category
OptCode = text(64)
Currency = text(3, min_length=3)
Reason = text(1000)
Ref = text(200, min_length=1)
LineNo = Annotated[int, Field(ge=1, le=100_000)]
Seq = Annotated[int, Field(ge=0, le=2_000_000_000)]
Whole = Annotated[int, Field(ge=1, le=1_000_000)]
Version = Annotated[int, Field(ge=0, le=1_000_000)]
Offset = Annotated[int, Field(ge=0, le=10_000_000)]


def _dec(v: Any, what: str, places: int, lo: Decimal, hi: Decimal) -> Decimal:
    if isinstance(v, (bool, float)):
        raise ValueError(f"{what} is an exact number: give it as a string such as \"120.00\"")
    try:
        d = v if isinstance(v, Decimal) else Decimal(str(v).strip())
    except (InvalidOperation, ValueError):
        raise ValueError(f"{v!r} is not a number") from None
    if not d.is_finite():
        raise ValueError(f"{what} is a finite number")
    if -d.normalize().as_tuple().exponent > places:
        raise ValueError(f"{what} has at most {places} decimal places")
    if not (lo <= d <= hi):
        raise ValueError(f"{what} is between {lo} and {hi}")
    return d


def _schema(pattern: str, text_: str):
    return WithJsonSchema({"type": "string", "pattern": pattern, "description": text_})


Money = Annotated[Decimal, BeforeValidator(lambda v: _dec(v, "an amount", 2, Decimal("-9999999999999999"), Decimal("9999999999999999"))),
                  _schema(r"^-?[0-9]{1,16}(\.[0-9]{1,2})?$", "an exact amount (\"1250.00\")")]
Price = Annotated[Decimal, BeforeValidator(lambda v: _dec(v, "a price", 4, Decimal(0), Decimal("999999999999999"))),
                  _schema(r"^[0-9]{1,15}(\.[0-9]{1,4})?$", "a price per unit, up to 4 decimals (\"18.0000\")")]
Qty = Annotated[Decimal, BeforeValidator(lambda v: _dec(v, "a quantity", 6, Decimal(0), Decimal("9999999999999"))),
                _schema(r"^[0-9]{1,13}(\.[0-9]{1,6})?$", "a quantity, up to 6 decimals (\"10\")")]
SignedQty = Annotated[Decimal, BeforeValidator(lambda v: _dec(v, "a quantity", 6, Decimal("-9999999999999"), Decimal("9999999999999"))),
                      _schema(r"^-?[0-9]{1,13}(\.[0-9]{1,6})?$", "a signed quantity")]
Pct = Annotated[Decimal, BeforeValidator(lambda v: _dec(v, "a percentage", 4, Decimal(0), Decimal(100))),
                _schema(r"^[0-9]{1,3}(\.[0-9]{1,4})?$", "a percentage between 0 and 100")]


def fit_money(value, what: str):
    """NUMERIC(18,2) holds 16 integer digits: a computed price x quantity (or a total) beyond that is a 422 before it is saved, not a store error."""
    from brick_purchasing.domain.values import fits_money
    if not fits_money(value):
        from erp_platform.bricks import Refused
        raise Refused(422, f"{what} {value} does not fit an amount (at most 16 digits before the decimal point)")
    return value


def day(v) -> Optional[date]:
    if v in (None, ""):
        return None
    return date.fromisoformat(v) if isinstance(v, str) else v


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


class AddressIn(_In):
    """The one postal address shape between modules (erp-method shared.yaml conventions.address)."""
    lines: Annotated[list[Annotated[str, Field(min_length=1, max_length=70)]], Field(min_length=1, max_length=4)]
    city: Annotated[str, Field(min_length=1, max_length=70)]
    postcode: Optional[Annotated[str, Field(max_length=16)]] = None
    region: Optional[Annotated[str, Field(max_length=70)]] = None
    country: Annotated[str, Field(pattern=r"^[A-Z]{2}$")]


class RequestLineIn(_In):
    """One thing asked for: a product (or a description), how many, and an estimated price per unit."""
    product_id: Optional[Code] = None
    description: Optional[Name] = None
    quantity: Qty
    unit_code: Optional[Code] = None
    unit_price: Price = Decimal(0)
    bp_id: Optional[int] = Field(default=None, ge=1, le=2 ** 63 - 1)
    needed_by: Optional[IsoDate] = None


class ComponentIn(_In):
    """A component we provide to a subcontractor per finished unit."""
    product_id: Code
    quantity_per: Qty
    unit_code: Code


class DeliveryCostIn(_In):
    """A planned freight, duty or other cost on an order (line_no 0 = the whole order): an amount or a percent of the value."""
    kind: Literal["freight", "duty", "insurance", "other"]
    amount: Optional[Money] = None
    percent: Optional[Pct] = None
    payee_bp_id: Optional[int] = Field(default=None, ge=1, le=2 ** 63 - 1)
    line_no: int = Field(default=0, ge=0, le=100_000)
    tax_code: Optional[OptCode] = None


class OrderLineIn(_In):
    """One thing ordered, in the supplier's unit. The unit, the order price unit and the price default from the supplier's price in force
    today (a manual `unit_price` for a line with none). The order price unit's conversion is fixed when the line is priced."""
    product_id: Optional[Code] = None
    description: Optional[Name] = None
    quantity: Qty
    unit_code: Optional[Code] = None
    unit_price: Optional[Price] = None
    pricing_unit_code: Optional[Code] = None
    pricing_quantity: Optional[Whole] = None
    expected_on: Optional[IsoDate] = None
    expense_category: Optional[Code] = None
    kind: Optional[Literal["goods", "service"]] = None
    account_assignment: Optional[Literal["stock", "cost_centre", "asset", "project"]] = None
    cost_centre: Optional[OptCode] = None
    asset_id: Optional[OptCode] = None
    asset_class_code: Optional[OptCode] = None
    project_id: Optional[OptCode] = None
    company_code: Optional[text(4)] = None
    item_category: Optional[Literal["standard", "consignment", "subcontract"]] = None
    ers: Optional[bool] = None
    components: Optional[list[ComponentIn]] = Field(default=None, max_length=200)
    delivery_costs: Optional[list[DeliveryCostIn]] = Field(default=None, max_length=50)
    quota_reason: Optional[Reason] = None


class ReceiptLineIn(_In):
    line_no: LineNo
    quantity: Qty
    unit_code: Optional[Code] = None
    catch_quantity: Optional[Ref] = None            # RESERVED until M7: refused with "catch weight arrives in M7"
    catch_unit_code: Optional[Ref] = None


class ReturnLineIn(_In):
    line_no: LineNo                                  # the receipt's line
    quantity: Qty


class BillLineIn(_In):
    """One line of the supplier's claim. `order_line_no` names the order line it bills (none: a line with no order)."""
    order_line_no: Optional[LineNo] = None
    product_id: Optional[Code] = None
    description: Optional[Name] = None
    quantity: Qty
    unit_code: Optional[Code] = None
    pricing_unit_code: Optional[Code] = None
    pricing_quantity: Optional[Whole] = None
    unit_price: Price
    net_amount: Optional[Money] = None
    tax_category: Optional[OptCode] = None
    tax_code: Optional[OptCode] = None
    expense_category: Optional[OptCode] = None
    cis_part: Optional[Literal["labour", "materials", "none"]] = None
    withdrawal_id: Optional[OptCode] = None
    delivery_cost_ref: Optional[OptCode] = None


class AllocationIn(_In):
    bill_id: Ref
    amount: Money


class QuoteLineIn(_In):
    line_no: LineNo
    unit_price: Price
    unit_code: Optional[Code] = None
    pricing_quantity: Optional[Whole] = None
    delivery_days: int = Field(default=0, ge=0, le=3650)


class RfqLineIn(_In):
    product_id: Optional[Code] = None
    description: Optional[Name] = None
    quantity: Qty
    unit_code: Code


class AgreementLineIn(_In):
    product_id: Code
    unit_code: Optional[Code] = None
    target_quantity: Qty = Decimal(0)
    unit_price: Price
    pricing_unit_code: Optional[Code] = None
    pricing_quantity: Optional[Whole] = None


class ScheduleLineIn(_In):
    line_no: LineNo
    delivery_date: IsoDate
    quantity: Qty
    unit_code: Optional[Code] = None


class QuotaIn(_In):
    bp_id: int = Field(ge=1, le=2 ** 63 - 1)
    quota_pct: Pct
    base_quantity: Qty = Decimal(0)
    max_quantity: Optional[Qty] = None


class ChangeIn(_In):
    line_no: LineNo
    quantity: Qty


class ProvisionIn(_In):
    component_no: Whole
    quantity: Qty
    reason: Optional[Reason] = None