food-market/app/ui/page_settings.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

363 lines
14 KiB
Python
Raw Permalink 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 datetime import date, timedelta
from PySide6.QtWidgets import (
QCheckBox,
QDialog,
QFileDialog,
QFormLayout,
QGroupBox,
QInputDialog,
QLabel,
QLineEdit,
QMessageBox,
QScrollArea,
QSpinBox,
QVBoxLayout,
QWidget,
)
from .. import gitsync, journal, paths
from ..storage import Vault
from . import theme
from . import widgets as w
class ChangePasswordDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Смена пароля")
self.setMinimumWidth(380)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 16, 20, 16)
layout.setSpacing(10)
form = QFormLayout()
self.current = QLineEdit()
self.current.setEchoMode(QLineEdit.Password)
form.addRow("Текущий", self.current)
self.new = QLineEdit()
self.new.setEchoMode(QLineEdit.Password)
form.addRow("Новый", self.new)
self.confirm = QLineEdit()
self.confirm.setEchoMode(QLineEdit.Password)
form.addRow("Ещё раз", self.confirm)
layout.addLayout(form)
self.error = QLabel("")
self.error.setStyleSheet(f"color: {theme.DANGER};")
self.error.setWordWrap(True)
layout.addWidget(self.error)
layout.addWidget(
w.label(
"Файл будет перешифрован целиком. Старые копии в истории git "
"останутся зашифрованными прежним паролем.",
"dim",
)
)
layout.itemAt(layout.count() - 1).widget().setWordWrap(True)
layout.addWidget(
w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сменить", "primary", self.accept))
)
class SettingsPage(QWidget):
def __init__(self, ctx, parent=None):
super().__init__(parent)
self.ctx = ctx
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
outer.addWidget(scroll)
body = QWidget()
layout = QVBoxLayout(body)
layout.setContentsMargins(20, 18, 20, 18)
layout.setSpacing(14)
scroll.setWidget(body)
layout.addWidget(w.heading("Настройки"))
layout.addWidget(self._git_group())
layout.addWidget(self._conflict_group())
layout.addWidget(self._general_group())
layout.addWidget(self._maintenance_group())
layout.addStretch(1)
# --- группы ---
def _git_group(self) -> QGroupBox:
box = QGroupBox("Синхронизация с GitLab")
layout = QVBoxLayout(box)
layout.setSpacing(8)
form = QFormLayout()
form.setSpacing(8)
self.remote = QLineEdit()
self.remote.setPlaceholderText("https://gt.ser.gay/kizya/food-market.git")
form.addRow("Репозиторий", self.remote)
self.token = QLineEdit()
self.token.setEchoMode(QLineEdit.Password)
self.token.setPlaceholderText("оставь пустым, чтобы не менять")
form.addRow("Токен", self.token)
self.branch = QLineEdit()
form.addRow("Ветка", self.branch)
self.interval = QSpinBox()
self.interval.setRange(5, 24 * 60)
self.interval.setSuffix(" мин")
form.addRow("Пушить каждые", self.interval)
self.enabled = QCheckBox("Синхронизировать автоматически")
form.addRow("", self.enabled)
self.squash = QCheckBox("Один коммит на день")
self.squash.setToolTip(
"Дельта-сжатие на зашифрованном файле не работает: каждый коммит — "
"это полная копия базы. Схлопывание сбивает рост репозитория "
"примерно с 60 до 13 МБ в год. Взамен откат через git возможен "
"на границу суток, а не на любой час — внутри дня историю показывает журнал."
)
form.addRow("", self.squash)
layout.addLayout(form)
self.git_status = w.label("", "dim")
self.git_status.setWordWrap(True)
layout.addWidget(self.git_status)
layout.addWidget(
w.row(
w.button("Сохранить", "primary", self.save_git),
w.button("Синхронизировать сейчас", on_click=self.ctx.sync_now),
None,
)
)
layout.addWidget(
w.label(
"Токен хранится внутри зашифрованной базы и не попадает в .git/config. "
"В журнал его значение тоже не пишется.",
"dim",
)
)
layout.itemAt(layout.count() - 1).widget().setWordWrap(True)
return box
def _conflict_group(self) -> QGroupBox:
self.conflict_box = QGroupBox("Расхождение версий")
layout = QVBoxLayout(self.conflict_box)
layout.setSpacing(8)
self.conflict_text = w.label("", "dim")
self.conflict_text.setWordWrap(True)
layout.addWidget(self.conflict_text)
layout.addWidget(
w.row(
w.button("Взять версию с сервера", on_click=self.take_remote),
w.button("Оставить мою и перезаписать сервер", "danger", self.force_local),
None,
)
)
return self.conflict_box
def _general_group(self) -> QGroupBox:
box = QGroupBox("Общее")
form = QFormLayout(box)
form.setSpacing(8)
self.currency = QLineEdit()
self.currency.setMaximumWidth(80)
form.addRow("Валюта", self.currency)
self.period_days = QSpinBox()
self.period_days.setRange(1, 365)
self.period_days.setSuffix(" дн.")
form.addRow("Период по умолчанию", self.period_days)
form.addRow("", w.button("Сохранить", "primary", self.save_general))
return box
def _maintenance_group(self) -> QGroupBox:
box = QGroupBox("База")
layout = QVBoxLayout(box)
layout.setSpacing(8)
self.db_info = w.label("", "dim")
self.db_info.setWordWrap(True)
layout.addWidget(self.db_info)
layout.addWidget(
w.row(
w.button("Сменить пароль", on_click=self.change_password),
w.button("Выгрузить открытый JSON", on_click=self.export_json),
w.button("Обрезать журнал", on_click=self.prune_journal),
None,
)
)
layout.addWidget(
w.label(
"Забытый пароль восстановить нельзя — это единственная страховка. "
"В выгрузке лежит и токен, так что храни её не на общем диске.",
"dim",
)
)
layout.itemAt(layout.count() - 1).widget().setWordWrap(True)
return box
# --- отрисовка ---
def refresh(self) -> None:
settings = self.ctx.vault.doc.settings
git = settings.git
self.remote.setText(git.remote_url)
self.branch.setText(git.branch)
self.interval.setValue(git.auto_push_minutes)
self.enabled.setChecked(git.enabled)
self.squash.setChecked(git.daily_squash)
self.token.setPlaceholderText(
"токен задан, оставь пустым чтобы не менять" if git.token else "токен не задан"
)
self.currency.setText(settings.currency)
self.period_days.setValue(settings.default_period_days)
if gitsync.git_executable() is None:
self.git_status.setText(
"git не найден в системе — синхронизация выключена. "
"Установи Git для Windows, учёт при этом работает как обычно."
)
else:
self.git_status.setText(self.ctx.sync_status_text())
self.conflict_box.setVisible(self.ctx.conflict_message is not None)
if self.ctx.conflict_message:
self.conflict_text.setText(
f"{self.ctx.conflict_message}\n\n"
f"Версия с сервера сохранена в {paths.remote_copy_path().name} "
"рядом с базой. Выбери, какая версия верная."
)
doc = self.ctx.vault.doc
size = self.ctx.vault.path.stat().st_size if self.ctx.vault.path.exists() else 0
self.db_info.setText(
f"{self.ctx.vault.path}\n"
f"{size / 1024:.1f} КБ • товаров {len(doc.products)}"
f"закупок {len(doc.batches)} • продаж {len(doc.sales)}"
f"записей в журнале {len(doc.journal)}"
)
# --- действия ---
def save_git(self) -> None:
changes = {
"remote_url": self.remote.text().strip(),
"branch": self.branch.text().strip() or "main",
"auto_push_minutes": self.interval.value(),
"enabled": self.enabled.isChecked(),
"daily_squash": self.squash.isChecked(),
}
# Пустое поле означает «не трогать», иначе токен стирался бы при
# каждом сохранении настроек.
if self.token.text():
changes["token"] = self.token.text()
journal.update_settings(self.ctx.vault, git=changes)
self.token.clear()
self.ctx.restart_sync_timer()
self.ctx.changed()
def save_general(self) -> None:
journal.update_settings(
self.ctx.vault,
currency=self.currency.text().strip(),
default_period_days=self.period_days.value(),
)
self.ctx.changed()
def change_password(self) -> None:
dialog = ChangePasswordDialog(self)
while dialog.exec() == QDialog.Accepted:
if not self.ctx.vault.check_password(dialog.current.text()):
dialog.error.setText("Текущий пароль неверный.")
continue
if len(dialog.new.text()) < 4:
dialog.error.setText("Новый пароль короче 4 символов.")
continue
if dialog.new.text() != dialog.confirm.text():
dialog.error.setText("Новые пароли не совпадают.")
continue
self.ctx.vault.change_password(dialog.new.text())
QMessageBox.information(self, "Готово", "Пароль изменён, база перешифрована.")
self.ctx.changed()
return
def export_json(self) -> None:
default = paths.data_dir() / f"export-{date.today().isoformat()}.json"
target, _ = QFileDialog.getSaveFileName(
self, "Выгрузить базу в открытый JSON", str(default), "JSON (*.json)"
)
if not target:
return
self.ctx.vault.export_plain_json(target)
QMessageBox.warning(
self,
"Выгружено",
f"Открытая копия сохранена:\n{target}\n\n"
"Файл не зашифрован и содержит токен GitLab. Держи его отдельно.",
)
def prune_journal(self) -> None:
years, ok = QInputDialog.getInt(
self, "Обрезать журнал", "Удалить записи старше, лет:", 3, 1, 50
)
if not ok:
return
cutoff = date.today() - timedelta(days=365 * years)
removed = journal.prune_journal(self.ctx.vault, cutoff)
QMessageBox.information(
self, "Готово", f"Удалено записей: {removed}." if removed else "Удалять нечего."
)
self.ctx.changed()
# --- разрешение конфликта ---
def take_remote(self) -> None:
copy = paths.remote_copy_path()
if not copy.exists():
QMessageBox.warning(self, "Нет файла", "Версия с сервера не найдена.")
return
confirm = QMessageBox.question(
self,
"Взять версию с сервера?",
"Твои локальные изменения будут потеряны. Продолжить?",
)
if confirm != QMessageBox.Yes:
return
self.ctx.adopt_remote_version(copy)
def force_local(self) -> None:
confirm = QMessageBox.question(
self,
"Перезаписать сервер?",
"Версия на сервере будет заменена твоей. Изменения, сделанные "
"на другой машине, пропадут. Продолжить?",
)
if confirm != QMessageBox.Yes:
return
self.ctx.force_push_local()