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