food-records/app/ui/page_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

149 lines
5.5 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 по зашифрованному файлу дать не может: коммиты хранят
непрозрачные снимки, а внутри суток они ещё и схлопываются в один. Здесь видно,
что именно менялось, когда и с какой машины.
"""
from __future__ import annotations
from PySide6.QtWidgets import QComboBox, QLineEdit, QVBoxLayout, QWidget
from ..models import JournalEntry
from . import theme
from . import widgets as w
ENTITY_LABELS = {
"product": "Товары",
"batch": "Закупки",
"sale": "Продажи",
"counterparty": "Контрагенты",
"bakery_payment": "Платежи пекарне",
"settings": "Настройки",
"journal": "Журнал",
}
ACTION_LABELS = {
"product.create": "товар создан",
"product.update": "товар изменён",
"product.price_change": "цены изменены",
"product.archive": "товар в архив",
"product.delete": "товар удалён",
"counterparty.create": "контрагент создан",
"counterparty.update": "контрагент изменён",
"counterparty.delete": "контрагент удалён",
"batch.create": "закупка создана",
"batch.update": "закупка изменена",
"batch.close": "партия закрыта",
"batch.reopen": "партия открыта",
"batch.delete": "закупка удалена",
"sale.create": "продажа создана",
"sale.update": "продажа изменена",
"sale.delete": "продажа удалена",
"sale.payment_add": "принята оплата",
"sale.payment_delete": "оплата удалена",
"bakery_payment.add": "платёж пекарне",
"bakery_payment.delete": "платёж пекарне удалён",
"settings.update": "настройки изменены",
"journal.prune": "журнал обрезан",
}
ACTION_COLORS = {
"delete": theme.DANGER,
"create": theme.OK,
"add": theme.OK,
}
def describe(entry: JournalEntry) -> str:
"""Свернуть изменения в одну строку таблицы."""
parts = []
for change in entry.changes:
if change.old and change.new:
parts.append(f"{change.field}: {change.old}{change.new}")
elif change.new:
parts.append(f"{change.field}: {change.new}")
elif change.old:
parts.append(f"{change.field}: было {change.old}")
return "; ".join(parts)
def _color_for(action: str) -> str:
tail = action.rsplit(".", 1)[-1]
for needle, color in ACTION_COLORS.items():
if needle in tail:
return color
return ""
class JournalPage(QWidget):
def __init__(self, ctx, parent=None):
super().__init__(parent)
self.ctx = ctx
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 18, 20, 18)
layout.setSpacing(12)
layout.addWidget(w.heading("Журнал"))
layout.addWidget(
w.label(
"Что менялось в базе. Git хранит только зашифрованные снимки — "
"подробности есть здесь.",
"dim",
)
)
self.search = QLineEdit()
self.search.setPlaceholderText("Поиск: булка с маком, 30,00, Вася…")
self.search.setClearButtonEnabled(True)
self.search.textChanged.connect(self.refresh)
self.entity = QComboBox()
self.entity.addItem("Все разделы", None)
for key, title in ENTITY_LABELS.items():
self.entity.addItem(title, key)
self.entity.currentIndexChanged.connect(self.refresh)
self.count_label = w.label("", "dim")
layout.addWidget(w.row(self.search, self.entity, None, self.count_label, stretch_at=0))
self.table = w.table(
["Когда", "Машина", "Что произошло", "Объект", "Изменения"], stretch_column=4
)
self.table.setSortingEnabled(False) # журнал всегда в хронологии
layout.addWidget(self.table, 1)
def refresh(self) -> None:
needle = self.search.text().strip().lower()
wanted = self.entity.currentData()
rows = []
total = 0
for entry in reversed(self.ctx.vault.doc.journal):
total += 1
if wanted and entry.entity != wanted:
continue
description = describe(entry)
action = ACTION_LABELS.get(entry.action, entry.action)
haystack = " ".join([action, entry.label, description, entry.host]).lower()
if needle and needle not in haystack:
continue
when = entry.ts.replace("T", " ")[:16]
rows.append(
[
w.text_item(when),
w.text_item(entry.host, theme.MUTED),
w.text_item(action, _color_for(entry.action)),
w.text_item(entry.label),
w.text_item(description),
]
)
w.fill(self.table, rows)
self.count_label.setText(
f"показано {len(rows)} из {total}" if len(rows) != total else f"записей: {total}"
)