Десктопное приложение на PySide6. Вся база — один JSON-документ, зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит в data/vault.fmdb и раз в час уезжает в этот репозиторий. Основное: - партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж по партиям и прогресс покрытия к сроку; - типы выбытия: розница, другу по себестоимости, съел сам, подарок, списание — съеденное вычитается из прибыли, за него платить всё равно; - долги контрагентов с частичными оплатами; - номенклатура с историей изменения цен; - журнал изменений внутри базы: git хранит непрозрачные снимки, поэтому настоящая история ведётся здесь. Синхронизация коммитит только путь базы и схлопывает часовые пуши в один коммит на день через amend + force-with-lease. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
69 lines
2.7 KiB
Python
69 lines
2.7 KiB
Python
"""Деньги и количества.
|
||
|
||
Всё считаем в 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(".", ",")
|