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)]