food-market/app/journal.py
Claude c8b9f5048b Учёт закупки и перепродажи булок
Десктопное приложение на PySide6. Вся база — один JSON-документ,
зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит
в data/vault.fmdb и раз в час уезжает в этот репозиторий.

Основное:
- партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж
  по партиям и прогресс покрытия к сроку;
- типы выбытия: розница, другу по себестоимости, съел сам, подарок,
  списание — съеденное вычитается из прибыли, за него платить всё равно;
- долги контрагентов с частичными оплатами;
- номенклатура с историей изменения цен;
- журнал изменений внутри базы: git хранит непрозрачные снимки,
  поэтому настоящая история ведётся здесь.

Синхронизация коммитит только путь базы и схлопывает часовые пуши
в один коммит на день через amend + force-with-lease.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:29:53 +03:00

799 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Единственный путь записи в базу.
Ни один экран не меняет документ напрямую — всё идёт через функции этого
модуля. Каждая из них снимает состояние сущности до и после, считает разницу
и дописывает запись в аудит-лог.
Так сделано потому, что git хранит зашифрованные снимки: `git diff` по ним
бесполезен, а после схлопывания коммитов по дням истории внутри суток там нет
вовсе. Журнал — единственное место, где видно «когда я поднял цену на булку
с маком». Если бы запись шла в обход, новый экран однажды забыл бы залогировать
изменение, и дыру заметили бы только когда история понадобится.
Побочный, но важный эффект: правки, ничего не меняющие по существу, журнал
не засоряют — при пустой разнице запись не создаётся и база не помечается
изменённой, то есть лишнего коммита в git тоже не будет.
"""
from __future__ import annotations
import socket
from datetime import date, datetime
from . import money as m
from .models import (
SALE_KIND_LABELS,
BakeryPayment,
Batch,
BatchLine,
Change,
Counterparty,
JournalEntry,
Payment,
PricePoint,
Product,
Sale,
SaleLine,
dump_date,
new_id,
parse_date,
)
# Значения этих настроек в журнал не попадают — только факт изменения.
SECRET_SETTINGS = frozenset({"token"})
class ValidationError(Exception):
"""Действие невозможно: UI показывает текст пользователю как есть."""
def _now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def _host() -> str:
try:
return socket.gethostname()
except OSError:
return "?"
def _record(
vault,
action: str,
entity: str,
entity_id: str,
label: str,
changes: list[Change],
) -> JournalEntry:
entry = JournalEntry(
id=new_id("j"),
ts=_now_iso(),
host=_host(),
action=action,
entity=entity,
entity_id=entity_id,
label=label,
changes=changes,
)
vault.doc.journal.append(entry)
vault.touch()
return entry
def _diff(before: dict[str, str], after: dict[str, str]) -> list[Change]:
keys = list(before) + [k for k in after if k not in before]
return [
Change(field=k, old=before.get(k, ""), new=after.get(k, ""))
for k in keys
if before.get(k, "") != after.get(k, "")
]
def _as_created(flat: dict[str, str]) -> list[Change]:
return [Change(field=k, old="", new=v) for k, v in flat.items() if v]
def _as_deleted(flat: dict[str, str]) -> list[Change]:
return [Change(field=k, old=v, new="") for k, v in flat.items() if v]
# --- Плоские снимки сущностей для журнала ---------------------------------
#
# Значения сразу человекочитаемые: журнал читает человек, а не программа,
# и «30,00 ₽» полезнее, чем «30».
def _flat_product(doc, p: Product) -> dict[str, str]:
return {
"название": p.name,
"единица": p.unit,
"себестоимость": m.fmt_money(p.cost_price, doc.settings.currency),
"цена продажи": m.fmt_money(p.retail_price, doc.settings.currency),
"в архиве": "да" if p.archived else "",
"заметка": p.note,
}
def _flat_counterparty(doc, c: Counterparty) -> dict[str, str]:
return {"имя": c.name, "тип": c.kind, "заметка": c.note}
def _lines_text(doc, lines, price_attr: str) -> str:
cur = doc.settings.currency
return "; ".join(
f"{doc.product_name(x.product_id)} × {m.fmt_qty(x.qty)} "
f"по {m.fmt_money(getattr(x, price_attr), cur)}"
for x in lines
)
def _flat_batch(doc, b: Batch) -> dict[str, str]:
return {
"дата": dump_date(b.date),
"покрыть до": dump_date(b.due_date),
"состав": _lines_text(doc, b.lines, "unit_cost"),
"сумма": m.fmt_money(b.cost_total, doc.settings.currency),
"закрыта": "да" if b.closed else "",
"заметка": b.note,
}
def _flat_sale(doc, s: Sale) -> dict[str, str]:
cur = doc.settings.currency
return {
"дата": dump_date(s.date),
"тип": SALE_KIND_LABELS.get(s.kind, s.kind),
"кому": doc.counterparty_name(s.counterparty_id),
"состав": _lines_text(doc, s.lines, "unit_price"),
"сумма": m.fmt_money(s.total, cur),
"оплачено": m.fmt_money(s.paid, cur),
"заметка": s.note,
}
def _batch_label(b: Batch) -> str:
return f"Партия №{b.number} от {b.date.strftime('%d.%m.%Y')}"
def _sale_label(doc, s: Sale) -> str:
who = doc.counterparty_name(s.counterparty_id)
kind = SALE_KIND_LABELS.get(s.kind, s.kind)
base = f"{kind} от {s.date.strftime('%d.%m.%Y')}"
return f"{base}{who}" if who else base
# --- Номенклатура ---------------------------------------------------------
def create_product(
vault,
name: str,
unit: str = "шт",
cost_price=0,
retail_price=0,
note: str = "",
) -> Product:
name = name.strip()
if not name:
raise ValidationError("У товара должно быть название.")
product = Product(
id=new_id("p"),
name=name,
unit=unit.strip() or "шт",
cost_price=m.money(cost_price),
retail_price=m.money(retail_price),
note=note.strip(),
)
# Стартовая цена — тоже точка истории, иначе первый период остался бы
# без объяснения, откуда взялась цифра.
product.price_history.append(
PricePoint(
date=date.today(),
cost_price=product.cost_price,
retail_price=product.retail_price,
note="создание товара",
)
)
vault.doc.products.append(product)
_record(
vault,
"product.create",
"product",
product.id,
product.name,
_as_created(_flat_product(vault.doc, product)),
)
return product
def update_product(
vault,
product_id: str,
name: str | None = None,
unit: str | None = None,
note: str | None = None,
archived: bool | None = None,
) -> Product:
"""Правка карточки без цен — для цен есть change_prices с историей."""
doc = vault.doc
product = doc.product(product_id)
if product is None:
raise ValidationError("Товар не найден.")
before = _flat_product(doc, product)
if name is not None:
if not name.strip():
raise ValidationError("У товара должно быть название.")
product.name = name.strip()
if unit is not None:
product.unit = unit.strip() or "шт"
if note is not None:
product.note = note.strip()
if archived is not None:
product.archived = bool(archived)
changes = _diff(before, _flat_product(doc, product))
if changes:
action = "product.archive" if archived is not None and archived else "product.update"
_record(vault, action, "product", product.id, product.name, changes)
return product
def change_prices(
vault,
product_id: str,
cost_price,
retail_price,
on_date: date | None = None,
note: str = "",
) -> Product:
"""Поменять цены товара, оставив след в истории цен."""
doc = vault.doc
product = doc.product(product_id)
if product is None:
raise ValidationError("Товар не найден.")
cost_price = m.money(cost_price)
retail_price = m.money(retail_price)
if cost_price < 0 or retail_price < 0:
raise ValidationError("Цена не может быть отрицательной.")
if cost_price == product.cost_price and retail_price == product.retail_price:
return product
before = _flat_product(doc, product)
product.cost_price = cost_price
product.retail_price = retail_price
product.price_history.append(
PricePoint(
date=on_date or date.today(),
cost_price=cost_price,
retail_price=retail_price,
note=note.strip(),
)
)
_record(
vault,
"product.price_change",
"product",
product.id,
product.name,
_diff(before, _flat_product(doc, product)),
)
return product
def delete_product(vault, product_id: str) -> None:
"""Удалить товар, если он нигде не использован.
Удалять использованный нельзя: закупки и продажи ссылаются на него, и
отчёты по закрытым периодам молча превратились бы в «(удалённый товар)».
Для вышедших из оборота булок есть архив.
"""
doc = vault.doc
product = doc.product(product_id)
if product is None:
raise ValidationError("Товар не найден.")
used_in_batches = sum(
1 for b in doc.batches for line in b.lines if line.product_id == product_id
)
used_in_sales = sum(
1 for s in doc.sales for line in s.lines if line.product_id == product_id
)
if used_in_batches or used_in_sales:
raise ValidationError(
f"«{product.name}» используется в закупках ({used_in_batches}) "
f"и продажах ({used_in_sales}), удалить нельзя. "
"Отправь товар в архив — он исчезнет из списков, а история останется."
)
flat = _flat_product(doc, product)
doc.products.remove(product)
_record(vault, "product.delete", "product", product_id, product.name, _as_deleted(flat))
# --- Контрагенты ----------------------------------------------------------
def create_counterparty(vault, name: str, kind: str = "friend", note: str = "") -> Counterparty:
name = name.strip()
if not name:
raise ValidationError("У контрагента должно быть имя.")
cp = Counterparty(id=new_id("c"), name=name, kind=kind, note=note.strip())
vault.doc.counterparties.append(cp)
_record(
vault,
"counterparty.create",
"counterparty",
cp.id,
cp.name,
_as_created(_flat_counterparty(vault.doc, cp)),
)
return cp
def update_counterparty(
vault,
cp_id: str,
name: str | None = None,
kind: str | None = None,
note: str | None = None,
) -> Counterparty:
doc = vault.doc
cp = doc.counterparty(cp_id)
if cp is None:
raise ValidationError("Контрагент не найден.")
before = _flat_counterparty(doc, cp)
if name is not None:
if not name.strip():
raise ValidationError("У контрагента должно быть имя.")
cp.name = name.strip()
if kind is not None:
cp.kind = kind
if note is not None:
cp.note = note.strip()
changes = _diff(before, _flat_counterparty(doc, cp))
if changes:
_record(vault, "counterparty.update", "counterparty", cp.id, cp.name, changes)
return cp
def delete_counterparty(vault, cp_id: str) -> None:
doc = vault.doc
cp = doc.counterparty(cp_id)
if cp is None:
raise ValidationError("Контрагент не найден.")
used = sum(1 for s in doc.sales if s.counterparty_id == cp_id)
if used:
raise ValidationError(
f"На «{cp.name}» ссылаются продажи ({used}), удалить нельзя."
)
flat = _flat_counterparty(doc, cp)
doc.counterparties.remove(cp)
_record(vault, "counterparty.delete", "counterparty", cp_id, cp.name, _as_deleted(flat))
# --- Закупки --------------------------------------------------------------
def _build_batch_lines(doc, lines: list[dict]) -> list[BatchLine]:
result: list[BatchLine] = []
for raw in lines:
product_id = raw.get("product_id")
if not product_id or doc.product(product_id) is None:
raise ValidationError("В закупке есть строка без существующего товара.")
quantity = m.qty(raw.get("qty"))
if quantity <= 0:
raise ValidationError(
f"Количество для «{doc.product_name(product_id)}» должно быть больше нуля."
)
cost = m.money(raw.get("unit_cost"))
if cost < 0:
raise ValidationError("Себестоимость не может быть отрицательной.")
result.append(BatchLine(product_id=product_id, qty=quantity, unit_cost=cost))
if not result:
raise ValidationError("В закупке должна быть хотя бы одна строка.")
return result
def create_batch(
vault,
on_date: date,
due_date: date,
lines: list[dict],
note: str = "",
) -> Batch:
doc = vault.doc
on_date = parse_date(on_date)
due_date = parse_date(due_date)
if due_date < on_date:
raise ValidationError("Дата покрытия не может быть раньше даты закупки.")
batch = Batch(
id=new_id("b"),
number=doc.next_batch_number(),
date=on_date,
due_date=due_date,
lines=_build_batch_lines(doc, lines),
note=note.strip(),
)
doc.batches.append(batch)
_record(
vault,
"batch.create",
"batch",
batch.id,
_batch_label(batch),
_as_created(_flat_batch(doc, batch)),
)
return batch
def update_batch(
vault,
batch_id: str,
on_date: date | None = None,
due_date: date | None = None,
lines: list[dict] | None = None,
note: str | None = None,
) -> Batch:
doc = vault.doc
batch = doc.batch(batch_id)
if batch is None:
raise ValidationError("Партия не найдена.")
before = _flat_batch(doc, batch)
new_date = parse_date(on_date) if on_date is not None else batch.date
new_due = parse_date(due_date) if due_date is not None else batch.due_date
if new_due < new_date:
raise ValidationError("Дата покрытия не может быть раньше даты закупки.")
batch.date = new_date
batch.due_date = new_due
if lines is not None:
batch.lines = _build_batch_lines(doc, lines)
if note is not None:
batch.note = note.strip()
changes = _diff(before, _flat_batch(doc, batch))
if changes:
_record(vault, "batch.update", "batch", batch.id, _batch_label(batch), changes)
return batch
def set_batch_closed(vault, batch_id: str, closed: bool) -> Batch:
doc = vault.doc
batch = doc.batch(batch_id)
if batch is None:
raise ValidationError("Партия не найдена.")
if batch.closed == closed:
return batch
before = _flat_batch(doc, batch)
batch.closed = closed
_record(
vault,
"batch.close" if closed else "batch.reopen",
"batch",
batch.id,
_batch_label(batch),
_diff(before, _flat_batch(doc, batch)),
)
return batch
def delete_batch(vault, batch_id: str) -> None:
doc = vault.doc
batch = doc.batch(batch_id)
if batch is None:
raise ValidationError("Партия не найдена.")
label = _batch_label(batch)
flat = _flat_batch(doc, batch)
doc.batches.remove(batch)
# Платежи, привязанные к удалённой партии, отвязываем, а не удаляем:
# деньги пекарне реально ушли, и терять их из учёта нельзя.
for payment in doc.bakery_payments:
if payment.batch_id == batch_id:
payment.batch_id = None
_record(vault, "batch.delete", "batch", batch_id, label, _as_deleted(flat))
# --- Продажи и выбытие ----------------------------------------------------
def _build_sale_lines(doc, lines: list[dict]) -> list[SaleLine]:
result: list[SaleLine] = []
for raw in lines:
product_id = raw.get("product_id")
if not product_id or doc.product(product_id) is None:
raise ValidationError("В продаже есть строка без существующего товара.")
quantity = m.qty(raw.get("qty"))
if quantity <= 0:
raise ValidationError(
f"Количество для «{doc.product_name(product_id)}» должно быть больше нуля."
)
price = m.money(raw.get("unit_price"))
if price < 0:
raise ValidationError("Цена не может быть отрицательной.")
result.append(SaleLine(product_id=product_id, qty=quantity, unit_price=price))
if not result:
raise ValidationError("В продаже должна быть хотя бы одна строка.")
return result
def create_sale(
vault,
on_date: date,
kind: str,
lines: list[dict],
counterparty_id: str | None = None,
note: str = "",
paid_amount=None,
) -> Sale:
"""Записать выбытие товара.
paid_amount=None означает «оплачено полностью» — самый частый случай.
Явный ноль или частичная сумма создают долг.
"""
doc = vault.doc
sale = Sale(
id=new_id("s"),
date=parse_date(on_date),
kind=kind,
counterparty_id=counterparty_id or None,
lines=_build_sale_lines(doc, lines),
note=note.strip(),
)
if not sale.is_consumption:
amount = sale.total if paid_amount is None else m.money(paid_amount)
if amount < 0:
raise ValidationError("Оплата не может быть отрицательной.")
if amount > 0:
sale.payments.append(Payment(date=sale.date, amount=amount))
doc.sales.append(sale)
_record(
vault,
"sale.create",
"sale",
sale.id,
_sale_label(doc, sale),
_as_created(_flat_sale(doc, sale)),
)
return sale
def update_sale(
vault,
sale_id: str,
on_date: date | None = None,
kind: str | None = None,
lines: list[dict] | None = None,
counterparty_id: str | None = None,
note: str | None = None,
) -> Sale:
"""Правка продажи. Платежи не трогает — для них есть отдельные операции."""
doc = vault.doc
sale = doc.sale(sale_id)
if sale is None:
raise ValidationError("Продажа не найдена.")
before = _flat_sale(doc, sale)
if on_date is not None:
sale.date = parse_date(on_date)
if kind is not None:
sale.kind = kind
if lines is not None:
sale.lines = _build_sale_lines(doc, lines)
if counterparty_id is not None:
sale.counterparty_id = counterparty_id or None
if note is not None:
sale.note = note.strip()
changes = _diff(before, _flat_sale(doc, sale))
if changes:
_record(vault, "sale.update", "sale", sale.id, _sale_label(doc, sale), changes)
return sale
def delete_sale(vault, sale_id: str) -> None:
doc = vault.doc
sale = doc.sale(sale_id)
if sale is None:
raise ValidationError("Продажа не найдена.")
label = _sale_label(doc, sale)
flat = _flat_sale(doc, sale)
doc.sales.remove(sale)
_record(vault, "sale.delete", "sale", sale_id, label, _as_deleted(flat))
def add_sale_payment(vault, sale_id: str, amount, on_date: date | None = None, note: str = "") -> Sale:
doc = vault.doc
sale = doc.sale(sale_id)
if sale is None:
raise ValidationError("Продажа не найдена.")
amount = m.money(amount)
if amount <= 0:
raise ValidationError("Сумма оплаты должна быть больше нуля.")
before = _flat_sale(doc, sale)
sale.payments.append(
Payment(date=parse_date(on_date) if on_date else date.today(), amount=amount, note=note.strip())
)
_record(
vault,
"sale.payment_add",
"sale",
sale.id,
_sale_label(doc, sale),
_diff(before, _flat_sale(doc, sale)),
)
return sale
def delete_sale_payment(vault, sale_id: str, index: int) -> Sale:
doc = vault.doc
sale = doc.sale(sale_id)
if sale is None:
raise ValidationError("Продажа не найдена.")
if not 0 <= index < len(sale.payments):
raise ValidationError("Такой оплаты нет.")
before = _flat_sale(doc, sale)
sale.payments.pop(index)
_record(
vault,
"sale.payment_delete",
"sale",
sale.id,
_sale_label(doc, sale),
_diff(before, _flat_sale(doc, sale)),
)
return sale
# --- Расчёты с пекарней ---------------------------------------------------
def add_bakery_payment(
vault,
amount,
on_date: date | None = None,
batch_id: str | None = None,
note: str = "",
) -> BakeryPayment:
doc = vault.doc
amount = m.money(amount)
if amount <= 0:
raise ValidationError("Сумма платежа должна быть больше нуля.")
if batch_id and doc.batch(batch_id) is None:
raise ValidationError("Партия не найдена.")
payment = BakeryPayment(
id=new_id("bp"),
date=parse_date(on_date) if on_date else date.today(),
amount=amount,
batch_id=batch_id or None,
note=note.strip(),
)
doc.bakery_payments.append(payment)
batch = doc.batch(batch_id) if batch_id else None
_record(
vault,
"bakery_payment.add",
"bakery_payment",
payment.id,
f"Платёж пекарне {m.fmt_money(amount, doc.settings.currency)}",
[
Change("дата", "", dump_date(payment.date)),
Change("сумма", "", m.fmt_money(amount, doc.settings.currency)),
Change("партия", "", _batch_label(batch) if batch else "без привязки"),
Change("заметка", "", payment.note),
],
)
return payment
def delete_bakery_payment(vault, payment_id: str) -> None:
doc = vault.doc
payment = next((p for p in doc.bakery_payments if p.id == payment_id), None)
if payment is None:
raise ValidationError("Платёж не найден.")
doc.bakery_payments.remove(payment)
_record(
vault,
"bakery_payment.delete",
"bakery_payment",
payment_id,
f"Платёж пекарне {m.fmt_money(payment.amount, doc.settings.currency)}",
[
Change("дата", dump_date(payment.date), ""),
Change("сумма", m.fmt_money(payment.amount, doc.settings.currency), ""),
],
)
# --- Настройки ------------------------------------------------------------
def update_settings(vault, currency=None, default_period_days=None, git: dict | None = None):
"""Правка настроек.
Значение токена в журнал не пишется — иначе секрет, ради которого всё
и шифруется, лежал бы открытым текстом внутри той же базы и уезжал бы
в открытый экспорт.
"""
doc = vault.doc
settings = doc.settings
changes: list[Change] = []
if currency is not None and currency != settings.currency:
changes.append(Change("валюта", settings.currency, currency))
settings.currency = currency
if default_period_days is not None and int(default_period_days) != settings.default_period_days:
changes.append(
Change("период по умолчанию", str(settings.default_period_days), str(default_period_days))
)
settings.default_period_days = int(default_period_days)
for key, value in (git or {}).items():
if not hasattr(settings.git, key):
continue
old = getattr(settings.git, key)
if isinstance(old, bool):
value = bool(value)
elif isinstance(old, int) and not isinstance(old, bool):
value = int(value)
if old == value:
continue
setattr(settings.git, key, value)
if key in SECRET_SETTINGS:
changes.append(Change(f"git.{key}", "скрыто", "изменён" if value else "удалён"))
else:
changes.append(Change(f"git.{key}", str(old), str(value)))
if changes:
_record(vault, "settings.update", "settings", "settings", "Настройки", changes)
return settings
# --- Обслуживание журнала -------------------------------------------------
def prune_journal(vault, before: date) -> int:
"""Выбросить записи журнала старше указанной даты. Возвращает их число."""
doc = vault.doc
cutoff = dump_date(parse_date(before))
kept = [e for e in doc.journal if e.ts[:10] >= cutoff]
removed = len(doc.journal) - len(kept)
if not removed:
return 0
doc.journal = kept
_record(
vault,
"journal.prune",
"journal",
"journal",
"Журнал",
[Change("удалено записей старше " + cutoff, "", str(removed))],
)
return removed