"""Units of measure (PRD-23): every conversion is an EXACT rational — a numerator and a denominator, never a decimal
factor — computed with `Fraction` and rounded to a unit's `quantity_decimals` only at the very end.
A product has one BASE unit (the one it is stocked in) and any number of others, each stored as "1 of it = numerator /
denominator of the base unit". 0.75 L bottle of a product counted in LTR is 3/4; a pack of 24 of them is 18/1; a pallet of
80 packs is 1440/1. A conversion true for every product (1 KGM = 1000 GRM) lives in unit_conversion and never in a
product row.
"""
from decimal import Decimal
from fractions import Fraction
from typing import Mapping, Optional
from brick_product.domain import unece
from brick_product.domain.errors import RuleBroken
BIG = 2 ** 63 - 1 # a numerator or a denominator is a BIGINT
def ratio(numerator: int, denominator: int, what: str = "a conversion") -> Fraction:
"""A conversion factor from its two integers. Both must be positive whole numbers that fit a BIGINT."""
for n, label in ((numerator, "numerator"), (denominator, "denominator")):
if isinstance(n, bool) or not isinstance(n, int) or not 1 <= n <= BIG:
raise RuleBroken(f"{what}: the {label} is a whole number of at least 1")
return Fraction(numerator, denominator)
def exact(value) -> Fraction:
"""A decimal string, a Decimal or an int as the exact rational it says. A float is never accepted."""
if isinstance(value, float):
raise RuleBroken("a quantity or a factor is exact: give it as a string such as \"0.75\", not a float")
return Fraction(value if not isinstance(value, str) else Decimal(value.strip()))
def round_half_up(value: Fraction, places: int = 0) -> Decimal:
"""`value` rounded half up to `places` decimals — the one place an exact quantity becomes a decimal."""
scaled = value * 10 ** places
n, d = scaled.numerator, scaled.denominator
whole = (2 * abs(n) + d) // (2 * d)
sign = "-" if n < 0 and whole else ""
text = str(whole).rjust(places + 1, "0") # built from digits, so no decimal context can overflow
return Decimal(f"{sign}{text[:-places]}.{text[-places:]}" if places else f"{sign}{text}")
# The seeded UN/ECE units that measure something, with their size in the smallest unit of their dimension. A pack code the
# customer invents (PK24) is free; one of THESE codes must keep its real meaning: KGM can be a kilogram of a product counted in
# grams, never "25 pieces".
DIMENSIONS = {"KGM": ("mass", 1000), "GRM": ("mass", 1), "LTR": ("volume", 1000), "MLT": ("volume", 1),
"HUR": ("time", 60), "MIN": ("time", 1), "DAY": ("time", 1440), "WEE": ("time", 10080),
"MTR": ("length", 1), "MTK": ("area", 1), "MON": ("month", 1), "ANN": ("year", 1)}
def check_pack_code(unit_code: str, base_unit_code: str, factor: Fraction) -> None:
"""A product row for a UN/ECE Rec 20 MEASURING code (the whole vendored list, not only the seeded ones) is refused unless it is
that code's real conversion to the base unit — which this module can state only for the dimensions it knows."""
if unit_code not in DIMENSIONS:
if unit_code in unece.RESERVED:
raise RuleBroken(f"{unit_code} ({unece.name_of(unit_code)}) is a UN/ECE Recommendation 20 measuring unit: it cannot be "
f"a pack of {base_unit_code}. Give your own pack code (for example PK25) and its e-invoice unit code")
return
dim, size = DIMENSIONS[unit_code]
base_dim, base_size = DIMENSIONS.get(base_unit_code, (None, None))
if base_dim != dim or factor != Fraction(size, base_size):
raise RuleBroken(f"{unit_code} is the UN/ECE code for a unit of {dim}: it can only convert as what it is, not as "
f"{factor.numerator}/{factor.denominator} {base_unit_code}. Give your own pack code (for example "
"PK25) and its e-invoice unit code instead")
def factor_of(unit_code: str, base_unit_code: str, own: Mapping[str, Fraction],
fixed: Mapping[tuple, Fraction]) -> Optional[Fraction]:
"""How many BASE units one `unit_code` is, or None when the product cannot count in it. The base unit is 1; a unit the
product (or its template) names is its own row; otherwise a conversion true for everyone, either way round."""
if unit_code == base_unit_code:
return Fraction(1)
if unit_code in own:
return own[unit_code]
if (unit_code, base_unit_code) in fixed:
return fixed[(unit_code, base_unit_code)]
if (base_unit_code, unit_code) in fixed:
return 1 / fixed[(base_unit_code, unit_code)]
return None
def convert(quantity, from_factor: Fraction, to_factor: Fraction) -> Fraction:
"""`quantity` of a unit worth `from_factor` base units, in a unit worth `to_factor`: exact, unrounded."""
return exact(quantity) * from_factor / to_factor
def fraction_within(value, rounding, limit: int = 10000) -> Fraction:
"""The simplest exact fraction (smallest denominator, up to `limit`) whose decimal agrees with `value` to the unit's `rounding`
(0.01 -> two places): an Odoo float factor 24.0 is 24/1, 0.333333 at 0.01 is 1/3. None within the limit raises RuleBroken."""
v, step = Decimal(str(value)).copy_abs(), Decimal(str(rounding or "0.01"))
places = max(0, -step.normalize().as_tuple().exponent) if step > 0 else 0
q = Decimal(1).scaleb(-places)
want = v.quantize(q, rounding="ROUND_HALF_UP")
exact = Fraction(v)
for d in range(1, limit + 1):
n = round_half_up(exact * d, 0)
if n >= 1 and round_half_up(Fraction(int(n), d), places) == want:
return Fraction(int(n), d)
raise RuleBroken(f"{value} is not an exact fraction within denominator {limit} at the rounding {rounding}")