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