food-market/app/ui/main_window.py
Claude 38e9614a25 Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.

Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.

Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.

График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.

Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:33:11 +03:00

343 lines
13 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.

"""Главное окно: боковое меню, экраны, автосохранение и фоновая синхронизация.
Окно заодно служит контекстом для страниц: они берут отсюда базу, пересчитанный
отчёт и валюту, а после любой правки зовут 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
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)),
("Статистика", 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()