dodil/erp-brick-purchasing
DODIL ERP brick: purchasing
src/brick_purchasing/domain/values.py
"""Words, settings and exact arithmetic the rules share.
Quantities and prices are EXACT: every conversion is a `Fraction` (numerator / denominator), and rounding happens once, where an amount
is shown or stored (`round_half_up`), never through a float (module.yaml invariants v3).
"""
import re
from dataclasses import dataclass
from datetime import date
from decimal import Decimal, InvalidOperation
from fractions import Fraction
from typing import Optional
from erp_platform.money import minor_unit
from brick_purchasing.domain.errors import Invalid
D = Decimal
ZERO = D(0)
EPOCH = "1970-01-01"
EVERY = "*" # a limit, strategy or source row for every category
REQUEST_STATES = ("draft", "awaiting_approval", "approved", "refused", "ordered", "closed", "cancelled")
ORDER_STATES = ("draft", "to_approve", "approved", "confirmed", "closed", "cancelled")
BILL_STATES = ("draft", "recorded", "approved", "disputed", "cancelled")
BILL_KINDS = ("bill", "credit", "down_payment_invoice")
ASSIGNMENTS = ("stock", "cost_centre", "asset", "project")
ITEM_CATEGORIES = ("standard", "consignment", "subcontract")
KINDS = ("price", "quantity", "tax", "date", "amount", "delivery_cost", "small_difference")
TOLERANCE_KEYS = ("PP", "DQ", "AN", "AP", "ST", "KW", "LA", "LD", "BD", "TX")
CIS_PARTS = ("labour", "materials", "none")
PAYMENT_FORMATS = ("pain.001.001.03", "pain.001.001.09", "bacs_std18", "csv")
# Money and quantity bounds: every value is checked before it can overflow NUMERIC (module.yaml invariants).
MONEY_MAX = D("9999999999999999.99") # NUMERIC(18,2): 16 integer digits
RATE_MAX = D("999999999999999.9999") # NUMERIC(19,4): 15 integer digits
QTY_MAX = D("9999999999999.999999") # NUMERIC(19,6): 13 integer digits
def num(v, field: str = "value") -> D:
"""A finite Decimal from a string, int or Decimal; a float is refused (it cannot hold most decimals exactly)."""
if isinstance(v, float) or isinstance(v, bool):
raise Invalid(f"{field} must be a decimal string, not a float or a boolean")
try:
d = v if isinstance(v, D) else D(str(v).strip())
except (InvalidOperation, ValueError):
raise Invalid(f"{field} {v!r} is not a number") from None
if not d.is_finite():
raise Invalid(f"{field} must be a finite number")
return d
def bounded(v, field: str, *, places: int, limit: D, negative: bool = False) -> D:
"""A number with at most `places` decimals and an absolute value below the column's limit: else a 422 naming the field."""
d = num(v, field)
if not negative and d < 0:
raise Invalid(f"{field} cannot be negative")
if (-d.normalize().as_tuple().exponent) > places:
raise Invalid(f"{field} {d} has more than {places} decimal places")
if abs(d) > limit:
raise Invalid(f"{field} {d} is too large (at most {limit})")
return d
def money_in(v, field: str = "amount", negative: bool = False) -> D:
return bounded(v, field, places=3, limit=MONEY_MAX, negative=negative)
def price_in(v, field: str = "unit_price", negative: bool = False) -> D:
return bounded(v, field, places=4, limit=RATE_MAX, negative=negative)
def qty_in(v, field: str = "quantity", negative: bool = False) -> D:
return bounded(v, field, places=6, limit=QTY_MAX, negative=negative)
def pct_in(v, field: str = "percent") -> D:
d = bounded(v, field, places=4, limit=D(100))
return d
def fr(v) -> Fraction:
"""An exact Fraction from a Decimal, int, str ('2/5') or Fraction — never a float."""
if isinstance(v, Fraction):
return v
if isinstance(v, float):
raise Invalid("an exact quantity cannot come from a float")
if isinstance(v, str) and "/" in v:
n, _, d = v.partition("/")
return Fraction(int(n), int(d))
return Fraction(D(str(v)))
def round_half_up(value, places: int) -> D:
"""A Fraction (or Decimal) rounded half up to `places` decimals: the one rounding."""
f = fr(value)
scale = 10 ** places
n = f * scale
q, r = divmod(abs(n.numerator), n.denominator)
if 2 * r >= n.denominator:
q += 1
q = q if n >= 0 else -q
return D(q).scaleb(-places)
def money_round(value, currency: str) -> D:
"""An exact amount rounded half up to the currency's minor unit."""
return round_half_up(value, minor_unit(currency))
def as_dec(f: Fraction, places: int = 6) -> D:
"""A Fraction shown as a Decimal (rounded half up), for a quantity column."""
return round_half_up(f, places)
def exact_str(f: Fraction) -> str:
return f"{f.numerator}/{f.denominator}"
def conv_in(numerator, denominator, field: str = "conversion") -> tuple:
n, d = int(numerator or 0), int(denominator or 0)
if n <= 0 or d <= 0:
raise Invalid(f"{field}: numerator and denominator are whole numbers above zero")
return n, d
def base_of(quantity, numerator: int, denominator: int) -> Fraction:
"""A quantity in the line's unit, in the base unit: quantity x numerator / denominator (1 PK24 = 24/1 bottles)."""
return fr(quantity) * Fraction(numerator, denominator)
def priced_of(quantity_base: Fraction, pricing_numerator: int, pricing_denominator: int) -> Fraction:
"""The quantity in the PRICING unit: quantity_base x pricing_denominator / pricing_numerator (10 bars = 25 KGM at 2/5)."""
return quantity_base * Fraction(pricing_denominator, pricing_numerator)
def line_net(quantity_priced: Fraction, unit_price, pricing_quantity: int, currency: str) -> D:
"""net = quantity_priced x unit_price / pricing_quantity, rounded once (half up) to the currency's minor unit."""
return money_round(quantity_priced * fr(unit_price) / int(pricing_quantity or 1), currency)
# ── dates ───────────────────────────────────────────────────────────────────────────────────────
def parse_day(v, field: str = "date") -> Optional[date]:
if v in (None, ""):
return None
if isinstance(v, date):
return v
try:
return date.fromisoformat(str(v).strip()[:10])
except ValueError:
raise Invalid(f"{field} is a date like 2026-11-01") from None
def add_months(d: date, months: int) -> date:
y, m = divmod(d.month - 1 + months, 12)
year, month = d.year + y, m + 1
import calendar
return date(year, month, min(d.day, calendar.monthrange(year, month)[1]))
def render_number(fmt: str, year: int, seq: int) -> str:
"""'PO-{yyyy}-{seq:4}' -> PO-2026-0042 (the sequence padded to 4 digits)."""
out = fmt.replace("{yyyy}", f"{year:04d}")
return re.sub(r"\{seq(?::(\d+))?\}", lambda m: str(seq).zfill(int(m.group(1) or 0)), out)
# ── settings (module.yaml config_rows) ────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class SettingDef:
key: str
kind: str # decimal | str | int | bool | mode
default: object
changes: str
control: bool = False # purchasing:settings:controls — never held by whoever it controls
choices: tuple = ()
low: Optional[float] = None
high: Optional[float] = None
def _s(key, kind, default, changes, control=False, choices=(), low=None, high=None):
return SettingDef(key, kind, default, changes, control, choices, low, high)
SETTINGS = [
_s("price_tolerance_pct", "decimal", "2", "a bill line's unit price may exceed the order's by this percent (the STANDARD group's PP upper)", True, low=0, high=100),
_s("price_tolerance_amount", "decimal", "0", "or by this amount per line in the bill currency (0: no amount limit); the LARGER applies", True, low=0, high=1e15),
_s("qty_tolerance_pct", "decimal", "0", "a bill may claim this percent more than received (the STANDARD group's DQ upper)", True, low=0, high=100),
_s("tax_tolerance_amount", "decimal", "0.05", "the supplier's stated VAT may differ from the computed VAT by this much (the STANDARD group's TX)", True, low=0, high=1e15),
_s("match_mode_goods", "mode", "three_way", "three_way | two_way for goods lines", True, ("three_way", "two_way")),
_s("match_mode_services", "mode", "two_way", "two_way | three_way for service lines", True, ("two_way", "three_way")),
_s("order_approval_threshold", "decimal", "0", "a direct order above this needs an approver; 0 = every direct order", True, low=0, high=1e15),
_s("order_number_format", "str", "PO-{yyyy}-{seq:4}", "stamped at confirm_purchase_order", True),
_s("bill_number_format", "str", "BILL-{yyyy}-{seq:5}", "our reference stamped at approve_bill", True),
_s("rfq_number_format", "str", "RFQ-{yyyy}-{seq:4}", "stamped at send_rfq", True),
_s("agreement_number_format", "str", "AG-{yyyy}-{seq:4}", "stamped at approve_agreement", True),
_s("down_payment_number_format", "str", "DP-{yyyy}-{seq:4}", "stamped at request_down_payment", True),
_s("self_billing_number_format", "str", "SB-{yyyy}-{seq:5}", "our self-billing series (never reused)", True),
_s("payment_file_format", "mode", "pain.001.001.03", "the bank's import format", True, PAYMENT_FORMATS),
_s("bacs_service_user_number", "str", "", "the six-digit BACS service user number the bank issued; empty = no BACS file", True),
_s("bacs_cycle_working_days", "int", 3, "working days between the file and the pay date", True, low=0, high=30),
_s("self_billing_max_months", "int", 12, "the longest a self-billing agreement may run before it is renewed", True, low=1, high=120),
_s("business_timezone", "str", "Europe/London", "the IANA zone in which 'today' is counted (ONE value per bucket)", True),
_s("ers_grouping", "mode", "per_supplier", "per_supplier | per_receipt: how ERS groups receipts into self-billed invoices", False, ("per_supplier", "per_receipt")),
_s("ers_daily", "bool", False, "the clock worker settles ERS daily, besides the clerk's run"),
_s("evaluation_grace_days", "int", 2, "a receipt this many days after expected_on still counts as on time", False, low=0, high=365),
_s("firm_zone_days", "int", 7, "default firm zone of a scheduling agreement", False, low=0, high=365),
_s("reclaim_vat", "bool", True, "false: VAT is part of the cost and no recoverable element is posted"),
_s("receipt_needs_location", "bool", True, "with inventory installed, a goods receipt line needs a location"),
_s("ai.ai_read_bill.mode", "mode", "suggest", "off | suggest", False, ("off", "suggest")),
_s("ai.ai_check_bill.mode", "mode", "suggest", "off | suggest", False, ("off", "suggest")),
_s("ai.ai_flag_bank_change.mode", "mode", "suggest", "off | suggest", False, ("off", "suggest")),
]
SETTING_DEFS = {s.key: s for s in SETTINGS}
CONTROL_KEYS = frozenset(s.key for s in SETTINGS if s.control)
SETTING_DEFAULTS = {s.key: s.default for s in SETTINGS}
def check_setting(key: str, value):
"""The value, validated against the setting's kind and bounds; Invalid names the setting."""
s = SETTING_DEFS.get(key)
if s is None:
raise Invalid(f"there is no setting {key!r}")
if s.kind == "bool":
if not isinstance(value, bool):
raise Invalid(f"{key} is true or false")
return value
if s.kind == "int":
if isinstance(value, bool) or not isinstance(value, int):
raise Invalid(f"{key} is a whole number")
if (s.low is not None and value < s.low) or (s.high is not None and value > s.high):
raise Invalid(f"{key} is between {s.low} and {s.high}")
return value
if s.kind == "decimal":
d = num(value, key)
if (s.low is not None and d < D(str(s.low))) or (s.high is not None and d > D(str(s.high))):
raise Invalid(f"{key} is between {s.low} and {s.high}")
if -d.as_tuple().exponent > 4:
raise Invalid(f"{key} has at most 4 decimal places")
return str(d)
if not isinstance(value, str):
raise Invalid(f"{key} is text")
if s.kind == "mode" and value not in s.choices:
raise Invalid(f"{key} is one of {', '.join(s.choices)}")
if key == "business_timezone":
from zoneinfo import ZoneInfo
try:
ZoneInfo(value)
except Exception:
raise Invalid(f"{value!r} is not an IANA time zone such as Europe/London") from None
if key.endswith("_number_format") and "{seq" not in value:
raise Invalid(f"{key} needs a {{seq:N}} part such as PO-{{yyyy}}-{{seq:4}}")
if key == "bacs_service_user_number" and value and not re.fullmatch(r"\d{6}", value):
raise Invalid("the BACS service user number is six digits")
if len(value) > 200:
raise Invalid(f"{key} is at most 200 characters")
return value
MONEY_LIMIT = Decimal("9999999999999999.995") # NUMERIC(18,2): 16 integer digits
def fits_money(value) -> bool:
return abs(Decimal(value)) < MONEY_LIMIT