"""Деньги и количества. Всё считаем в Decimal, а не в float: 0.1 + 0.2 в float даёт 0.30000000000004, и такие копейки накапливаются в отчётах по партиям. В JSON кладём строкой, чтобы точность не терялась при сохранении. """ from __future__ import annotations from decimal import Decimal, InvalidOperation, ROUND_HALF_UP CENTS = Decimal("0.01") QTY_STEP = Decimal("0.001") ZERO = Decimal("0") def money(value) -> Decimal: """Привести что угодно к сумме денег, округлив до копеек.""" return _to_decimal(value).quantize(CENTS, rounding=ROUND_HALF_UP) def qty(value) -> Decimal: """Привести что угодно к количеству. До тысячных — булки штучные, но так же работают вес и объём. """ return _to_decimal(value).quantize(QTY_STEP, rounding=ROUND_HALF_UP) def _to_decimal(value) -> Decimal: if isinstance(value, Decimal): return value if value is None or value == "": return ZERO if isinstance(value, float): # Через str, иначе Decimal(0.1) даст 0.1000000000000000055511151231257827. value = repr(value) try: return Decimal(str(value).replace(",", ".").replace(" ", "").replace(" ", "")) except (InvalidOperation, ValueError) as exc: raise ValueError(f"Не похоже на число: {value!r}") from exc def dumps(value: Decimal) -> str: """Сериализовать сумму для JSON.""" return format(value, "f") def fmt_money(value, currency: str = "₽") -> str: """Отформатировать сумму для показа: 12 345,67 ₽.""" value = money(value) sign = "-" if value < 0 else "" whole, _, frac = format(abs(value), "f").partition(".") groups = [] while len(whole) > 3: groups.insert(0, whole[-3:]) whole = whole[:-3] groups.insert(0, whole) text = f"{sign}{' '.join(groups)},{frac}" # Разделители — обычные пробелы, а не неразрывные: то, что видно на экране, # должно находиться поиском по журналу ровно так, как пользователь это набрал. return f"{text} {currency}" if currency else text def fmt_qty(value) -> str: """Отформатировать количество, пряча незначащие нули: 5, 2,5.""" text = format(qty(value), "f").rstrip("0").rstrip(".") return (text or "0").replace(".", ",")