2026-08-22 21:29:53 +07:00
|
|
|
|
"""Главное окно: боковое меню, экраны, автосохранение и фоновая синхронизация.
|
|
|
|
|
|
|
|
|
|
|
|
Окно заодно служит контекстом для страниц: они берут отсюда базу, пересчитанный
|
|
|
|
|
|
отчёт и валюту, а после любой правки зовут 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
|
2026-08-22 21:29:53 +07:00
|
|
|
|
|
|
|
|
|
|
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)),
|
2026-08-22 21:29:53 +07:00
|
|
|
|
("Журнал", 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
|
|
|
|
|
|
|
2026-08-23 02:30:42 +07:00
|
|
|
|
def _git(self) -> GitSync:
|
|
|
|
|
|
"""Git всегда работает над папкой данных, а не над корнем приложения.
|
|
|
|
|
|
|
|
|
|
|
|
Единственная точка создания GitSync: если exe лежит внутри клона
|
|
|
|
|
|
с исходниками, ошибка в этом месте заставила бы приложение
|
|
|
|
|
|
распоряжаться репозиторием кода — переписать ему origin и запушить
|
|
|
|
|
|
исходники в репозиторий данных.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return GitSync(paths.data_dir(), paths.vault_rel_posix(), self.vault.doc.settings.git)
|
|
|
|
|
|
|
2026-08-22 21:29:53 +07:00
|
|
|
|
# --- сохранение ---
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
2026-08-23 02:30:42 +07:00
|
|
|
|
sync = self._git()
|
2026-08-22 21:29:53 +07:00
|
|
|
|
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:
|
2026-08-23 02:30:42 +07:00
|
|
|
|
sync = self._git()
|
2026-08-22 21:29:53 +07:00
|
|
|
|
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()
|
2026-08-23 02:30:42 +07:00
|
|
|
|
sync = self._git()
|
2026-08-22 21:29:53 +07:00
|
|
|
|
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("Отправляю базу на сервер…")
|
2026-08-23 02:30:42 +07:00
|
|
|
|
sync = self._git()
|
2026-08-22 21:29:53 +07:00
|
|
|
|
task = SyncTask(sync, paths.remote_copy_path(), self)
|
|
|
|
|
|
task.start()
|
|
|
|
|
|
task.wait(CLOSE_SYNC_WAIT_MS)
|
|
|
|
|
|
event.accept()
|