dodil/erp-brick-sales-order

DODIL ERP brick: sales order

src/brick_sales_order/domain/values.py

"""Value objects: lifecycle states, the fixed vocabularies, money and quantity arithmetic, and the settings.

Stored as their plain strings; naming them here makes a typo a NameError, not a new state.
"""
import re
from decimal import ROUND_HALF_UP, Decimal
from enum import Enum

from brick_sales_order.domain.errors import OutOfBounds


class SalesOrderState(str, Enum):
    DRAFT = "draft"
    CONFIRMED = "confirmed"
    DONE = "done"
    CANCELLED = "cancelled"


class InvoicingPolicy(str, Enum):
    ORDERED = "ordered"
    DELIVERED = "delivered"


class DeliveryKind(str, Enum):
    DELIVERY = "delivery"
    CORRECTION = "correction"
    OPENING = "opening"


class InvoiceRequestKind(str, Enum):
    REGULAR = "regular"
    DEPOSIT = "deposit"
    FINAL = "final"
    CREDIT = "credit"            # a credit note asked of invoicing (a return, a credit memo request, an unused deposit)
    DEBIT = "debit"              # a debit memo's draft


class SuggestionDecision(str, Enum):
    ACCEPTED = "accepted"
    CHANGED = "changed"
    REJECTED = "rejected"
    OPEN = "open"


class RevisionStatus(str, Enum):
    PENDING = "pending"
    APPLIED = "applied"
    REJECTED = "rejected"


# billing kinds that invoicing bills from a schedule (SO-20), not from an invoice request
REPEATING = ("recurring", "metered", "instalment")
RETURN_TYPES = ("RETURNS", "CONS_RETURN")          # order types that follow the return_order workflow: a credit against the original invoice
MEMO_TYPES = ("CREDIT_REQ", "DEBIT_REQ")           # money going back or forward: approved by a second person
DEDUCTION_MODES = ("proportional", "full_first", "manual")

ZERO = Decimal("0")
MONEY_LIMIT = Decimal("1e16")
# The controls on order takers (permission so:settings:controls): an owner's, never held by whoever they control.
CONTROL_KEYS = frozenset({"free_of_charge_limit", "confirm_on_acceptance", "amendment_needs_approval", "lock_done_orders", "business_timezone", "delivery_multiple_mode", "rebate_accrual_mode",
                          "ai.ai_order_from_po.mode", "ai.ai_check_po.mode"})
AI_KEYS = {"ai_order_from_po": "ai.ai_order_from_po.mode", "ai_check_po": "ai.ai_check_po.mode"}

# (key, kind, default, what it changes, choices) — module.yaml config_rows
SETTINGS = (
    ("order_number_format", "str", "SO-{yyyy}-{seq:4}", "the order number stamped at confirmation", ()),
    ("number_restarts_yearly", "bool", True, "whether the order number restarts each year", ()),
    ("confirm_on_acceptance", "bool", False, "whether an accepted quote's order is confirmed at once instead of waiting as a draft for a person (SO-01); so:settings:controls", ()),
    ("amendment_needs_approval", "bool", True, "whether amending a confirmed order waits for so:order:approve by someone other than the amender (SO-05); so:settings:controls", ()),
    ("default_deposit_pct", "int", 0, "the deposit percentage a new order asks for; a customer's policy overrides it (SO-09)", ()),
    ("lock_done_orders", "bool", True, "whether a fully delivered and invoiced order locks (SO-13); so:settings:controls", ()),
    ("business_timezone", "str", "Europe/London", "the IANA time zone in which 'today' is counted: no order, delivery or dated row may be dated against it; so:settings:controls", ()),
    ("delivery_multiple_mode", "mode", "warn", "what a line off its product's delivery multiple does at confirm and amend (SO-28): warn confirms it and shows the reason, block refuses it; so:settings:controls", ("warn", "block")),
    ("contract_creates_order", "bool", False, "where contracts are signed: false bills an active contract directly through invoicing's contract adapter; true raises a draft order from it instead", ()),
    ("rebate_accrual_mode", "str", "", "post | report: unset = post when the ledger is installed, else a monthly report for the accountant (SO-33); so:settings:controls", ()),
    ("rebate_accounts", "str", "", "the ledger accounts per company for rebate_expense and accrued_rebates, as JSON {\"company_code\": \"UK01\", \"rebate_expense\": 6100, \"accrued_rebates\": 2300}; unset: the ledger's own purposes (SO-33)", ()),
    ("company_country", "str", "GB", "the company's own country (ISO 3166): a ship-to outside it is an export order, whose delivery terms the incompletion rules ask for (SO-38)", ()),
    ("default_warehouse_id", "str", "", "the warehouse that delivers a line that names none, for promised dates and reservations (SO-35); unset: availability is not checked", ()),
    ("pick_pack_days", "int", 1, "days between stock being available and goods leaving, for promised dates (SO-35), unless inventory answers its own", ()),
    ("reschedule_mode", "mode", "later_only", "later_only | both: whether the daily re-promise also moves dates earlier on its own for quantities not yet promised (SO-36)", ("later_only", "both")),
    ("return_credit_on", "mode", "inspection", "receipt | inspection: when a return is credited, unless its reason says otherwise (SO-41)", ("receipt", "inspection")),
    ("free_of_charge_limit", "str", "250.00", "the statistical value of a free-of-charge order above which a second person approves (SO-43); so:settings:controls", ()),
    ("default_deposit_deduction_mode", "mode", "proportional", "proportional | full_first | manual: how invoices take deposits off (SO-47); a customer's policy overrides it", DEDUCTION_MODES),
    ("ai.ai_order_from_po.mode", "mode", "off", "a customer's PO by email becomes a checked draft in seconds instead of ten minutes of typing: off or suggest; so:settings:controls", ("off", "suggest")),
    ("ai.ai_check_po.mode", "mode", "off", "a PO that quietly changes a price or a quantity is caught before confirmation, not at invoicing: off or suggest; so:settings:controls", ("off", "suggest")),
)
SETTING_KINDS = {k: (kind, choices) for k, kind, _, _, choices in SETTINGS}
SETTING_DEFAULTS = {k: d for k, _, d, _, _ in SETTINGS}
EPOCH = "1970-01-01"      # the seeded default of every setting: the only dated row that may start in the past


def money(x) -> Decimal:
    """Money to the minor unit, half up (NUMERIC(18,2)). An amount the column cannot hold is OutOfBounds (a 422), never an overflow."""
    d = Decimal(x)
    if not d.is_finite() or abs(d) >= MONEY_LIMIT:
        raise OutOfBounds(f"an amount of {d:.2f} is beyond what an order holds (below 10,000,000,000,000,000.00): check the price, the rate and the quantity"
                          if d.is_finite() else "an amount is a finite number")
    return d.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)


def num(x, places: int = 0) -> str:
    """A number as a person reads it: 12.0000 -> '12', 24 -> '24.00' with places=2."""
    d = Decimal(x)
    d = d.quantize(Decimal(1)) if d == d.to_integral() else d.normalize()
    if -d.as_tuple().exponent < places:
        d = d.quantize(Decimal(1).scaleb(-places))
    return format(d, "f")


def rate4(x) -> Decimal:
    return Decimal(x).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)


def render_number(fmt: str, year: int, seq: int) -> str:
    """'SO-{yyyy}-{seq:4}' -> 'SO-2026-0001'."""
    out = fmt.replace("{yyyy}", f"{year:04d}")
    return re.sub(r"\{seq:(\d+)\}", lambda m: f"{seq:0{int(m.group(1))}d}", out).replace("{seq}", str(seq))