dodil/erp-brick-product

src/brick_product/domain/units.py

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