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