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