"""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