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