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)