dodil/erp-brick-sales-order
DODIL ERP brick: sales order
src/brick_sales_order/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 and a quantity are exact decimals with up to four places, given as strings such as "120.00"."""
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Annotated, Any, Literal, Optional
from erp_platform.types import IsoDate, Name, Note, text
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, WithJsonSchema
Code = text(64, min_length=1)
Currency = text(3) # the use case says why a code is not an ISO 4217 code
Day = text(32) # a date such as 2026-11-01; the use case says what is wrong with one that is not
Reason = text(500, min_length=1)
Ref = text(100, min_length=1)
LineNo = Annotated[int, Field(ge=1, le=9999)]
Offset = Annotated[int, Field(ge=0, le=1_000_000)]
def _decimal(v: Any, what: str, places: int) -> 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.as_tuple().exponent < -places:
raise ValueError(f"{what} has at most {places} decimal places")
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 more than zero and below 1,000,000,000,000")
return d
def _qty0(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 _rate(v: Any) -> Decimal:
d = _decimal(v, "a price", 4)
if not (Decimal(0) <= d < Decimal("1e14")):
raise ValueError("a price is zero or more, and below 100,000,000,000,000")
return d
def _pct(v: Any) -> Decimal:
d = _decimal(v, "a percentage", 4)
if not (Decimal(0) <= d <= Decimal(100)):
raise ValueError("a percentage is between 0 and 100")
return d
def _money(v: Any) -> Decimal:
d = _decimal(v, "an amount", 2)
if not (Decimal(0) < d < Decimal("1e16")):
raise ValueError("an amount is more than zero")
return d
def _exact(pattern: str, description: str):
return WithJsonSchema({"type": "string", "pattern": pattern, "description": description})
Qty = Annotated[Decimal, BeforeValidator(_qty), _exact(r"^[0-9]{1,12}(\.[0-9]{1,4})?$", "a quantity, an exact decimal (\"12\")")]
Qty0 = Annotated[Decimal, BeforeValidator(_qty0), _exact(r"^[0-9]{1,12}(\.[0-9]{1,4})?$", "a quantity that may be zero, an exact decimal (\"0\")")]
Rate = Annotated[Decimal, BeforeValidator(_rate), _exact(r"^[0-9]{1,14}(\.[0-9]{1,4})?$", "a price per unit, an exact decimal (\"120.00\")")]
Pct = Annotated[Decimal, BeforeValidator(_pct), _exact(r"^[0-9]{1,3}(\.[0-9]{1,4})?$", "a percentage (\"50\" is 50%)")]
Amt = Annotated[Decimal, BeforeValidator(_money), _exact(r"^[0-9]{1,16}(\.[0-9]{1,2})?$", "an amount of money, two places (\"5000.00\")")]
def day(v: Optional[str], what: str = "a date") -> Optional[date]:
if v in (None, ""):
return None
try:
return date.fromisoformat(v)
except ValueError:
raise ValueError(f"{what} is a date like 2026-11-01") from None
class _In(BaseModel):
model_config = ConfigDict(extra="forbid")
class MilestoneIn(_In):
"""One stage of a milestone line (SO-46): its share of the line, the event that releases it (confirmation releases it at once), and what the
invoice calls it ("on order"); an event with a date is released from that day."""
pct: Pct
event: Code = "confirmation"
name: Optional[text(120)] = None
due_on: Optional[IsoDate] = None
class DeductionIn(_In):
"""What the biller takes off one deposit on this invoice (deduction mode manual)."""
deposit_invoice_id: Ref
amount: Amt
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 LineIn(_In):
"""A line to order: the product, how many, and in which unit (default: the product's sales unit). The price comes
from the product module, never from the caller."""
product_id: Code
quantity: Qty
unit_code: Optional[Code] = None
tax_code: Optional[Code] = None # only where the tax module is not installed: tax is entered per line
tax_rate_pct: Optional[Pct] = None
usage: Optional[Literal["normal", "drop_ship"]] = "normal" # drop_ship: the supplier sends it to the customer (item category THIRD_PARTY)
# what a return, a memo request or a consignment movement refers to (the header's refs are the default for every line)
ref_sales_order_id: Optional[Ref] = None
ref_line_no: Optional[LineNo] = None
ref_delivery_ref: Optional[Ref] = None
ref_invoice_id: Optional[Ref] = None
ref_invoice_line_no: Optional[LineNo] = None
unit_price: Optional[Rate] = None # ONLY a credit or debit memo request states its amount; every other price comes from the product module
description: Optional[text(500)] = None # likewise (a memo request says what it is for)
milestones: Optional[list[MilestoneIn]] = Field(default=None, max_length=50) # a milestone line is billed by value (SO-46)
instalments: Optional[Annotated[int, Field(ge=2, le=240)]] = None # equal instalments of the line's total, a schedule at invoicing
class PricedFromIn(BaseModel):
"""product's PRD-20 answer a quote line was priced from: the price list, the rule and the row."""
model_config = ConfigDict(extra="ignore")
price_list_id: Code
rule_seq: Optional[int] = None
row: Optional[Any] = None
class QuoteLineIn(BaseModel):
"""One line of the accepted quote version, as brick-quote's get_quote answers it. Extra fields are ignored."""
model_config = ConfigDict(extra="ignore")
line_no: LineNo
product_id: Code
description: Optional[text(500)] = ""
quantity: Qty
list_price: Rate
unit_price: Rate
discount_pct: Optional[Pct] = Decimal(0)
unit_code: Optional[Code] = None
pricing_quantity: Optional[int] = Field(default=1, ge=1, le=10**9)
pricing_unit_code: Optional[Code] = None
priced_from: Optional[PricedFromIn] = None # absent = not priced by product (SO-01 refuses it: priced_by_product)
priced_on: Optional[IsoDate] = None
class ScaleTierIn(_In):
"""One tier of a condition record's scale: from this quantity (in the base unit) the tier's rate applies instead of the plain rate."""
from_quantity: Qty
rate: Rate
class RebateTierIn(_In):
"""One tier of a rebate scale: from this much invoiced net, the customer earns this percentage."""
from_value: Annotated[Decimal, BeforeValidator(lambda v: _decimal(v, "an amount", 2)), _exact(r"^[0-9]{1,16}(\.[0-9]{1,2})?$", "invoiced net in money (\"50000.00\")")]
rate_pct: Pct
class ChangeIn(_In):
line_no: LineNo
quantity: Optional[Qty] = None
unit_code: Optional[Code] = None
class DecisionIn(_In):
"""A decision on one item of a suggestion: 'line:0', 'po_number' or 'diff:1'."""
item: text(40, min_length=1)
action: Literal["accept", "change", "reject"]
product_id: Optional[Code] = None
unit_code: Optional[Code] = None
quantity: Optional[Qty] = None
class SuggestedLineIn(_In):
product_id: Code
quantity: Qty
unit_code: Optional[Code] = None
confidence: Optional[Pct] = None
class DifferenceIn(_In):
kind: Literal["price", "quantity", "terms", "delivery_date", "other"] = "other"
detail: text(1000, min_length=1)
po_text: Optional[text(1000)] = ""
line_no: Optional[LineNo] = None
class PoSuggestionIn(_In):
"""What the AI step read from the PO: matched lines, the PO number, differences from the quote."""
lines: list[SuggestedLineIn] = Field(default_factory=list, max_length=200)
po_number: Optional[Ref] = None
differences: list[DifferenceIn] = Field(default_factory=list, max_length=200)
__all__ = ["MilestoneIn", "DeductionIn", "PricedFromIn", "Amt", "ChangeIn", "Code", "Currency", "Day", "DecisionIn", "DifferenceIn", "IsoDate", "LineIn", "LineNo", "Name", "Note",
"Offset", "Pct", "PoSuggestionIn", "Qty", "Qty0", "RebateTierIn", "ScaleTierIn", "QuoteLineIn", "Rate", "Reason", "Ref", "SuggestedLineIn", "day"]
class TransitIn(BaseModel):
"""Days a ship-to is from one warehouse: the promised date is the availability date plus pick-and-pack plus these."""
model_config = ConfigDict(extra="forbid")
warehouse_id: Code
days: Annotated[int, Field(ge=0, le=365)]