food-market/app/ui/main_window.py

343 lines
13 KiB
Python
Raw Permalink Normal View History

"""Главное окно: боковое меню, экраны, автосохранение и фоновая синхронизация.
Окно заодно служит контекстом для страниц: они берут отсюда базу, пересчитанный
отчёт и валюту, а после любой правки зовут ctx.changed().
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from PySide6.QtCore import QThread, QTimer, Qt, Signal
from PySide6.QtWidgets import (
QLabel,
QListWidget,
QListWidgetItem,
QMainWindow,
QMessageBox,
QStackedWidget,
QWidget,
QHBoxLayout,
)
from .. import gitsync, ledger, paths
from ..gitsync import Diverged, GitError, GitSync
from ..storage import Vault
from . import theme
from . import widgets as w
from .page_batches import BatchesPage
from .page_dashboard import DashboardPage
from .page_debts import DebtsPage
from .page_journal import JournalPage
from .page_products import ProductsPage
from .page_sales import SalesPage
from .page_settings import SettingsPage
Раздел статистики Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы, годы, товары и люди. У каждого график и таблица с итоговой строкой. Главное решение — две базы расчёта одновременно. Смешивать «когда продал» и «когда получил деньги» нельзя: ответы расходятся, как только появляется хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по своим датам. У разреза «Периоды» основание третье, привязка к партии, потому что вопрос там другой: покрыл ли я её к сроку. Количество нигде не сводится в одно число — только по единицам, «1,5 л · 4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям. График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24 корзин на график не помещается, и про срез написано над ним — молча показанная часть выглядела бы как всё. Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел ломал половину проверок. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
from .page_stats import StatsPage
AUTOSAVE_MS = 2000
CLOSE_SYNC_WAIT_MS = 20_000
class SyncTask(QThread):
"""Один прогон синхронизации в фоне.
Git блокирует надолго (сеть, scrypt тут ни при чём), а замерзший интерфейс
на каждом часовом тике худшее, что может сделать фоновая задача.
"""
done = Signal(object, str, bool) # результат, текст ошибки, был ли конфликт
def __init__(self, sync: GitSync, remote_copy: Path, parent=None):
super().__init__(parent)
self.sync = sync
self.remote_copy = remote_copy
def run(self) -> None:
try:
self.done.emit(self.sync.sync(self.remote_copy), "", False)
except Diverged as exc:
self.done.emit(None, str(exc), True)
except GitError as exc:
self.done.emit(None, str(exc), False)
except Exception as exc: # noqa: BLE001 — фон не должен ронять приложение
self.done.emit(None, f"Непредвиденная ошибка синхронизации: {exc}", False)
class MainWindow(QMainWindow):
def __init__(self, vault: Vault):
super().__init__()
self.vault = vault
self.report = ledger.build(vault.doc)
self.conflict_message: str | None = None
self._sync_task: SyncTask | None = None
self._syncing = False
self._sync_text = "не синхронизировалось"
self._saved_revision = vault.revision
self.setWindowTitle("Булочная")
self.resize(1180, 760)
self._build_ui()
self._build_status_bar()
self._start_timers()
self.refresh_all()
# Первый заход в сеть — сразу: если с другой машины что-то прилетело,
# лучше узнать об этом до того, как начнёшь вводить данные.
QTimer.singleShot(600, lambda: self.sync_now(quiet=True))
# --- сборка ---
def _build_ui(self) -> None:
central = QWidget()
layout = QHBoxLayout(central)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.sidebar = QListWidget()
self.sidebar.setObjectName("sidebar")
self.sidebar.setFixedWidth(196)
self.sidebar.setFocusPolicy(Qt.NoFocus)
layout.addWidget(self.sidebar)
self.stack = QStackedWidget()
layout.addWidget(self.stack, 1)
self.pages = [
("Сводка", DashboardPage(self)),
("Закупки", BatchesPage(self)),
("Продажи", SalesPage(self)),
("Долги", DebtsPage(self)),
("Товары", ProductsPage(self)),
Раздел статистики Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы, годы, товары и люди. У каждого график и таблица с итоговой строкой. Главное решение — две базы расчёта одновременно. Смешивать «когда продал» и «когда получил деньги» нельзя: ответы расходятся, как только появляется хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по своим датам. У разреза «Периоды» основание третье, привязка к партии, потому что вопрос там другой: покрыл ли я её к сроку. Количество нигде не сводится в одно число — только по единицам, «1,5 л · 4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям. График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24 корзин на график не помещается, и про срез написано над ним — молча показанная часть выглядела бы как всё. Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел ломал половину проверок. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
("Статистика", StatsPage(self)),
("Журнал", JournalPage(self)),
("Настройки", SettingsPage(self)),
]
for title, page in self.pages:
self.sidebar.addItem(QListWidgetItem(title))
self.stack.addWidget(page)
self.sidebar.currentRowChanged.connect(self._on_page_changed)
self.sidebar.setCurrentRow(0)
self.setCentralWidget(central)
def _build_status_bar(self) -> None:
self.status_label = QLabel("")
self.status_label.setStyleSheet(f"color: {theme.TEXT_DIM};")
bar = self.statusBar()
bar.addWidget(self.status_label, 1)
bar.addPermanentWidget(w.button("Синхронизировать", on_click=self.sync_now))
def _start_timers(self) -> None:
self.autosave_timer = QTimer(self)
self.autosave_timer.timeout.connect(self._autosave)
self.autosave_timer.start(AUTOSAVE_MS)
self.sync_timer = QTimer(self)
self.sync_timer.timeout.connect(lambda: self.sync_now(quiet=True))
self.restart_sync_timer()
def restart_sync_timer(self) -> None:
git = self.vault.doc.settings.git
self.sync_timer.stop()
if git.enabled and git.remote_url:
self.sync_timer.start(max(1, git.auto_push_minutes) * 60_000)
# --- контекст для страниц ---
@property
def currency(self) -> str:
return self.vault.doc.settings.currency
def changed(self) -> None:
"""Позвать после любой правки базы."""
self.report = ledger.build(self.vault.doc)
self.refresh_all()
def refresh_all(self) -> None:
for _, page in self.pages:
page.refresh()
self._update_status()
def _on_page_changed(self, index: int) -> None:
self.stack.setCurrentIndex(index)
# Настройки и сводка показывают состояние синхронизации, которое могло
# измениться в фоне, пока смотрели другой экран.
self.pages[index][1].refresh()
def sync_status_text(self) -> str:
return self._sync_text
def _git(self) -> GitSync:
"""Git всегда работает над папкой данных, а не над корнем приложения.
Единственная точка создания GitSync: если exe лежит внутри клона
с исходниками, ошибка в этом месте заставила бы приложение
распоряжаться репозиторием кода переписать ему origin и запушить
исходники в репозиторий данных.
"""
return GitSync(paths.data_dir(), paths.vault_rel_posix(), self.vault.doc.settings.git)
# --- сохранение ---
def _autosave(self) -> None:
if self._syncing or self.vault.revision == self._saved_revision:
return
self.save_now()
def save_now(self) -> bool:
try:
written = self.vault.save()
except OSError as exc:
self.status_label.setText(f"Не удалось сохранить базу: {exc}")
return False
self._saved_revision = self.vault.revision
return written
# --- синхронизация ---
def sync_now(self, quiet: bool = False) -> None:
if self._syncing:
return
git = self.vault.doc.settings.git
if not git.remote_url:
if not quiet:
QMessageBox.information(
self, "Не настроено", "Укажи адрес репозитория в настройках."
)
return
if gitsync.git_executable() is None:
self._sync_text = "git не установлен — синхронизация недоступна"
self._update_status()
if not quiet:
QMessageBox.warning(self, "Нет git", self._sync_text)
return
if self.conflict_message and quiet:
# Пока конфликт не разрешён, автопуш стоит: иначе мы бы либо
# затирали чужое, либо каждый час показывали одну и ту же ошибку.
return
self.save_now()
self._syncing = True
self._sync_text = "синхронизация…"
self._update_status()
sync = self._git()
self._sync_task = SyncTask(sync, paths.remote_copy_path(), self)
self._sync_task.done.connect(lambda *args: self._on_sync_done(*args, quiet=quiet))
self._sync_task.start()
def _on_sync_done(self, result, error: str, conflict: bool, quiet: bool = True) -> None:
self._syncing = False
stamp = datetime.now().strftime("%H:%M")
if conflict:
self.conflict_message = error
self._sync_text = f"{stamp} — расхождение версий, автопуш остановлен"
self._update_status()
self.refresh_all()
QMessageBox.warning(self, "Версии разошлись", error)
return
if error:
self._sync_text = f"{stamp} — ошибка: {error.splitlines()[0][:120]}"
self._update_status()
if not quiet:
QMessageBox.warning(self, "Синхронизация не удалась", error)
return
self.conflict_message = None
if result is not None and result.pulled:
try:
self.vault.reload()
self._saved_revision = self.vault.revision
except Exception as exc: # noqa: BLE001
QMessageBox.critical(
self,
"Не удалось прочитать версию с сервера",
f"Файл подтянут, но открыть его не вышло: {exc}\n\n"
"Скорее всего, база с другой машины шифровалась другим паролем.",
)
self._sync_text = f"{stamp}{result.message if result else 'готово'}"
self.changed()
def _update_status(self) -> None:
git = self.vault.doc.settings.git
mode = (
f"каждые {git.auto_push_minutes} мин"
if git.enabled and git.remote_url
else "автопуш выключен"
)
dirty = " • есть несохранённые изменения" if self.vault.revision != self._saved_revision else ""
self.status_label.setText(f"{self._sync_text}{mode}{dirty}")
# --- разрешение конфликта ---
def adopt_remote_version(self, _copy_path: Path) -> None:
sync = self._git()
try:
sync.reset_to_remote()
self.vault.reload()
except (GitError, OSError) as exc:
QMessageBox.critical(self, "Не получилось", str(exc))
return
except Exception as exc: # noqa: BLE001
QMessageBox.critical(
self,
"Версия с сервера не открывается",
f"{exc}\n\nВероятно, у неё другой пароль.",
)
return
self._saved_revision = self.vault.revision
self.conflict_message = None
self._sync_text = "принята версия с сервера"
self.changed()
def force_push_local(self) -> None:
self.save_now()
sync = self._git()
try:
sync.force_push()
except GitError as exc:
QMessageBox.critical(self, "Не получилось", str(exc))
return
self.conflict_message = None
self._sync_text = "сервер перезаписан локальной версией"
self.changed()
# --- закрытие ---
def closeEvent(self, event) -> None:
self.autosave_timer.stop()
self.sync_timer.stop()
self.save_now()
git = self.vault.doc.settings.git
if not (git.enabled and git.remote_url) or self.conflict_message:
event.accept()
return
if gitsync.git_executable() is None:
event.accept()
return
if self._sync_task is not None and self._sync_task.isRunning():
self._sync_task.wait(CLOSE_SYNC_WAIT_MS)
event.accept()
return
# Последний пуш на выходе, с потолком по времени: висеть на закрытии
# из-за пропавшей сети приложение не должно.
self.status_label.setText("Отправляю базу на сервер…")
sync = self._git()
task = SyncTask(sync, paths.remote_copy_path(), self)
task.start()
task.wait(CLOSE_SYNC_WAIT_MS)
event.accept()