dodil/erp-brick-product

src/brick_product/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 price per unit is a RATE: up to four decimal places, as an exact string on the wire."""
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Annotated, Any, Literal, Optional, Union

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

Code = text(64, min_length=1)                 # a product code, a list code, a category code
Currency = text(3)                            # the use case says why a code is not an ISO 4217 code (after the gate)
Reason = text(500)
RATE_LIMIT = Decimal("1e14")


def _decimal(v: Any, what: str, places: int) -> Decimal:
    if isinstance(v, bool) or isinstance(v, 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.as_tuple().exponent < -places:
        raise ValueError(f"{what} has at most {places} decimal places")
    return d


def _rate(v: Any) -> Decimal:
    d = _decimal(v, "a price", 4)
    if not (Decimal(0) <= d < RATE_LIMIT):
        raise ValueError("a price is zero or more, and below 100,000,000,000,000")
    return d


def _qty(v: Any) -> Decimal:
    d = _decimal(v, "a quantity", 4)
    if not (Decimal(0) <= d < Decimal("1e12")):
        raise ValueError("a quantity is zero or more, and below 1,000,000,000,000")
    return d


def _measure(v: Any) -> Decimal:
    d = _decimal(v, "a measure", 6)
    if not (Decimal(0) <= d < Decimal("1e12")):
        raise ValueError("a measure is zero or more, and below 1,000,000,000,000")
    return d


def _pct(v: Any) -> Decimal:
    d = _decimal(v, "a percentage", 4)
    if not (Decimal(-99) <= d <= Decimal(1000)):
        raise ValueError("a percentage is between -99 and 1000")
    return d


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


Rate = Annotated[Decimal, BeforeValidator(_rate),
                 _exact(r"^[0-9]{1,14}(\.[0-9]{1,4})?$", "a price per unit, an exact decimal with up to 4 places (\"120.00\")")]
Quantity = Annotated[Decimal, BeforeValidator(_qty),
                     _exact(r"^[0-9]{1,12}(\.[0-9]{1,4})?$", "a quantity, an exact decimal with up to 4 places (\"12\")")]
Measure = Annotated[Decimal, BeforeValidator(_measure),
                    _exact(r"^[0-9]{1,12}(\.[0-9]{1,6})?$", "a weight, volume or length, an exact decimal with up to 6 places (\"19.2\")")]
Pct = Annotated[Decimal, BeforeValidator(_pct),
                _exact(r"^-?[0-9]{1,4}(\.[0-9]{1,4})?$", "a percentage as an exact decimal (\"5\" is 5%)")]


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


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


class BatchLineIn(_In):
    """One line of POST /prices/batch (PRD-41): what the one-line routes would each be asked for it."""
    line_no: Annotated[int, Field(ge=0, le=10 ** 9)]
    product_id: Code
    quantity: Quantity
    unit_code: Optional[Code] = None                  # default: the product's sales unit (as GET /products/{id}/price)
    price: bool = True
    rounding_role: Optional[Literal["delivery", "sales", "purchase", "issue", "production", "warehouse"]] = None
    convert: bool = False                             # the conversion of the quantity from unit_code to unit_code (in_base_unit)


class PriceRowIn(_In):
    """One product's price on a list from a date. A different price needs a later valid_from."""
    product_id: Code
    valid_from: IsoDate
    unit_price: Rate                                 # one price per product and unit from a date; breaks are rules
    valid_to: Optional[IsoDate] = None
    unit_code: Optional[Code] = None                 # the unit the price is per (default: the product's sales unit)
    pricing_quantity: Annotated[int, Field(ge=1, le=100000)] = 1       # v4: the price is per this many of unit_code (2.4500 per 100)


class PriceRuleIn(_In):
    """A price derived from another, at its place (seq) in the list: the first rule that matches wins."""
    seq: Annotated[int, Field(ge=1, le=9999)]
    applies_to: text(10) = "all"                      # product | category | all
    target: Optional[Code] = None                     # the product or category it applies to
    min_quantity: Optional[Quantity] = None           # applies from this quantity (a quantity break)
    valid_from: Optional[IsoDate] = None
    valid_to: Optional[IsoDate] = None
    method: text(12) = "discount_pct"                 # fixed | discount_pct | formula
    discount_pct: Optional[Annotated[Decimal, BeforeValidator(_pct), _exact(r"^[0-9]{1,3}(\.[0-9]{1,4})?$", "percent off, 0-100")]] = None
    fixed_price: Optional[Rate] = None                # the price, for a fixed rule
    surcharge_amount: Optional[Rate] = None           # added per unit, for a formula
    base_list: Optional[Code] = None                  # the list a percentage starts from; empty = this list's own prices
    unit_code: Optional[Code] = None                  # the unit fixed_price / surcharge_amount are stated in (a product's sales unit by default)
    pricing_quantity: Annotated[int, Field(ge=1, le=100000)] = 1       # v4: the quantity fixed_price / surcharge_amount are per


class DiscardIn(_In):
    """A DRAFT price row to throw away (a row that was never published). A live row is never removed."""
    product_id: Code
    valid_from: IsoDate
    unit_code: Optional[Code] = None                 # the unit the row is per (default: the sales unit)


class CatalogueIn(_In):
    """One product of an imported catalogue, with its list price if it has one."""
    product_id: Code
    name: Name
    product_type_code: Optional[Code] = None         # REQUIRED (PRD-35): checked by the use case, so a missing one is a 422 naming the field and the types
    description: Optional[Note] = None
    kind: Optional[text(20)] = None
    base_unit_code: Optional[Code] = None
    category_code: Optional[Code] = None
    tax_category: Optional[Code] = None
    billing_kind: Optional[text(20)] = None
    billing_plan: Optional[text(20)] = None
    invoicing_policy: Optional[text(20)] = None
    cost_amount: Optional[Rate] = None
    unit_price: Optional[Rate] = None
    price_unit_code: Optional[Code] = None           # the unit the list price is per (default: the base unit)
    valid_from: Optional[IsoDate] = None             # the date the list price starts (required with unit_price)


RuleSeq = Annotated[int, Field(ge=1, le=9999)]     # a rule's place in its list


class UnitIn(_In):
    """A unit a product is counted in, and how many BASE units one of it is — an exact ratio: either `numerator` /
    `denominator` ("1 of it = numerator / denominator of the base unit"), or `contains_quantity` of `contains_unit` (a pack of
    24 bottles). A conversion true for every product (kg-g, l-ml) needs neither."""
    unit_code: Code
    numerator: Optional[Annotated[int, Field(ge=1, le=2 ** 63 - 1)]] = None
    denominator: Optional[Annotated[int, Field(ge=1, le=2 ** 63 - 1)]] = None
    contains_unit: Optional[Code] = None
    contains_quantity: Optional[Quantity] = None
    relative_unit_code: Optional[Code] = None   # numerator / denominator are stated in THIS unit, not the base (PK24 = 24 C62 on a template; the bottle is per variant)
    packaging_level: Optional[Literal["each", "inner", "case", "pallet", "none"]] = None
    quantity_decimals: Optional[Annotated[int, Field(ge=0, le=6)]] = None
    einvoice_unit_code: Optional[text(3)] = None          # UN/ECE Rec 20 or 21 code a UBL / Peppol line carries (XPK, XBX …); needed before a document uses a pack
    gtin: Optional[text(14)] = None         # this packaging level's barcode (GS1 GTIN with its check digit)


class AttrValueIn(_In):
    """One value of an attribute (0.75 L) and the short `code` its SKU carries (075); without one, its letters and digits."""
    value: Name
    code: Optional[text(32, min_length=1)] = None


class AttributeIn(_In):
    """One attribute of a template (volume) and its values. `characteristic_code` names the characteristic each variant carries
    the value of (volume 0.75 L), in the `class_code` the template is generated with."""
    name: Name
    characteristic_code: Optional[Code] = None
    values: Annotated[list[AttrValueIn], Field(min_length=1, max_length=100)]


class OdooUomIn(_In):
    """One uom.uom of the Odoo database: its name, and (19) the unit it is relative to with the factor, or (16-18) the factor inside
    its category. `rounding` is Odoo's quantity rounding (0.01, 1)."""
    odoo_id: Annotated[int, Field(ge=1)]
    name: text(100, min_length=1)
    relative_uom_id: Optional[Annotated[int, Field(ge=1)]] = None
    relative_factor: Optional[text(40)] = None
    rounding: Optional[text(20)] = "0.01"


class OdooBarcodeIn(_In):
    """A barcode Odoo holds for a product unit: product.packaging.barcode (16-18) or product.uom.barcode (19)."""
    odoo_id: Optional[Annotated[int, Field(ge=1)]] = None
    uom_id: Optional[Annotated[int, Field(ge=1)]] = None             # the Odoo unit it is on (19) ...
    name: Optional[text(100)] = None                                  # ... or the packaging's name (16-18)
    qty: Optional[text(40)] = None                                    # product.packaging.qty in the product's base unit (16-18)
    barcode: Optional[text(40)] = None


class OdooProductIn(_In):
    odoo_id: Annotated[int, Field(ge=1)]
    default_code: Code
    name: Name
    uom_id: Annotated[int, Field(ge=1)]                               # the product's own unit: its base unit
    type: Optional[text(20)] = None                                   # Odoo product type: consu | product | service (16-18), goods | service | combo (19); REQUIRED (checked by the run)
    detailed_type: Optional[text(20)] = None                          # Odoo 16-17 detailed_type (consu | product | service); wins over `type`
    is_storable: Optional[bool] = None                                # Odoo 18+: a consu / goods product that is tracked in stock
    barcode: Optional[text(40)] = None
    weight: Optional[text(40)] = None
    volume: Optional[text(40)] = None
    uoms: list[OdooBarcodeIn] = []                                    # 19: the units the product is sold in, with their barcodes
    packagings: list[OdooBarcodeIn] = []                              # 16-18: product.packaging


class CharValueIn(_In):
    """One characteristic value: a list item, a number (with its unit, converted exactly to the characteristic's), a date or text.
    A multi-valued characteristic takes a list."""
    characteristic_code: Code
    value: Union[text(200), Annotated[list[text(200)], Field(max_length=50)]]
    unit_code: Optional[Code] = None


class ClassCharIn(_In):
    """A characteristic of a class, and whether a product in the class must have a value for it before it is activated."""
    characteristic_code: Code
    required: bool = False


class StepIn(_In):
    """One step of a rounding profile: round to a multiple of `multiple` once the remainder reaches `round_up_pct` percent of it."""
    multiple: Measure
    round_up_pct: Pct