dodil/erp-brick-product

src/brick_product/domain/rounding.py

"""A rounding rule (PRD-30, PRD-32): a minimum and a multiple, in one unit. Product ANSWERS whether a quantity meets it and the
nearest quantities either side; it enforces nothing — the buying or selling brick decides what an unmet rule does."""
from dataclasses import dataclass
from decimal import Decimal
from fractions import Fraction
from typing import Optional

from brick_product.domain.errors import RuleBroken
from brick_product.domain.units import round_half_up

ROLES = ("purchase", "sales", "issue", "production", "warehouse", "delivery")
VERB = {"purchase": "bought", "sales": "sold", "issue": "issued", "production": "produced", "warehouse": "stored",
        "delivery": "delivered"}


def fmt(v) -> str:
    """A quantity as plain digits: 80, 0.5, 4/3 as 1.333333 (six places, half up). Never an exponent."""
    f = Fraction(v)
    if f.denominator == 1:
        return str(f.numerator)
    d = f.denominator
    while d % 2 == 0:
        d //= 2
    while d % 5 == 0:
        d //= 5
    if d == 1:                                           # a terminating decimal: exact
        places = 1
        while (f * 10 ** places).denominator != 1:
            places += 1
        return str(round_half_up(f, places))
    return str(round_half_up(f, 6))


@dataclass(frozen=True)
class Verdict:
    met: bool
    next_down: Optional[Fraction]
    next_up: Optional[Fraction]
    reason: str


def check_rule(min_quantity, multiple) -> None:
    for what, v in (("the minimum", min_quantity), ("the multiple", multiple)):
        if v is not None:
            if v <= 0:
                raise RuleBroken(f"{what} is more than zero")
            if v >= Decimal("1e12") or v.as_tuple().exponent < -6:
                raise RuleBroken(f"{what} is below 1,000,000,000,000 with at most 6 decimal places")
    if min_quantity is None and multiple is None:
        raise RuleBroken("give a minimum, a multiple or both")


def judge(product_id: str, role: str, unit: str, q: Fraction, min_quantity, multiple) -> Verdict:
    """`q` is the asked quantity in the rule's own unit, exact. Unmet: the nearest allowed quantity below (None when there is
    none) and above."""
    mn = None if min_quantity is None else Fraction(min_quantity)
    mu = None if multiple is None else Fraction(multiple)
    ok_min = mn is None or q >= mn
    ok_mul = mu is None or (q / mu).denominator == 1
    if ok_min and ok_mul:
        return Verdict(True, None, None, "")
    if mu is not None:
        down = Fraction((q / mu).__floor__()) * mu
        up = down + mu if down != q else down
        lo = mn if mn is not None else Fraction(0)
        if up < lo:
            up = Fraction(-((-lo) // mu)) * mu             # the first multiple at or above the minimum
        if down < lo or down <= 0:
            down = None
    else:
        down, up = None, mn
    need = []
    if mu is not None:
        need.append(f"in multiples of {fmt(mu)} {unit}")
    if mn is not None and mn != mu:
        need.append(f"at least {fmt(mn)} {unit}")
    return Verdict(False, down, up, f"{product_id} is {VERB[role]} {' and '.join(need)}")