dodil/erp-brick-inventory
DODIL ERP brick: inventory
src/brick_inventory/domain/values.py
"""The vocabulary of stock: stock types, location kinds, bounds, and the exact arithmetic of quantities and money.
Everything here is plain Python. A quantity is a `Decimal` with at most four places (the ledger column is
NUMERIC(19,4)); a conversion is an exact `Fraction`, never a float, and one that does not fit four places is refused,
never rounded. Money is rounded half up, once.
"""
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal, localcontext
from fractions import Fraction
from typing import Optional
from brick_inventory.domain.errors import BadInput
UNRESTRICTED, QUALITY, BLOCKED = "unrestricted", "quality", "blocked"
STOCK_TYPES = (UNRESTRICTED, QUALITY, BLOCKED)
OURS = "" # the owner of stock that is ours (a claim key is never NULL)
NO_LOT = ""
# Location kinds. Stock sits in: internal, transit, consignment_customer, subcontractor. The rest are the world.
HOLDING = ("internal", "transit", "consignment_customer", "subcontractor")
VIRTUAL = ("supplier", "customer", "loss", "transit", "opening", "consumption", "production")
VALUED = HOLDING # stock here is the company's (unless an owner says it is a supplier's)
LOCATION_KINDS = ("view", "internal", "supplier", "customer", "loss", "transit", "opening", "consumption", "production",
"consignment_customer", "subcontractor")
COMPANY_VIRTUALS = ("SUPPLIER", "CUSTOMER", "LOSS", "TRANSIT", "OPENING", "CONSUMPTION", "PRODUCTION") # v6: PRODUCTION, where output comes from
QUANTITY_PLACES = 4
QUANTITY_LIMIT = Decimal(10) ** 12
MONEY_LIMIT = Decimal(10) ** 15
ZERO = Decimal(0)
Q4 = Decimal("0.0001")
Q2 = Decimal("0.01")
def dec(value) -> Decimal:
"""A Decimal from text, an int or a Decimal; a float is never accepted."""
if isinstance(value, float):
raise BadInput("a quantity or an amount is exact: give it as text such as \"0.75\", not a float")
if isinstance(value, Decimal):
return value
try:
return Decimal(str(value).strip())
except Exception: # noqa: BLE001 — InvalidOperation, ValueError
raise BadInput(f"{value!r} is not a number") from None
def places_of(d: Decimal) -> int:
return max(0, -d.as_tuple().exponent)
def check_quantity(value, what: str = "a quantity", *, decimals: Optional[int] = None, positive: bool = True) -> Decimal:
"""A finite quantity below 10^12 with no more places than `decimals` (and than the ledger's four)."""
d = dec(value)
if not d.is_finite():
raise BadInput(f"{what} is a finite number")
if positive and d <= 0:
raise BadInput(f"{what} is more than zero")
if not positive and d < 0:
raise BadInput(f"{what} is zero or more")
if abs(d) >= QUANTITY_LIMIT:
raise BadInput(f"{what} is below 1,000,000,000,000")
limit = QUANTITY_PLACES if decimals is None else min(decimals, QUANTITY_PLACES)
if places_of(d) > limit:
raise BadInput(f"{what} {d} has more than {limit} decimal places")
return d
def check_amount(value, what: str = "an amount", *, places: int = 4, signed: bool = False) -> Decimal:
d = dec(value)
if not d.is_finite():
raise BadInput(f"{what} is a finite number")
if not signed and d < 0:
raise BadInput(f"{what} is zero or more")
if abs(d) >= MONEY_LIMIT:
raise BadInput(f"{what} is below 1,000,000,000,000,000")
if places_of(d) > places:
raise BadInput(f"{what} {d} has more than {places} decimal places")
return d
def round_half_up(value, places: int = 2) -> Decimal:
"""Half up, exactly (a Decimal, or a Fraction of two Decimals' arithmetic)."""
if isinstance(value, Fraction):
scaled = value * 10 ** places
n, d = scaled.numerator, scaled.denominator
whole = (2 * abs(n) + d) // (2 * d)
digits = str(whole).rjust(places + 1, "0")
sign = "-" if n < 0 and whole else ""
return Decimal(f"{sign}{digits[:-places]}.{digits[-places:]}" if places else f"{sign}{digits}")
with localcontext() as ctx:
ctx.prec = 80
return Decimal(value).quantize(Decimal(1).scaleb(-places), rounding=ROUND_HALF_UP)
def q4(value) -> Decimal:
return round_half_up(Decimal(value), 4)
def money(value) -> Decimal:
return round_half_up(Decimal(value), 2)
def fraction(value) -> Fraction:
return Fraction(value if not isinstance(value, str) else Decimal(value.strip()))
@dataclass(frozen=True)
class Converted:
"""A document quantity in the product's base unit, the exact factor kept beside it."""
quantity: Decimal
unit_code: str
numerator: int
denominator: int
base: Decimal # the ledger quantity, exact, at most four places
base_unit_code: str
def converted(quantity: Decimal, unit_code: str, base_unit_code: str, exact: str) -> Converted:
"""`exact` is product's "n/d": the exact value of `quantity` in the base unit. The ledger holds it only when it fits
four places; otherwise it is refused with a reason, never rounded."""
try:
base_fraction = Fraction(exact)
except (ValueError, ZeroDivisionError):
raise BadInput(f"product answered {exact!r}, which is not an exact quantity") from None
scaled = base_fraction * 10 ** QUANTITY_PLACES
if scaled.denominator != 1:
raise BadInput(f"{quantity} {unit_code} is {exact} {base_unit_code}, which does not fit four decimal places "
"exactly: count it in a smaller unit (the ledger never rounds a quantity)")
base = Decimal(scaled.numerator).scaleb(-QUANTITY_PLACES)
if abs(base) >= QUANTITY_LIMIT:
raise BadInput("a quantity is below 1,000,000,000,000 in the base unit")
factor = base_fraction / fraction(quantity)
return Converted(quantity, unit_code, factor.numerator, factor.denominator, base, base_unit_code)
def times_factor(quantity: Decimal, numerator: int, denominator: int) -> Fraction:
return fraction(quantity) * Fraction(numerator, denominator)
def to_base_exact(quantity: Decimal, numerator: int, denominator: int) -> Decimal:
"""quantity x numerator / denominator, exact in four places or refused (no float anywhere)."""
f = times_factor(quantity, numerator, denominator) * 10 ** QUANTITY_PLACES
if f.denominator != 1:
raise BadInput(f"{quantity} x {numerator}/{denominator} does not fit four decimal places exactly")
return Decimal(f.numerator).scaleb(-QUANTITY_PLACES)
def cost_in_fractions(unit_cost: Decimal, pricing_numerator: int, pricing_denominator: int, pricing_quantity: Decimal,
quantity_base: Decimal) -> Decimal:
"""A receipt line's value: round_half_up(quantity_base x pricing_denominator / pricing_numerator x unit_cost /
pricing_quantity, 2) - rounded once, the same number purchasing netted for the line received in full."""
f = fraction(quantity_base) * Fraction(pricing_denominator, pricing_numerator) * fraction(unit_cost) / fraction(pricing_quantity)
return round_half_up(f, 2)