From c8b9f5048b9dd766b7f5aec03939501e8fd14b54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 17:29:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D1=87=D1=91=D1=82=20=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D1=83=D0=BF=D0=BA=D0=B8=20=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B4=D0=B0=D0=B6=D0=B8=20=D0=B1=D1=83=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Десктопное приложение на PySide6. Вся база — один JSON-документ, зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит в data/vault.fmdb и раз в час уезжает в этот репозиторий. Основное: - партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж по партиям и прогресс покрытия к сроку; - типы выбытия: розница, другу по себестоимости, съел сам, подарок, списание — съеденное вычитается из прибыли, за него платить всё равно; - долги контрагентов с частичными оплатами; - номенклатура с историей изменения цен; - журнал изменений внутри базы: git хранит непрозрачные снимки, поэтому настоящая история ведётся здесь. Синхронизация коммитит только путь базы и схлопывает часовые пуши в один коммит на день через amend + force-with-lease. Co-Authored-By: Claude Opus 5 --- .gitattributes | 8 + .gitignore | 20 + .vscode/settings.json | 5 + README.md | 114 ++++++ app/__init__.py | 4 + app/crypto.py | 170 ++++++++ app/gitsync.py | 399 +++++++++++++++++++ app/journal.py | 798 +++++++++++++++++++++++++++++++++++++ app/ledger.py | 485 ++++++++++++++++++++++ app/main.py | 33 ++ app/models.py | 574 ++++++++++++++++++++++++++ app/money.py | 68 ++++ app/paths.py | 42 ++ app/storage.py | 194 +++++++++ app/ui/__init__.py | 1 + app/ui/login.py | 159 ++++++++ app/ui/main_window.py | 332 +++++++++++++++ app/ui/page_batches.py | 363 +++++++++++++++++ app/ui/page_dashboard.py | 205 ++++++++++ app/ui/page_debts.py | 191 +++++++++ app/ui/page_journal.py | 148 +++++++ app/ui/page_products.py | 277 +++++++++++++ app/ui/page_sales.py | 322 +++++++++++++++ app/ui/page_settings.py | 362 +++++++++++++++++ app/ui/theme.py | 181 +++++++++ app/ui/widgets.py | 436 ++++++++++++++++++++ food-market.code-workspace | 12 + food-market.spec | 89 +++++ pytest.ini | 3 + requirements-dev.txt | 3 + requirements.txt | 2 + run.py | 12 + tests/conftest.py | 31 ++ tests/test_crypto.py | 78 ++++ tests/test_gitsync.py | 282 +++++++++++++ tests/test_journal.py | 267 +++++++++++++ tests/test_ledger.py | 314 +++++++++++++++ tests/test_storage.py | 134 +++++++ tests/test_ui_smoke.py | 391 ++++++++++++++++++ 39 files changed, 7509 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/crypto.py create mode 100644 app/gitsync.py create mode 100644 app/journal.py create mode 100644 app/ledger.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/money.py create mode 100644 app/paths.py create mode 100644 app/storage.py create mode 100644 app/ui/__init__.py create mode 100644 app/ui/login.py create mode 100644 app/ui/main_window.py create mode 100644 app/ui/page_batches.py create mode 100644 app/ui/page_dashboard.py create mode 100644 app/ui/page_debts.py create mode 100644 app/ui/page_journal.py create mode 100644 app/ui/page_products.py create mode 100644 app/ui/page_sales.py create mode 100644 app/ui/page_settings.py create mode 100644 app/ui/theme.py create mode 100644 app/ui/widgets.py create mode 100644 food-market.code-workspace create mode 100644 food-market.spec create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 tests/conftest.py create mode 100644 tests/test_crypto.py create mode 100644 tests/test_gitsync.py create mode 100644 tests/test_journal.py create mode 100644 tests/test_ledger.py create mode 100644 tests/test_storage.py create mode 100644 tests/test_ui_smoke.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d71a005 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# База зашифрована. Если git примет её за текст и подставит CRLF, файл +# перестанет расшифровываться. Помечаем явно. +*.fmdb binary +data/vault.fmdb binary + +* text=auto eol=lf +*.png binary +*.ico binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ed4f49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +venv/ + +build/ +dist/ +*.spec.bak + +# Собранный exe весит ~50 МБ. Репозиторий и так растёт от снимков базы — +# бинарник в нём не нужен, он собирается из исходников одной командой. +/food-market.exe + +# Рабочие артефакты базы. Сама база data/vault.fmdb НЕ игнорируется — +# она и есть предмет синхронизации. +*.tmp +data/*.bak +data/vault.remote.fmdb +data/export-*.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c103c2d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "workbench.editorAssociations": { + "*.sprite": "d2rmodding.spriteViewer" + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..416afc9 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# Булочная + +Десктопный учёт закупки и перепродажи булок. Вся база — один файл, +зашифрованный паролем, который раз в час уезжает в этот репозиторий. + +## Что считает + +Булки берутся в пекарне **в долг**: к дедлайну надо вернуть себестоимость +партии, иначе новых не напекут. Наценка остаётся тебе. Часть булок уходит +друзьям по себестоимости, часть съедается — и за них пекарне тоже платить. + +Программа отвечает на главный вопрос: **успеваю ли я собрать себестоимость +конкретной партии до её срока**. + +- **Закупки** — партии с составом, ценами и датой, до которой надо рассчитаться. +- **Продажи** — розница, «другу по себестоимости», «съел сам», подарок, списание. + Цена подставляется по типу. +- **Долги** — кто сколько должен, с частичными оплатами. +- **Товары** — номенклатура с историей изменения цен. +- **Журнал** — что менялось в базе, когда и с какой машины. +- **Сводка** — долг пекарне, дебиторка, прибыль, остатки, ближайший дедлайн. + +Продажи разносятся по партиям методом FIFO. Цены у поставщика фиксированные, +поэтому на себестоимость это не влияет — FIFO нужен только чтобы понимать, +деньги за какую партию уже пришли. + +Прибыль считается как наценка с проданного **минус** себестоимость съеденного +и подаренного: за них пекарне платить всё равно, и покрывается это из маржи. + +## Как запустить + +```bash +pip install -r requirements.txt +python run.py +``` + +Собрать один exe: + +```bash +pip install -r requirements-dev.txt +pyinstaller food-market.spec +``` + +Готовый `dist/food-market.exe` (~50 МБ) положи в корень клона этого +репозитория. Базу он ищет в `data/vault.fmdb` рядом с собой, поэтому достаточно +склонировать репозиторий, положить exe в корень и запустить. + +## Хранение и шифрование + +`data/vault.fmdb` — единственный файл с данными: + +``` +JSON → gzip → AES-256-GCM (ключ: scrypt от пароля) → файл +``` + +Заголовок файла идёт в GCM как AAD, так что подменить соль или параметры KDF +незаметно нельзя. Запись атомарная, предыдущая версия остаётся в `.bak`. + +**Забытый пароль восстановить невозможно.** В настройках есть кнопка выгрузки +открытого JSON — держи такую копию отдельно. В ней лежит и токен GitLab. + +## Синхронизация + +Раз в час (интервал настраивается) приложение сохраняет базу, коммитит +**только** `data/vault.fmdb` и пушит. Незакоммиченные правки исходников в +автокоммит не попадают. + +Токен GitLab хранится внутри зашифрованной базы и подставляется в URL только на +время вызова — в `.git/config` он не пишется и в журнал не попадает. + +### Один коммит на день + +Дельта-сжатие на шифртексте не работает: каждый коммит хранит полную копию +файла. При часовых пушах это ~1800 коммитов и порядка 60 МБ в год. Поэтому в +течение суток приложение дописывает в ту же вершину через `--amend` — выходит +около 13 МБ в год. + +Форс-пуш делается только с `--force-with-lease` и только когда все условия +выполнены: вершина имеет наш формат сообщения, датирована сегодня, трогает ровно +`data/vault.fmdb` и совпадает с тем, что на сервере. Иначе — обычный коммит без +переписывания истории. + +Плата за схлопывание: откатиться через git можно на границу суток, а не на любой +час. Детализацию внутри дня даёт журнал. + +### Работа с двух машин + +Слить два зашифрованных блоба автоматически невозможно. Если версии разошлись, +автопуш останавливается, версия с сервера кладётся рядом как +`data/vault.remote.fmdb`, а выбор предлагается в настройках. Ничего не +затирается молча. + +## Разработка + +```bash +pytest -q +``` + +Модули: + +| файл | зачем | +|---|---| +| `app/crypto.py` | формат файла, scrypt + AES-GCM | +| `app/models.py` | структура документа, деньги в Decimal | +| `app/storage.py` | загрузка, атомарная запись, миграции схемы, хеш «грязности» | +| `app/journal.py` | **единственный путь записи** в базу + аудит-лог | +| `app/ledger.py` | FIFO, покрытие партий, долги, остатки, сводка | +| `app/gitsync.py` | git, схлопывание коммитов, разрешение расхождений | +| `app/ui/` | экраны на PySide6 | + +Главное архитектурное правило: **ни один экран не меняет документ напрямую**. +Всё идёт через функции `app/journal.py`, которые сами считают разницу и +дописывают запись в журнал. Иначе новый экран однажды забыл бы залогировать +изменение, и заметили бы это только когда история понадобится. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..328e688 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,4 @@ +"""Булочная — учёт закупки и перепродажи булок.""" + +APP_NAME = "Булочная" +APP_VERSION = "1.0.0" diff --git a/app/crypto.py b/app/crypto.py new file mode 100644 index 0000000..1923874 --- /dev/null +++ b/app/crypto.py @@ -0,0 +1,170 @@ +"""Формат зашифрованного файла базы (.fmdb). + +Вся база — один JSON-документ. На диск он попадает так: + + json → utf-8 → gzip → AES-256-GCM → файл + +Ключ выводится из пароля через scrypt. Заголовок файла (37 байт) передаётся +в GCM как AAD, поэтому подменить параметры KDF или соль незаметно нельзя — +расшифровка упадёт с InvalidTag так же, как при неверном пароле. + + смещение размер содержимое + 0 4 магия b"FMDB" + 4 1 версия формата + 5 1 id функции выведения ключа (1 = scrypt) + 6 3 параметры scrypt: log2(n), r, p + 9 16 salt + 25 12 nonce + 37 … ciphertext + тег GCM (16 байт) +""" + +from __future__ import annotations + +import gzip +import os +import struct + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.scrypt import Scrypt + +MAGIC = b"FMDB" +FORMAT_VERSION = 1 +KDF_SCRYPT = 1 + +SALT_SIZE = 16 +NONCE_SIZE = 12 +KEY_SIZE = 32 +HEADER_SIZE = 37 + +# n=2**15 — примерно 100 мс на вывод ключа. Достаточно, чтобы перебор паролей +# был дорогим, и незаметно при открытии базы. +SCRYPT_LOG_N = 15 +SCRYPT_R = 8 +SCRYPT_P = 1 + +_HEADER_STRUCT = struct.Struct(f"!4sBBBBB{SALT_SIZE}s{NONCE_SIZE}s") +assert _HEADER_STRUCT.size == HEADER_SIZE + + +class VaultError(Exception): + """Базовая ошибка работы с файлом базы.""" + + +class WrongPassword(VaultError): + """Пароль неверный либо файл повреждён/подделан. + + Намеренно не различаем эти случаи: по тексту ошибки не должно быть видно, + угадана ли часть пароля. + """ + + +class UnsupportedFormat(VaultError): + """Файл создан более новой версией программы либо это вообще не база.""" + + +class Header: + """Разобранный заголовок файла.""" + + __slots__ = ("version", "kdf_id", "log_n", "r", "p", "salt", "nonce", "raw") + + def __init__( + self, + version: int, + kdf_id: int, + log_n: int, + r: int, + p: int, + salt: bytes, + nonce: bytes, + ) -> None: + self.version = version + self.kdf_id = kdf_id + self.log_n = log_n + self.r = r + self.p = p + self.salt = salt + self.nonce = nonce + self.raw = _HEADER_STRUCT.pack( + MAGIC, version, kdf_id, log_n, r, p, salt, nonce + ) + + @classmethod + def new(cls) -> "Header": + """Свежий заголовок со случайными salt и nonce.""" + return cls( + version=FORMAT_VERSION, + kdf_id=KDF_SCRYPT, + log_n=SCRYPT_LOG_N, + r=SCRYPT_R, + p=SCRYPT_P, + salt=os.urandom(SALT_SIZE), + nonce=os.urandom(NONCE_SIZE), + ) + + @classmethod + def parse(cls, blob: bytes) -> "Header": + if len(blob) < HEADER_SIZE: + raise UnsupportedFormat("Файл слишком короткий, это не база.") + + magic, version, kdf_id, log_n, r, p, salt, nonce = _HEADER_STRUCT.unpack( + blob[:HEADER_SIZE] + ) + if magic != MAGIC: + raise UnsupportedFormat("Это не файл базы (неверная сигнатура).") + if version > FORMAT_VERSION: + raise UnsupportedFormat( + f"Файл создан более новой версией программы (формат {version}). " + "Обнови приложение." + ) + if kdf_id != KDF_SCRYPT: + raise UnsupportedFormat(f"Неизвестный способ выведения ключа: {kdf_id}.") + # Верхняя граница защищает от файла, который просит гигабайты памяти. + if not 10 <= log_n <= 22: + raise UnsupportedFormat(f"Недопустимый параметр scrypt: log2(n)={log_n}.") + if not 1 <= r <= 32 or not 1 <= p <= 16: + raise UnsupportedFormat("Недопустимые параметры scrypt.") + + return cls(version, kdf_id, log_n, r, p, salt, nonce) + + +def derive_key(password: str, header: Header) -> bytes: + """Вывести ключ шифрования из пароля по параметрам из заголовка.""" + kdf = Scrypt( + salt=header.salt, + length=KEY_SIZE, + n=2**header.log_n, + r=header.r, + p=header.p, + ) + return kdf.derive(password.encode("utf-8")) + + +def encrypt(plaintext: bytes, password: str) -> bytes: + """Сжать и зашифровать открытые данные, вернуть содержимое файла целиком. + + Каждый вызов берёт новые salt и nonce, поэтому одни и те же данные дают + разные байты. Проверять «изменилось ли что-то» по этому результату нельзя — + для этого storage хранит хеш открытого JSON. + """ + header = Header.new() + key = derive_key(password, header) + packed = gzip.compress(plaintext, compresslevel=9, mtime=0) + ciphertext = AESGCM(key).encrypt(header.nonce, packed, header.raw) + return header.raw + ciphertext + + +def decrypt(blob: bytes, password: str) -> bytes: + """Расшифровать содержимое файла, вернуть открытые данные.""" + header = Header.parse(blob) + key = derive_key(password, header) + try: + packed = AESGCM(key).decrypt(header.nonce, blob[HEADER_SIZE:], header.raw) + except InvalidTag as exc: + raise WrongPassword("Неверный пароль или файл повреждён.") from exc + + try: + return gzip.decompress(packed) + except (OSError, EOFError) as exc: + # Сюда попасть почти невозможно: GCM уже подтвердил целостность. + raise VaultError("Данные расшифровались, но распаковать их не удалось.") from exc diff --git a/app/gitsync.py b/app/gitsync.py new file mode 100644 index 0000000..725f37b --- /dev/null +++ b/app/gitsync.py @@ -0,0 +1,399 @@ +"""Синхронизация зашифрованной базы через системный git. + +Git тут — канал бэкапа и переноса между машинами, а не источник истории: +зашифрованный блоб непрозрачен, `git diff` по нему бесполезен. Историю ведёт +журнал внутри базы (см. journal.py). + +Два решения, определяющие всё остальное: + +1. **Коммитим только путь базы.** Все команды заканчиваются на + `-- data/vault.fmdb`, поэтому незакоммиченные правки исходников никогда не + уедут в автокоммит вместе с данными. + +2. **Один коммит на день.** Дельта-сжатие на шифртексте не работает, каждый + коммит хранит полную копию файла. При часовых пушах это ~1800 коммитов и + порядка 60 МБ в год. Поэтому в течение суток дописываем в ту же вершину + через --amend. Форс — только --force-with-lease и только когда мы точно + знаем, что вершина наша и никем не сдвинута. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from datetime import date +from pathlib import Path +from urllib.parse import quote, urlsplit, urlunsplit + +from .models import GitSettings + +COMMIT_PREFIX = "vault" +COMMIT_RE = re.compile(r"^vault \d{4}-\d{2}-\d{2}$") + +LOCAL_TIMEOUT = 30 +NETWORK_TIMEOUT = 180 + + +class GitError(Exception): + """Любая проблема с git. Текст показывается пользователю как есть.""" + + +class GitNotAvailable(GitError): + pass + + +class Diverged(GitError): + """Локальная и удалённая версии разошлись. + + Слить два зашифрованных блоба автоматически невозможно, поэтому выбор + всегда за пользователем — молча затирать чью-то работу нельзя. + """ + + +@dataclass +class SyncResult: + committed: bool = False + amended: bool = False + pushed: bool = False + pulled: bool = False + message: str = "" + + +def git_executable() -> str | None: + return shutil.which("git") + + +def commit_message(today: date | None = None) -> str: + """Сообщение коммита без единой цифры о бизнесе. + + Дата нужна, чтобы понимать, можно ли дописать в сегодняшнюю вершину. + Количества партий и суммы сюда не попадают: история git может быть видна + тому, у кого нет пароля от базы. + """ + return f"{COMMIT_PREFIX} {(today or date.today()).isoformat()}" + + +def auth_url(remote_url: str, token: str) -> str: + """Подставить токен в URL. + + В .git/config токен не пишем — иначе он лежал бы открытым текстом рядом + с зашифрованной базой, обесценивая всё шифрование. Расплата: на время + вызова URL виден в списке процессов. + """ + if not token or not remote_url.startswith(("http://", "https://")): + return remote_url + + parts = urlsplit(remote_url) + host = parts.netloc.rsplit("@", 1)[-1] + netloc = f"oauth2:{quote(token, safe='')}@{host}" + return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) + + +def scrub(text: str, token: str) -> str: + """Убрать токен из текста перед показом или логированием.""" + if not token: + return text + return text.replace(token, "***").replace(quote(token, safe=""), "***") + + +class GitSync: + """Обёртка над git для одного репозитория с одним синхронизируемым файлом.""" + + def __init__(self, repo_dir: Path, rel_path: str, settings: GitSettings): + self.repo_dir = Path(repo_dir) + self.rel_path = rel_path + self.settings = settings + + # --- запуск git --- + + def _run(self, args: list[str], timeout: int = LOCAL_TIMEOUT, check: bool = True) -> subprocess.CompletedProcess: + exe = git_executable() + if exe is None: + raise GitNotAvailable( + "git не найден. Установи Git для Windows — без него " + "синхронизация не работает, но приложение считает всё локально." + ) + + env = dict(os.environ) + # Без этого git может молча повиснуть на запросе логина в фоновом потоке. + env["GIT_TERMINAL_PROMPT"] = "0" + env["GCM_INTERACTIVE"] = "never" + env["GIT_ASKPASS"] = "" + env["LC_ALL"] = "C" + + kwargs = {} + if sys.platform == "win32": + # Иначе при каждом тике синхронизации мигает окно консоли. + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + + cmd = [exe, "-c", "credential.helper=", "-c", "core.autocrlf=false", *args] + try: + proc = subprocess.run( + cmd, + cwd=self.repo_dir, + env=env, + capture_output=True, + timeout=timeout, + **kwargs, + ) + except subprocess.TimeoutExpired as exc: + raise GitError(f"git не ответил за {timeout} с — проверь сеть.") from exc + + if check and proc.returncode != 0: + stderr = proc.stderr.decode("utf-8", "replace").strip() + raise GitError(scrub(stderr or "неизвестная ошибка git", self.settings.token)) + return proc + + def _out(self, args: list[str], **kwargs) -> str: + return self._run(args, **kwargs).stdout.decode("utf-8", "replace").strip() + + # --- состояние репозитория --- + + @property + def branch(self) -> str: + return self.settings.branch or "main" + + @property + def _remote_ref(self) -> str: + return f"refs/remotes/origin/{self.branch}" + + def is_repo(self) -> bool: + return (self.repo_dir / ".git").exists() + + def head_sha(self) -> str | None: + proc = self._run(["rev-parse", "HEAD"], check=False) + if proc.returncode != 0: + return None # репозиторий без единого коммита + return proc.stdout.decode().strip() + + def remote_sha(self) -> str | None: + proc = self._run(["rev-parse", self._remote_ref], check=False) + if proc.returncode != 0: + return None + return proc.stdout.decode().strip() + + def _is_ancestor(self, maybe_ancestor: str, descendant: str) -> bool: + return self._run( + ["merge-base", "--is-ancestor", maybe_ancestor, descendant], check=False + ).returncode == 0 + + def has_uncommitted_vault_changes(self) -> bool: + return bool(self._out(["status", "--porcelain", "--", self.rel_path])) + + # --- подготовка --- + + def ensure_repo(self) -> None: + """Создать репозиторий и прописать remote, если их ещё нет.""" + if not self.is_repo(): + self.repo_dir.mkdir(parents=True, exist_ok=True) + self._run(["init", "-b", self.branch]) + + self._run(["config", "user.name", self.settings.author_name or "food-market"]) + self._run(["config", "user.email", self.settings.author_email or "food-market@localhost"]) + + if self.settings.remote_url: + # Кладём чистый URL: токен подставляется только в момент вызова. + existing = self._run(["remote", "get-url", "origin"], check=False) + if existing.returncode != 0: + self._run(["remote", "add", "origin", self.settings.remote_url]) + elif existing.stdout.decode().strip() != self.settings.remote_url: + self._run(["remote", "set-url", "origin", self.settings.remote_url]) + + # --- обмен с сервером --- + + def fetch(self) -> str | None: + """Забрать удалённую ветку в origin/. Возвращает её sha.""" + if not self.settings.remote_url: + return None + + url = auth_url(self.settings.remote_url, self.settings.token) + proc = self._run( + ["fetch", "--no-tags", url, f"+refs/heads/{self.branch}:{self._remote_ref}"], + timeout=NETWORK_TIMEOUT, + check=False, + ) + if proc.returncode != 0: + stderr = proc.stderr.decode("utf-8", "replace") + # Пустой репозиторий или ветки ещё нет — это нормальный первый запуск. + if "couldn't find remote ref" in stderr or "not our ref" in stderr: + return None + raise GitError(scrub(stderr.strip(), self.settings.token)) + + return self.remote_sha() + + def save_remote_copy(self, target: Path) -> Path | None: + """Выложить удалённую версию базы отдельным файлом для разбора конфликта.""" + proc = self._run(["show", f"{self._remote_ref}:{self.rel_path}"], check=False) + if proc.returncode != 0: + return None + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(proc.stdout) + return target + + # --- коммит --- + + def _can_amend(self, remote: str | None) -> bool: + """Можно ли дописать в сегодняшнюю вершину вместо нового коммита. + + Все условия обязательны. Достаточно одному не совпасть — идём обычным + путём, без переписывания истории. + """ + if not self.settings.daily_squash: + return False + + head = self.head_sha() + if head is None: + return False + + # Вершина должна быть уже на сервере и никем не сдвинута, иначе + # force-push затрёт чужую работу. + if remote is None or head != remote: + return False + + subject = self._out(["log", "-1", "--format=%s"]) + if not COMMIT_RE.match(subject) or subject != commit_message(): + return False + + # Коммит должен трогать ровно наш файл: иначе amend утащит в себя + # чужие изменения, попавшие в ту же вершину. + touched = self._out(["show", "--name-only", "--format=", "HEAD"]).split("\n") + return [t for t in touched if t.strip()] == [self.rel_path] + + def _commit(self, remote: str | None) -> tuple[bool, bool, str | None]: + """Закоммитить базу. Возвращает (закоммитили, через amend, sha до amend).""" + self._run(["add", "--", self.rel_path]) + + staged = self._run( + ["diff", "--cached", "--quiet", "--", self.rel_path], check=False + ).returncode != 0 + if not staged: + return False, False, None + + if self._can_amend(remote): + pre_amend = self.head_sha() + self._run(["commit", "--amend", "--no-edit", "--", self.rel_path]) + return True, True, pre_amend + + self._run(["commit", "-m", commit_message(), "--", self.rel_path]) + return True, False, None + + def _push(self, amended: bool, lease: str | None) -> None: + url = auth_url(self.settings.remote_url, self.settings.token) + args = ["push"] + if amended and lease: + # Явный lease: пуш пройдёт, только если на сервере до сих пор та же + # вершина, которую мы переписали. + args.append(f"--force-with-lease=refs/heads/{self.branch}:{lease}") + args += [url, f"HEAD:refs/heads/{self.branch}"] + + proc = self._run(args, timeout=NETWORK_TIMEOUT, check=False) + if proc.returncode == 0: + # push сам не двигает refs/remotes/origin/*, а от актуальности этой + # ссылки зависит право на amend в следующий раз. + head = self.head_sha() + if head: + self._run(["update-ref", self._remote_ref, head], check=False) + return + + stderr = proc.stderr.decode("utf-8", "replace") + if "stale info" in stderr or "non-fast-forward" in stderr or "fetch first" in stderr: + raise Diverged( + "На сервере появились изменения с другой машины. " + "Автопуш остановлен, чтобы ничего не затереть." + ) + raise GitError(scrub(stderr.strip(), self.settings.token)) + + # --- основной сценарий --- + + def sync(self, remote_copy_path: Path | None = None) -> SyncResult: + """Полный цикл: fetch → примирение → коммит → push. + + Fetch идёт первым намеренно. Право на amend зависит от того, что + сейчас на сервере, поэтому спрашивать надо до переписывания истории, + а не после. + """ + if not self.settings.remote_url: + raise GitError("Не задан адрес репозитория — укажи его в настройках.") + + self.ensure_repo() + remote = self.fetch() + result = SyncResult() + + head = self.head_sha() + if remote and head and head != remote: + behind = self._is_ancestor(head, remote) + ahead = self._is_ancestor(remote, head) + + if behind and not ahead: + if self.has_uncommitted_vault_changes(): + # Правки с двух машин одновременно: то, что лежит локально, + # и то, что на сервере — обе версии настоящие. + self._report_divergence(remote_copy_path) + self._run(["merge", "--ff-only", self._remote_ref]) + result.pulled = True + result.message = "Подтянуты изменения с сервера" + elif not ahead and not behind: + self._report_divergence(remote_copy_path) + + committed, amended, lease = self._commit(remote) + result.committed = committed + result.amended = amended + + head = self.head_sha() + if head and head != self.remote_sha(): + self._push(amended, lease) + result.pushed = True + + if not result.message: + if result.pushed and amended: + result.message = "Сохранено в сегодняшний коммит" + elif result.pushed: + result.message = "Отправлено на сервер" + else: + result.message = "Изменений нет" + return result + + # --- ручное разрешение конфликта --- + # + # Обе операции разрушительны, поэтому вызываются только из настроек после + # явного подтверждения. Автоматика сюда не заходит никогда. + + def reset_to_remote(self) -> None: + """Откатить локальную копию к версии с сервера.""" + self.fetch() + if self.remote_sha() is None: + raise GitError("На сервере ещё нет ветки — откатываться не к чему.") + self._run(["reset", "--hard", self._remote_ref]) + + def force_push(self) -> None: + """Перезаписать сервер локальной версией.""" + self.ensure_repo() + self._run(["add", "--", self.rel_path]) + if self._run(["diff", "--cached", "--quiet", "--", self.rel_path], check=False).returncode != 0: + self._run(["commit", "-m", commit_message(), "--", self.rel_path]) + + url = auth_url(self.settings.remote_url, self.settings.token) + proc = self._run( + ["push", "--force", url, f"HEAD:refs/heads/{self.branch}"], + timeout=NETWORK_TIMEOUT, + check=False, + ) + if proc.returncode != 0: + raise GitError(scrub(proc.stderr.decode("utf-8", "replace").strip(), self.settings.token)) + + head = self.head_sha() + if head: + self._run(["update-ref", self._remote_ref, head], check=False) + + def _report_divergence(self, remote_copy_path: Path | None) -> None: + if remote_copy_path is not None: + self.save_remote_copy(remote_copy_path) + raise Diverged( + "Локальная база и база на сервере разошлись — их правили с двух машин. " + "Слить зашифрованные файлы автоматически нельзя. " + "Удалённая версия сохранена рядом, выбери нужную в настройках." + ) diff --git a/app/journal.py b/app/journal.py new file mode 100644 index 0000000..d3493d5 --- /dev/null +++ b/app/journal.py @@ -0,0 +1,798 @@ +"""Единственный путь записи в базу. + +Ни один экран не меняет документ напрямую — всё идёт через функции этого +модуля. Каждая из них снимает состояние сущности до и после, считает разницу +и дописывает запись в аудит-лог. + +Так сделано потому, что git хранит зашифрованные снимки: `git diff` по ним +бесполезен, а после схлопывания коммитов по дням истории внутри суток там нет +вовсе. Журнал — единственное место, где видно «когда я поднял цену на булку +с маком». Если бы запись шла в обход, новый экран однажды забыл бы залогировать +изменение, и дыру заметили бы только когда история понадобится. + +Побочный, но важный эффект: правки, ничего не меняющие по существу, журнал +не засоряют — при пустой разнице запись не создаётся и база не помечается +изменённой, то есть лишнего коммита в git тоже не будет. +""" + +from __future__ import annotations + +import socket +from datetime import date, datetime + +from . import money as m +from .models import ( + SALE_KIND_LABELS, + BakeryPayment, + Batch, + BatchLine, + Change, + Counterparty, + JournalEntry, + Payment, + PricePoint, + Product, + Sale, + SaleLine, + dump_date, + new_id, + parse_date, +) + +# Значения этих настроек в журнал не попадают — только факт изменения. +SECRET_SETTINGS = frozenset({"token"}) + + +class ValidationError(Exception): + """Действие невозможно: UI показывает текст пользователю как есть.""" + + +def _now_iso() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def _host() -> str: + try: + return socket.gethostname() + except OSError: + return "?" + + +def _record( + vault, + action: str, + entity: str, + entity_id: str, + label: str, + changes: list[Change], +) -> JournalEntry: + entry = JournalEntry( + id=new_id("j"), + ts=_now_iso(), + host=_host(), + action=action, + entity=entity, + entity_id=entity_id, + label=label, + changes=changes, + ) + vault.doc.journal.append(entry) + vault.touch() + return entry + + +def _diff(before: dict[str, str], after: dict[str, str]) -> list[Change]: + keys = list(before) + [k for k in after if k not in before] + return [ + Change(field=k, old=before.get(k, ""), new=after.get(k, "")) + for k in keys + if before.get(k, "") != after.get(k, "") + ] + + +def _as_created(flat: dict[str, str]) -> list[Change]: + return [Change(field=k, old="", new=v) for k, v in flat.items() if v] + + +def _as_deleted(flat: dict[str, str]) -> list[Change]: + return [Change(field=k, old=v, new="") for k, v in flat.items() if v] + + +# --- Плоские снимки сущностей для журнала --------------------------------- +# +# Значения сразу человекочитаемые: журнал читает человек, а не программа, +# и «30,00 ₽» полезнее, чем «30». + + +def _flat_product(doc, p: Product) -> dict[str, str]: + return { + "название": p.name, + "единица": p.unit, + "себестоимость": m.fmt_money(p.cost_price, doc.settings.currency), + "цена продажи": m.fmt_money(p.retail_price, doc.settings.currency), + "в архиве": "да" if p.archived else "", + "заметка": p.note, + } + + +def _flat_counterparty(doc, c: Counterparty) -> dict[str, str]: + return {"имя": c.name, "тип": c.kind, "заметка": c.note} + + +def _lines_text(doc, lines, price_attr: str) -> str: + cur = doc.settings.currency + return "; ".join( + f"{doc.product_name(x.product_id)} × {m.fmt_qty(x.qty)} " + f"по {m.fmt_money(getattr(x, price_attr), cur)}" + for x in lines + ) + + +def _flat_batch(doc, b: Batch) -> dict[str, str]: + return { + "дата": dump_date(b.date), + "покрыть до": dump_date(b.due_date), + "состав": _lines_text(doc, b.lines, "unit_cost"), + "сумма": m.fmt_money(b.cost_total, doc.settings.currency), + "закрыта": "да" if b.closed else "", + "заметка": b.note, + } + + +def _flat_sale(doc, s: Sale) -> dict[str, str]: + cur = doc.settings.currency + return { + "дата": dump_date(s.date), + "тип": SALE_KIND_LABELS.get(s.kind, s.kind), + "кому": doc.counterparty_name(s.counterparty_id), + "состав": _lines_text(doc, s.lines, "unit_price"), + "сумма": m.fmt_money(s.total, cur), + "оплачено": m.fmt_money(s.paid, cur), + "заметка": s.note, + } + + +def _batch_label(b: Batch) -> str: + return f"Партия №{b.number} от {b.date.strftime('%d.%m.%Y')}" + + +def _sale_label(doc, s: Sale) -> str: + who = doc.counterparty_name(s.counterparty_id) + kind = SALE_KIND_LABELS.get(s.kind, s.kind) + base = f"{kind} от {s.date.strftime('%d.%m.%Y')}" + return f"{base} — {who}" if who else base + + +# --- Номенклатура --------------------------------------------------------- + + +def create_product( + vault, + name: str, + unit: str = "шт", + cost_price=0, + retail_price=0, + note: str = "", +) -> Product: + name = name.strip() + if not name: + raise ValidationError("У товара должно быть название.") + + product = Product( + id=new_id("p"), + name=name, + unit=unit.strip() or "шт", + cost_price=m.money(cost_price), + retail_price=m.money(retail_price), + note=note.strip(), + ) + # Стартовая цена — тоже точка истории, иначе первый период остался бы + # без объяснения, откуда взялась цифра. + product.price_history.append( + PricePoint( + date=date.today(), + cost_price=product.cost_price, + retail_price=product.retail_price, + note="создание товара", + ) + ) + vault.doc.products.append(product) + _record( + vault, + "product.create", + "product", + product.id, + product.name, + _as_created(_flat_product(vault.doc, product)), + ) + return product + + +def update_product( + vault, + product_id: str, + name: str | None = None, + unit: str | None = None, + note: str | None = None, + archived: bool | None = None, +) -> Product: + """Правка карточки без цен — для цен есть change_prices с историей.""" + doc = vault.doc + product = doc.product(product_id) + if product is None: + raise ValidationError("Товар не найден.") + + before = _flat_product(doc, product) + if name is not None: + if not name.strip(): + raise ValidationError("У товара должно быть название.") + product.name = name.strip() + if unit is not None: + product.unit = unit.strip() or "шт" + if note is not None: + product.note = note.strip() + if archived is not None: + product.archived = bool(archived) + + changes = _diff(before, _flat_product(doc, product)) + if changes: + action = "product.archive" if archived is not None and archived else "product.update" + _record(vault, action, "product", product.id, product.name, changes) + return product + + +def change_prices( + vault, + product_id: str, + cost_price, + retail_price, + on_date: date | None = None, + note: str = "", +) -> Product: + """Поменять цены товара, оставив след в истории цен.""" + doc = vault.doc + product = doc.product(product_id) + if product is None: + raise ValidationError("Товар не найден.") + + cost_price = m.money(cost_price) + retail_price = m.money(retail_price) + if cost_price < 0 or retail_price < 0: + raise ValidationError("Цена не может быть отрицательной.") + if cost_price == product.cost_price and retail_price == product.retail_price: + return product + + before = _flat_product(doc, product) + product.cost_price = cost_price + product.retail_price = retail_price + product.price_history.append( + PricePoint( + date=on_date or date.today(), + cost_price=cost_price, + retail_price=retail_price, + note=note.strip(), + ) + ) + _record( + vault, + "product.price_change", + "product", + product.id, + product.name, + _diff(before, _flat_product(doc, product)), + ) + return product + + +def delete_product(vault, product_id: str) -> None: + """Удалить товар, если он нигде не использован. + + Удалять использованный нельзя: закупки и продажи ссылаются на него, и + отчёты по закрытым периодам молча превратились бы в «(удалённый товар)». + Для вышедших из оборота булок есть архив. + """ + doc = vault.doc + product = doc.product(product_id) + if product is None: + raise ValidationError("Товар не найден.") + + used_in_batches = sum( + 1 for b in doc.batches for line in b.lines if line.product_id == product_id + ) + used_in_sales = sum( + 1 for s in doc.sales for line in s.lines if line.product_id == product_id + ) + if used_in_batches or used_in_sales: + raise ValidationError( + f"«{product.name}» используется в закупках ({used_in_batches}) " + f"и продажах ({used_in_sales}), удалить нельзя. " + "Отправь товар в архив — он исчезнет из списков, а история останется." + ) + + flat = _flat_product(doc, product) + doc.products.remove(product) + _record(vault, "product.delete", "product", product_id, product.name, _as_deleted(flat)) + + +# --- Контрагенты ---------------------------------------------------------- + + +def create_counterparty(vault, name: str, kind: str = "friend", note: str = "") -> Counterparty: + name = name.strip() + if not name: + raise ValidationError("У контрагента должно быть имя.") + + cp = Counterparty(id=new_id("c"), name=name, kind=kind, note=note.strip()) + vault.doc.counterparties.append(cp) + _record( + vault, + "counterparty.create", + "counterparty", + cp.id, + cp.name, + _as_created(_flat_counterparty(vault.doc, cp)), + ) + return cp + + +def update_counterparty( + vault, + cp_id: str, + name: str | None = None, + kind: str | None = None, + note: str | None = None, +) -> Counterparty: + doc = vault.doc + cp = doc.counterparty(cp_id) + if cp is None: + raise ValidationError("Контрагент не найден.") + + before = _flat_counterparty(doc, cp) + if name is not None: + if not name.strip(): + raise ValidationError("У контрагента должно быть имя.") + cp.name = name.strip() + if kind is not None: + cp.kind = kind + if note is not None: + cp.note = note.strip() + + changes = _diff(before, _flat_counterparty(doc, cp)) + if changes: + _record(vault, "counterparty.update", "counterparty", cp.id, cp.name, changes) + return cp + + +def delete_counterparty(vault, cp_id: str) -> None: + doc = vault.doc + cp = doc.counterparty(cp_id) + if cp is None: + raise ValidationError("Контрагент не найден.") + + used = sum(1 for s in doc.sales if s.counterparty_id == cp_id) + if used: + raise ValidationError( + f"На «{cp.name}» ссылаются продажи ({used}), удалить нельзя." + ) + + flat = _flat_counterparty(doc, cp) + doc.counterparties.remove(cp) + _record(vault, "counterparty.delete", "counterparty", cp_id, cp.name, _as_deleted(flat)) + + +# --- Закупки -------------------------------------------------------------- + + +def _build_batch_lines(doc, lines: list[dict]) -> list[BatchLine]: + result: list[BatchLine] = [] + for raw in lines: + product_id = raw.get("product_id") + if not product_id or doc.product(product_id) is None: + raise ValidationError("В закупке есть строка без существующего товара.") + quantity = m.qty(raw.get("qty")) + if quantity <= 0: + raise ValidationError( + f"Количество для «{doc.product_name(product_id)}» должно быть больше нуля." + ) + cost = m.money(raw.get("unit_cost")) + if cost < 0: + raise ValidationError("Себестоимость не может быть отрицательной.") + result.append(BatchLine(product_id=product_id, qty=quantity, unit_cost=cost)) + + if not result: + raise ValidationError("В закупке должна быть хотя бы одна строка.") + return result + + +def create_batch( + vault, + on_date: date, + due_date: date, + lines: list[dict], + note: str = "", +) -> Batch: + doc = vault.doc + on_date = parse_date(on_date) + due_date = parse_date(due_date) + if due_date < on_date: + raise ValidationError("Дата покрытия не может быть раньше даты закупки.") + + batch = Batch( + id=new_id("b"), + number=doc.next_batch_number(), + date=on_date, + due_date=due_date, + lines=_build_batch_lines(doc, lines), + note=note.strip(), + ) + doc.batches.append(batch) + _record( + vault, + "batch.create", + "batch", + batch.id, + _batch_label(batch), + _as_created(_flat_batch(doc, batch)), + ) + return batch + + +def update_batch( + vault, + batch_id: str, + on_date: date | None = None, + due_date: date | None = None, + lines: list[dict] | None = None, + note: str | None = None, +) -> Batch: + doc = vault.doc + batch = doc.batch(batch_id) + if batch is None: + raise ValidationError("Партия не найдена.") + + before = _flat_batch(doc, batch) + new_date = parse_date(on_date) if on_date is not None else batch.date + new_due = parse_date(due_date) if due_date is not None else batch.due_date + if new_due < new_date: + raise ValidationError("Дата покрытия не может быть раньше даты закупки.") + + batch.date = new_date + batch.due_date = new_due + if lines is not None: + batch.lines = _build_batch_lines(doc, lines) + if note is not None: + batch.note = note.strip() + + changes = _diff(before, _flat_batch(doc, batch)) + if changes: + _record(vault, "batch.update", "batch", batch.id, _batch_label(batch), changes) + return batch + + +def set_batch_closed(vault, batch_id: str, closed: bool) -> Batch: + doc = vault.doc + batch = doc.batch(batch_id) + if batch is None: + raise ValidationError("Партия не найдена.") + if batch.closed == closed: + return batch + + before = _flat_batch(doc, batch) + batch.closed = closed + _record( + vault, + "batch.close" if closed else "batch.reopen", + "batch", + batch.id, + _batch_label(batch), + _diff(before, _flat_batch(doc, batch)), + ) + return batch + + +def delete_batch(vault, batch_id: str) -> None: + doc = vault.doc + batch = doc.batch(batch_id) + if batch is None: + raise ValidationError("Партия не найдена.") + + label = _batch_label(batch) + flat = _flat_batch(doc, batch) + doc.batches.remove(batch) + # Платежи, привязанные к удалённой партии, отвязываем, а не удаляем: + # деньги пекарне реально ушли, и терять их из учёта нельзя. + for payment in doc.bakery_payments: + if payment.batch_id == batch_id: + payment.batch_id = None + _record(vault, "batch.delete", "batch", batch_id, label, _as_deleted(flat)) + + +# --- Продажи и выбытие ---------------------------------------------------- + + +def _build_sale_lines(doc, lines: list[dict]) -> list[SaleLine]: + result: list[SaleLine] = [] + for raw in lines: + product_id = raw.get("product_id") + if not product_id or doc.product(product_id) is None: + raise ValidationError("В продаже есть строка без существующего товара.") + quantity = m.qty(raw.get("qty")) + if quantity <= 0: + raise ValidationError( + f"Количество для «{doc.product_name(product_id)}» должно быть больше нуля." + ) + price = m.money(raw.get("unit_price")) + if price < 0: + raise ValidationError("Цена не может быть отрицательной.") + result.append(SaleLine(product_id=product_id, qty=quantity, unit_price=price)) + + if not result: + raise ValidationError("В продаже должна быть хотя бы одна строка.") + return result + + +def create_sale( + vault, + on_date: date, + kind: str, + lines: list[dict], + counterparty_id: str | None = None, + note: str = "", + paid_amount=None, +) -> Sale: + """Записать выбытие товара. + + paid_amount=None означает «оплачено полностью» — самый частый случай. + Явный ноль или частичная сумма создают долг. + """ + doc = vault.doc + sale = Sale( + id=new_id("s"), + date=parse_date(on_date), + kind=kind, + counterparty_id=counterparty_id or None, + lines=_build_sale_lines(doc, lines), + note=note.strip(), + ) + + if not sale.is_consumption: + amount = sale.total if paid_amount is None else m.money(paid_amount) + if amount < 0: + raise ValidationError("Оплата не может быть отрицательной.") + if amount > 0: + sale.payments.append(Payment(date=sale.date, amount=amount)) + + doc.sales.append(sale) + _record( + vault, + "sale.create", + "sale", + sale.id, + _sale_label(doc, sale), + _as_created(_flat_sale(doc, sale)), + ) + return sale + + +def update_sale( + vault, + sale_id: str, + on_date: date | None = None, + kind: str | None = None, + lines: list[dict] | None = None, + counterparty_id: str | None = None, + note: str | None = None, +) -> Sale: + """Правка продажи. Платежи не трогает — для них есть отдельные операции.""" + doc = vault.doc + sale = doc.sale(sale_id) + if sale is None: + raise ValidationError("Продажа не найдена.") + + before = _flat_sale(doc, sale) + if on_date is not None: + sale.date = parse_date(on_date) + if kind is not None: + sale.kind = kind + if lines is not None: + sale.lines = _build_sale_lines(doc, lines) + if counterparty_id is not None: + sale.counterparty_id = counterparty_id or None + if note is not None: + sale.note = note.strip() + + changes = _diff(before, _flat_sale(doc, sale)) + if changes: + _record(vault, "sale.update", "sale", sale.id, _sale_label(doc, sale), changes) + return sale + + +def delete_sale(vault, sale_id: str) -> None: + doc = vault.doc + sale = doc.sale(sale_id) + if sale is None: + raise ValidationError("Продажа не найдена.") + + label = _sale_label(doc, sale) + flat = _flat_sale(doc, sale) + doc.sales.remove(sale) + _record(vault, "sale.delete", "sale", sale_id, label, _as_deleted(flat)) + + +def add_sale_payment(vault, sale_id: str, amount, on_date: date | None = None, note: str = "") -> Sale: + doc = vault.doc + sale = doc.sale(sale_id) + if sale is None: + raise ValidationError("Продажа не найдена.") + + amount = m.money(amount) + if amount <= 0: + raise ValidationError("Сумма оплаты должна быть больше нуля.") + + before = _flat_sale(doc, sale) + sale.payments.append( + Payment(date=parse_date(on_date) if on_date else date.today(), amount=amount, note=note.strip()) + ) + _record( + vault, + "sale.payment_add", + "sale", + sale.id, + _sale_label(doc, sale), + _diff(before, _flat_sale(doc, sale)), + ) + return sale + + +def delete_sale_payment(vault, sale_id: str, index: int) -> Sale: + doc = vault.doc + sale = doc.sale(sale_id) + if sale is None: + raise ValidationError("Продажа не найдена.") + if not 0 <= index < len(sale.payments): + raise ValidationError("Такой оплаты нет.") + + before = _flat_sale(doc, sale) + sale.payments.pop(index) + _record( + vault, + "sale.payment_delete", + "sale", + sale.id, + _sale_label(doc, sale), + _diff(before, _flat_sale(doc, sale)), + ) + return sale + + +# --- Расчёты с пекарней --------------------------------------------------- + + +def add_bakery_payment( + vault, + amount, + on_date: date | None = None, + batch_id: str | None = None, + note: str = "", +) -> BakeryPayment: + doc = vault.doc + amount = m.money(amount) + if amount <= 0: + raise ValidationError("Сумма платежа должна быть больше нуля.") + if batch_id and doc.batch(batch_id) is None: + raise ValidationError("Партия не найдена.") + + payment = BakeryPayment( + id=new_id("bp"), + date=parse_date(on_date) if on_date else date.today(), + amount=amount, + batch_id=batch_id or None, + note=note.strip(), + ) + doc.bakery_payments.append(payment) + + batch = doc.batch(batch_id) if batch_id else None + _record( + vault, + "bakery_payment.add", + "bakery_payment", + payment.id, + f"Платёж пекарне {m.fmt_money(amount, doc.settings.currency)}", + [ + Change("дата", "", dump_date(payment.date)), + Change("сумма", "", m.fmt_money(amount, doc.settings.currency)), + Change("партия", "", _batch_label(batch) if batch else "без привязки"), + Change("заметка", "", payment.note), + ], + ) + return payment + + +def delete_bakery_payment(vault, payment_id: str) -> None: + doc = vault.doc + payment = next((p for p in doc.bakery_payments if p.id == payment_id), None) + if payment is None: + raise ValidationError("Платёж не найден.") + + doc.bakery_payments.remove(payment) + _record( + vault, + "bakery_payment.delete", + "bakery_payment", + payment_id, + f"Платёж пекарне {m.fmt_money(payment.amount, doc.settings.currency)}", + [ + Change("дата", dump_date(payment.date), ""), + Change("сумма", m.fmt_money(payment.amount, doc.settings.currency), ""), + ], + ) + + +# --- Настройки ------------------------------------------------------------ + + +def update_settings(vault, currency=None, default_period_days=None, git: dict | None = None): + """Правка настроек. + + Значение токена в журнал не пишется — иначе секрет, ради которого всё + и шифруется, лежал бы открытым текстом внутри той же базы и уезжал бы + в открытый экспорт. + """ + doc = vault.doc + settings = doc.settings + changes: list[Change] = [] + + if currency is not None and currency != settings.currency: + changes.append(Change("валюта", settings.currency, currency)) + settings.currency = currency + + if default_period_days is not None and int(default_period_days) != settings.default_period_days: + changes.append( + Change("период по умолчанию", str(settings.default_period_days), str(default_period_days)) + ) + settings.default_period_days = int(default_period_days) + + for key, value in (git or {}).items(): + if not hasattr(settings.git, key): + continue + old = getattr(settings.git, key) + if isinstance(old, bool): + value = bool(value) + elif isinstance(old, int) and not isinstance(old, bool): + value = int(value) + if old == value: + continue + + setattr(settings.git, key, value) + if key in SECRET_SETTINGS: + changes.append(Change(f"git.{key}", "скрыто", "изменён" if value else "удалён")) + else: + changes.append(Change(f"git.{key}", str(old), str(value))) + + if changes: + _record(vault, "settings.update", "settings", "settings", "Настройки", changes) + return settings + + +# --- Обслуживание журнала ------------------------------------------------- + + +def prune_journal(vault, before: date) -> int: + """Выбросить записи журнала старше указанной даты. Возвращает их число.""" + doc = vault.doc + cutoff = dump_date(parse_date(before)) + kept = [e for e in doc.journal if e.ts[:10] >= cutoff] + removed = len(doc.journal) - len(kept) + if not removed: + return 0 + + doc.journal = kept + _record( + vault, + "journal.prune", + "journal", + "journal", + "Журнал", + [Change("удалено записей старше " + cutoff, "", str(removed))], + ) + return removed diff --git a/app/ledger.py b/app/ledger.py new file mode 100644 index 0000000..79753a8 --- /dev/null +++ b/app/ledger.py @@ -0,0 +1,485 @@ +"""Расчёты по партиям, долгам и остаткам. Чистые функции, без Qt. + +Главный вопрос, на который отвечает модуль: успеваю ли я собрать себестоимость +конкретной партии до её дедлайна. Чтобы на него ответить, недостаточно знать +общую выручку — нужно понимать, деньги за какую именно партию уже пришли. +Поэтому продажи распределяются по партиям методом FIFO. + +Цены у поставщика фиксированные, так что на саму себестоимость выбор FIFO +не влияет. Он нужен ровно для привязки выручки к периоду. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date +from decimal import Decimal + +from . import money as m +from .models import ( + Batch, + CONSUMPTION_KINDS, + Document, + Sale, +) + +# За сколько дней до дедлайна партия начинает считаться «горящей». +DUE_SOON_DAYS = 3 + +STATUS_SETTLED = "settled" +STATUS_OVERDUE = "overdue" +STATUS_DUE_SOON = "due_soon" +STATUS_OPEN = "open" + +STATUS_LABELS = { + STATUS_SETTLED: "Закрыта", + STATUS_OVERDUE: "Просрочена", + STATUS_DUE_SOON: "Горит", + STATUS_OPEN: "В работе", +} + + +@dataclass +class Allocation: + """Кусок продажи, списанный с конкретной партии.""" + + sale_id: str + line_index: int + product_id: str + batch_id: str + qty: Decimal + unit_cost: Decimal + unit_price: Decimal + kind: str + paid_fraction: Decimal + + @property + def cost(self) -> Decimal: + return self.qty * self.unit_cost + + @property + def revenue(self) -> Decimal: + """Начислено — включая то, что ещё не оплачено.""" + return self.qty * self.unit_price + + @property + def cash(self) -> Decimal: + """Реально полученные деньги с учётом частичной оплаты.""" + return self.revenue * self.paid_fraction + + +@dataclass +class Shortfall: + """Продали больше, чем закупали: столько-то штук взялись из ниоткуда.""" + + sale_id: str + product_id: str + qty: Decimal + + +@dataclass +class BatchReport: + batch: Batch + cost_total: Decimal + qty_total: Decimal + qty_sold: Decimal + qty_left: Decimal + revenue: Decimal + cash_collected: Decimal + consumed_cost: Decimal + paid_to_bakery: Decimal + days_left: int + status: str + stock: dict[str, Decimal] = field(default_factory=dict) + + @property + def remaining_to_bakery(self) -> Decimal: + return max(m.ZERO, m.money(self.cost_total - self.paid_to_bakery)) + + @property + def coverage_pct(self) -> Decimal: + """Какая доля себестоимости уже собрана деньгами.""" + if self.cost_total <= 0: + return Decimal(100) + return m.money(self.cash_collected / self.cost_total * 100) + + @property + def still_to_collect(self) -> Decimal: + """Сколько денег ещё надо собрать, чтобы выйти в ноль по этой партии.""" + return max(m.ZERO, m.money(self.cost_total - self.cash_collected)) + + @property + def label(self) -> str: + return f"№{self.batch.number} от {self.batch.date.strftime('%d.%m.%Y')}" + + +@dataclass +class DebtorReport: + counterparty_id: str | None + name: str + debt: Decimal + sales: list[Sale] = field(default_factory=list) + + +@dataclass +class StockRow: + product_id: str + name: str + unit: str + qty: Decimal + cost: Decimal + + +@dataclass +class Summary: + owed_to_bakery: Decimal + receivable: Decimal + revenue: Decimal + cash_collected: Decimal + gross_margin: Decimal + consumed_cost: Decimal + stock_qty: Decimal + stock_cost: Decimal + overdue_count: int + next_due: BatchReport | None + + @property + def profit(self) -> Decimal: + """Что осталось тебе. + + Съеденное и подаренное вычитается: пекарне за эти булки всё равно + платить, и покрывается это из наценки на проданных. + """ + return m.money(self.gross_margin - self.consumed_cost) + + +@dataclass +class Report: + today: date + batches: list[BatchReport] + allocations: list[Allocation] + shortfalls: list[Shortfall] + debtors: list[DebtorReport] + stock: list[StockRow] + summary: Summary + unassigned_bakery_payment: Decimal + warnings: list[str] + + def batch_report(self, batch_id: str) -> BatchReport | None: + return next((r for r in self.batches if r.batch.id == batch_id), None) + + @property + def open_batches(self) -> list[BatchReport]: + return [r for r in self.batches if r.status != STATUS_SETTLED] + + +# --- Распределение продаж по партиям -------------------------------------- + + +def _paid_fraction(sale: Sale) -> Decimal: + """Какая доля продажи оплачена. Переплата не даёт больше единицы.""" + total = sale.total + if total <= 0: + return m.ZERO + return min(Decimal(1), sale.paid / total) + + +def _ordered_batches(doc: Document) -> list[tuple[int, Batch]]: + return sorted(enumerate(doc.batches), key=lambda t: (t[1].date, t[1].number, t[0])) + + +def _ordered_sales(doc: Document) -> list[tuple[int, Sale]]: + # Порядок ввода — вторичный ключ: две продажи одной датой должны + # распределяться одинаково при каждом пересчёте. + return sorted(enumerate(doc.sales), key=lambda t: (t[1].date, t[0])) + + +def allocate(doc: Document) -> tuple[list[Allocation], list[Shortfall], dict[str, dict[str, Decimal]]]: + """Разложить продажи по партиям методом FIFO. + + Возвращает аллокации, нехватку товара и остаток по каждой партии. + + Намеренно не проверяем, что дата продажи позже даты партии: люди заводят + документы в произвольном порядке, и жаловаться на это было бы шумом. + Значение имеет только очередь партий. + """ + remaining: dict[str, dict[str, Decimal]] = {} + for _, batch in _ordered_batches(doc): + per_product = remaining.setdefault(batch.id, {}) + for line in batch.lines: + per_product[line.product_id] = per_product.get(line.product_id, m.ZERO) + line.qty + + # Себестоимость берём из строки партии — это слепок цены на момент закупки. + unit_costs: dict[tuple[str, str], Decimal] = { + (batch.id, line.product_id): line.unit_cost + for _, batch in _ordered_batches(doc) + for line in batch.lines + } + order = [batch.id for _, batch in _ordered_batches(doc)] + + allocations: list[Allocation] = [] + shortfalls: list[Shortfall] = [] + + for _, sale in _ordered_sales(doc): + fraction = _paid_fraction(sale) + for line_index, line in enumerate(sale.lines): + need = line.qty + for batch_id in order: + if need <= 0: + break + available = remaining.get(batch_id, {}).get(line.product_id, m.ZERO) + if available <= 0: + continue + + take = min(available, need) + remaining[batch_id][line.product_id] = available - take + need -= take + allocations.append( + Allocation( + sale_id=sale.id, + line_index=line_index, + product_id=line.product_id, + batch_id=batch_id, + qty=take, + unit_cost=unit_costs.get((batch_id, line.product_id), m.ZERO), + unit_price=line.unit_price, + kind=sale.kind, + paid_fraction=fraction, + ) + ) + + if need > 0: + shortfalls.append( + Shortfall(sale_id=sale.id, product_id=line.product_id, qty=need) + ) + + return allocations, shortfalls, remaining + + +# --- Разнесение платежей пекарне ------------------------------------------ + + +def _spread_bakery_payments(doc: Document, cost_totals: dict[str, Decimal]) -> tuple[dict[str, Decimal], Decimal]: + """Разложить платежи пекарне по партиям. + + Платёж с указанной партией гасит её; излишек не превращается в + отрицательный долг, а переливается в общий котёл. Платежи без привязки + гасят старейшие непокрытые партии — так же, как это происходит в жизни. + """ + paid: dict[str, Decimal] = {batch_id: m.ZERO for batch_id in cost_totals} + pool = m.ZERO + + for payment in doc.bakery_payments: + batch_id = payment.batch_id + if batch_id and batch_id in paid: + room = max(m.ZERO, cost_totals[batch_id] - paid[batch_id]) + applied = min(room, payment.amount) + paid[batch_id] += applied + pool += payment.amount - applied + else: + pool += payment.amount + + for _, batch in _ordered_batches(doc): + if pool <= 0: + break + room = max(m.ZERO, cost_totals.get(batch.id, m.ZERO) - paid.get(batch.id, m.ZERO)) + applied = min(room, pool) + paid[batch.id] = paid.get(batch.id, m.ZERO) + applied + pool -= applied + + return paid, pool + + +# --- Сборка отчёта -------------------------------------------------------- + + +def _status(batch: Batch, remaining_to_bakery: Decimal, days_left: int) -> str: + if batch.closed or remaining_to_bakery <= 0: + return STATUS_SETTLED + if days_left < 0: + return STATUS_OVERDUE + if days_left <= DUE_SOON_DAYS: + return STATUS_DUE_SOON + return STATUS_OPEN + + +def build(doc: Document, today: date | None = None) -> Report: + """Пересчитать всё. Дешевле, чем поддерживать инкрементальное состояние.""" + today = today or date.today() + allocations, shortfalls, remaining = allocate(doc) + + cost_totals = {batch.id: batch.cost_total for batch in doc.batches} + paid_to_bakery, unassigned = _spread_bakery_payments(doc, cost_totals) + + per_batch: dict[str, dict[str, Decimal]] = { + batch.id: {"qty_sold": m.ZERO, "revenue": m.ZERO, "cash": m.ZERO, "consumed": m.ZERO} + for batch in doc.batches + } + for a in allocations: + bucket = per_batch.get(a.batch_id) + if bucket is None: + continue + bucket["qty_sold"] += a.qty + if a.kind in CONSUMPTION_KINDS: + bucket["consumed"] += a.cost + else: + bucket["revenue"] += a.revenue + bucket["cash"] += a.cash + + batch_reports: list[BatchReport] = [] + for _, batch in _ordered_batches(doc): + bucket = per_batch[batch.id] + cost_total = cost_totals[batch.id] + paid = m.money(paid_to_bakery.get(batch.id, m.ZERO)) + days_left = (batch.due_date - today).days + stock = { + pid: qty + for pid, qty in remaining.get(batch.id, {}).items() + if qty > 0 + } + batch_reports.append( + BatchReport( + batch=batch, + cost_total=cost_total, + qty_total=batch.qty_total, + qty_sold=m.qty(bucket["qty_sold"]), + qty_left=m.qty(sum(stock.values(), m.ZERO)), + revenue=m.money(bucket["revenue"]), + cash_collected=m.money(bucket["cash"]), + consumed_cost=m.money(bucket["consumed"]), + paid_to_bakery=paid, + days_left=days_left, + status=_status(batch, max(m.ZERO, cost_total - paid), days_left), + stock=stock, + ) + ) + + return Report( + today=today, + batches=batch_reports, + allocations=allocations, + shortfalls=shortfalls, + debtors=_debtors(doc), + stock=_stock(doc, batch_reports), + summary=_summary(doc, batch_reports, allocations, today), + unassigned_bakery_payment=m.money(unassigned), + warnings=_warnings(doc, shortfalls, unassigned), + ) + + +def _debtors(doc: Document) -> list[DebtorReport]: + """Кто и сколько должен. Продажи без контрагента идут отдельной строкой.""" + buckets: dict[str | None, DebtorReport] = {} + for sale in doc.sales: + if sale.debt <= 0: + continue + key = sale.counterparty_id + report = buckets.get(key) + if report is None: + report = DebtorReport( + counterparty_id=key, + name=doc.counterparty_name(key) if key else "Без контрагента", + debt=m.ZERO, + ) + buckets[key] = report + report.debt = m.money(report.debt + sale.debt) + report.sales.append(sale) + + return sorted(buckets.values(), key=lambda r: (-r.debt, r.name)) + + +def _stock(doc: Document, batch_reports: list[BatchReport]) -> list[StockRow]: + qty_by_product: dict[str, Decimal] = {} + cost_by_product: dict[str, Decimal] = {} + for report in batch_reports: + for product_id, qty in report.stock.items(): + unit_cost = next( + (line.unit_cost for line in report.batch.lines if line.product_id == product_id), + m.ZERO, + ) + qty_by_product[product_id] = qty_by_product.get(product_id, m.ZERO) + qty + cost_by_product[product_id] = cost_by_product.get(product_id, m.ZERO) + qty * unit_cost + + rows = [] + for product in doc.products: + qty = qty_by_product.get(product.id, m.ZERO) + if qty <= 0 and product.archived: + continue + rows.append( + StockRow( + product_id=product.id, + name=product.name, + unit=product.unit, + qty=m.qty(qty), + cost=m.money(cost_by_product.get(product.id, m.ZERO)), + ) + ) + return sorted(rows, key=lambda r: r.name) + + +def _summary( + doc: Document, + batch_reports: list[BatchReport], + allocations: list[Allocation], + today: date, +) -> Summary: + revenue = m.ZERO + cash = m.ZERO + margin = m.ZERO + consumed = m.ZERO + for a in allocations: + if a.kind in CONSUMPTION_KINDS: + consumed += a.cost + else: + revenue += a.revenue + cash += a.cash + margin += a.revenue - a.cost + + open_reports = [r for r in batch_reports if r.status != STATUS_SETTLED] + next_due = min(open_reports, key=lambda r: r.batch.due_date, default=None) + + return Summary( + owed_to_bakery=m.money(sum((r.remaining_to_bakery for r in batch_reports), m.ZERO)), + receivable=m.money(sum((s.debt for s in doc.sales), m.ZERO)), + revenue=m.money(revenue), + cash_collected=m.money(cash), + gross_margin=m.money(margin), + consumed_cost=m.money(consumed), + stock_qty=m.qty(sum((r.qty_left for r in batch_reports), m.ZERO)), + stock_cost=m.money( + sum( + ( + qty * line.unit_cost + for r in batch_reports + for pid, qty in r.stock.items() + for line in r.batch.lines + if line.product_id == pid + ), + m.ZERO, + ) + ), + overdue_count=sum(1 for r in batch_reports if r.status == STATUS_OVERDUE), + next_due=next_due, + ) + + +def _warnings(doc: Document, shortfalls: list[Shortfall], unassigned: Decimal) -> list[str]: + """То, что стоит показать пользователю, но что не должно ломать расчёт.""" + warnings: list[str] = [] + + by_product: dict[str, Decimal] = {} + for s in shortfalls: + by_product[s.product_id] = by_product.get(s.product_id, m.ZERO) + s.qty + for product_id, qty in by_product.items(): + warnings.append( + f"«{doc.product_name(product_id)}»: продано на {m.fmt_qty(qty)} больше, " + "чем закуплено — проверь закупки." + ) + + if unassigned > 0: + warnings.append( + f"Пекарне переплачено {m.fmt_money(unassigned, doc.settings.currency)} — " + "это аванс под будущие партии." + ) + + return warnings diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..84fde9e --- /dev/null +++ b/app/main.py @@ -0,0 +1,33 @@ +"""Точка входа.""" + +from __future__ import annotations + +import sys + +from PySide6.QtWidgets import QApplication, QDialog + +from . import paths +from .ui import theme +from .ui.login import LoginDialog +from .ui.main_window import MainWindow + + +def main() -> int: + app = QApplication(sys.argv) + app.setApplicationName("Булочная") + app.setOrganizationName("food-market") + theme.apply(app) + + paths.data_dir().mkdir(parents=True, exist_ok=True) + + login = LoginDialog(paths.vault_path()) + if login.exec() != QDialog.Accepted or login.vault is None: + return 0 + + window = MainWindow(login.vault) + window.show() + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..6c57495 --- /dev/null +++ b/app/models.py @@ -0,0 +1,574 @@ +"""Структура документа базы и сериализация в/из JSON. + +Вся база — один словарь, который целиком помещается в память. Датаклассы ниже +описывают его форму; на диск и обратно всё ходит через to_dict/from_dict, +чтобы Decimal и date не разъезжались с их JSON-представлением. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +from . import money as m + +SCHEMA_VERSION = 1 + +# --- Типы движения товара ------------------------------------------------- +# +# Тип определяет и подстановку цены в форме, и то, как продажа попадает +# в отчёты: приносит ли она деньги или это чистое выбытие себестоимости. + +KIND_RETAIL = "retail" +KIND_FRIEND = "friend" +KIND_SELF = "self" +KIND_GIFT = "gift" +KIND_WRITEOFF = "writeoff" + +SALE_KINDS = (KIND_RETAIL, KIND_FRIEND, KIND_SELF, KIND_GIFT, KIND_WRITEOFF) + +SALE_KIND_LABELS = { + KIND_RETAIL: "Продажа", + KIND_FRIEND: "Другу по себестоимости", + KIND_SELF: "Съел сам", + KIND_GIFT: "Подарил", + KIND_WRITEOFF: "Списание", +} + +# Приносят выручку. Остальные типы — выбытие без денег: себестоимость всё равно +# придётся вернуть пекарне, поэтому она гасится из маржи. +REVENUE_KINDS = frozenset({KIND_RETAIL, KIND_FRIEND}) +CONSUMPTION_KINDS = frozenset({KIND_SELF, KIND_GIFT, KIND_WRITEOFF}) + + +def new_id(prefix: str) -> str: + return f"{prefix}_{uuid.uuid4().hex[:12]}" + + +def parse_date(value: Any) -> date: + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + return date.fromisoformat(str(value)) + + +def dump_date(value: date) -> str: + return value.isoformat() + + +# --- Номенклатура --------------------------------------------------------- + + +@dataclass +class PricePoint: + """Снимок цен товара на момент изменения. + + Пишется при каждой правке цены, поэтому история цен видна прямо в карточке + товара, не полагаясь на историю git. + """ + + date: date + cost_price: Decimal + retail_price: Decimal + note: str = "" + + @classmethod + def from_dict(cls, d: dict) -> "PricePoint": + return cls( + date=parse_date(d["date"]), + cost_price=m.money(d.get("cost_price")), + retail_price=m.money(d.get("retail_price")), + note=d.get("note", ""), + ) + + def to_dict(self) -> dict: + return { + "date": dump_date(self.date), + "cost_price": m.dumps(self.cost_price), + "retail_price": m.dumps(self.retail_price), + "note": self.note, + } + + +@dataclass +class Product: + id: str + name: str + unit: str = "шт" + cost_price: Decimal = m.ZERO + retail_price: Decimal = m.ZERO + archived: bool = False + note: str = "" + price_history: list[PricePoint] = field(default_factory=list) + + @classmethod + def from_dict(cls, d: dict) -> "Product": + return cls( + id=d["id"], + name=d.get("name", ""), + unit=d.get("unit", "шт"), + cost_price=m.money(d.get("cost_price")), + retail_price=m.money(d.get("retail_price")), + archived=bool(d.get("archived", False)), + note=d.get("note", ""), + price_history=[PricePoint.from_dict(p) for p in d.get("price_history", [])], + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "unit": self.unit, + "cost_price": m.dumps(self.cost_price), + "retail_price": m.dumps(self.retail_price), + "archived": self.archived, + "note": self.note, + "price_history": [p.to_dict() for p in self.price_history], + } + + +@dataclass +class Counterparty: + id: str + name: str + kind: str = "friend" + note: str = "" + + @classmethod + def from_dict(cls, d: dict) -> "Counterparty": + return cls( + id=d["id"], + name=d.get("name", ""), + kind=d.get("kind", "friend"), + note=d.get("note", ""), + ) + + def to_dict(self) -> dict: + return {"id": self.id, "name": self.name, "kind": self.kind, "note": self.note} + + +# --- Закупки -------------------------------------------------------------- + + +@dataclass +class BatchLine: + product_id: str + qty: Decimal + unit_cost: Decimal + + @property + def total(self) -> Decimal: + return m.money(self.qty * self.unit_cost) + + @classmethod + def from_dict(cls, d: dict) -> "BatchLine": + return cls( + product_id=d["product_id"], + qty=m.qty(d.get("qty")), + unit_cost=m.money(d.get("unit_cost")), + ) + + def to_dict(self) -> dict: + return { + "product_id": self.product_id, + "qty": m.dumps(self.qty), + "unit_cost": m.dumps(self.unit_cost), + } + + +@dataclass +class Batch: + """Партия булок, взятая в пекарне в долг. + + unit_cost в строках — слепок цены на момент закупки: если цена у поставщика + потом изменится, долг по уже взятой партии не должен поехать. + """ + + id: str + number: int + date: date + due_date: date + lines: list[BatchLine] = field(default_factory=list) + closed: bool = False + note: str = "" + + @property + def cost_total(self) -> Decimal: + """Сколько надо вернуть пекарне за эту партию.""" + return m.money(sum((line.total for line in self.lines), m.ZERO)) + + @property + def qty_total(self) -> Decimal: + return m.qty(sum((line.qty for line in self.lines), m.ZERO)) + + @classmethod + def from_dict(cls, d: dict) -> "Batch": + return cls( + id=d["id"], + number=int(d.get("number", 0)), + date=parse_date(d["date"]), + due_date=parse_date(d["due_date"]), + lines=[BatchLine.from_dict(x) for x in d.get("lines", [])], + closed=bool(d.get("closed", False)), + note=d.get("note", ""), + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "number": self.number, + "date": dump_date(self.date), + "due_date": dump_date(self.due_date), + "lines": [x.to_dict() for x in self.lines], + "closed": self.closed, + "note": self.note, + } + + +# --- Продажи и выбытие ---------------------------------------------------- + + +@dataclass +class SaleLine: + product_id: str + qty: Decimal + unit_price: Decimal + + @property + def total(self) -> Decimal: + return m.money(self.qty * self.unit_price) + + @classmethod + def from_dict(cls, d: dict) -> "SaleLine": + return cls( + product_id=d["product_id"], + qty=m.qty(d.get("qty")), + unit_price=m.money(d.get("unit_price")), + ) + + def to_dict(self) -> dict: + return { + "product_id": self.product_id, + "qty": m.dumps(self.qty), + "unit_price": m.dumps(self.unit_price), + } + + +@dataclass +class Payment: + """Приход денег по конкретной продаже (может быть частичным).""" + + date: date + amount: Decimal + note: str = "" + + @classmethod + def from_dict(cls, d: dict) -> "Payment": + return cls( + date=parse_date(d["date"]), + amount=m.money(d.get("amount")), + note=d.get("note", ""), + ) + + def to_dict(self) -> dict: + return { + "date": dump_date(self.date), + "amount": m.dumps(self.amount), + "note": self.note, + } + + +@dataclass +class Sale: + """Любое выбытие товара: продажа, отдача другу, съел сам, списание.""" + + id: str + date: date + kind: str = KIND_RETAIL + counterparty_id: str | None = None + lines: list[SaleLine] = field(default_factory=list) + payments: list[Payment] = field(default_factory=list) + note: str = "" + + @property + def total(self) -> Decimal: + return m.money(sum((line.total for line in self.lines), m.ZERO)) + + @property + def paid(self) -> Decimal: + return m.money(sum((p.amount for p in self.payments), m.ZERO)) + + @property + def debt(self) -> Decimal: + """Сколько ещё должны по этой продаже (не бывает отрицательным).""" + return max(m.ZERO, m.money(self.total - self.paid)) + + @property + def is_consumption(self) -> bool: + return self.kind in CONSUMPTION_KINDS + + @classmethod + def from_dict(cls, d: dict) -> "Sale": + return cls( + id=d["id"], + date=parse_date(d["date"]), + kind=d.get("kind", KIND_RETAIL), + counterparty_id=d.get("counterparty_id") or None, + lines=[SaleLine.from_dict(x) for x in d.get("lines", [])], + payments=[Payment.from_dict(x) for x in d.get("payments", [])], + note=d.get("note", ""), + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "date": dump_date(self.date), + "kind": self.kind, + "counterparty_id": self.counterparty_id, + "lines": [x.to_dict() for x in self.lines], + "payments": [x.to_dict() for x in self.payments], + "note": self.note, + } + + +@dataclass +class BakeryPayment: + """Возврат денег пекарне. + + batch_id=None означает «просто отдал денег» — такой платёж гасит долг + по старейшим непокрытым партиям (см. ledger). + """ + + id: str + date: date + amount: Decimal + batch_id: str | None = None + note: str = "" + + @classmethod + def from_dict(cls, d: dict) -> "BakeryPayment": + return cls( + id=d["id"], + date=parse_date(d["date"]), + amount=m.money(d.get("amount")), + batch_id=d.get("batch_id") or None, + note=d.get("note", ""), + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "date": dump_date(self.date), + "amount": m.dumps(self.amount), + "batch_id": self.batch_id, + "note": self.note, + } + + +# --- Журнал --------------------------------------------------------------- + + +@dataclass +class Change: + field: str + old: str + new: str + + @classmethod + def from_dict(cls, d: dict) -> "Change": + return cls(field=d.get("field", ""), old=d.get("old", ""), new=d.get("new", "")) + + def to_dict(self) -> dict: + return {"field": self.field, "old": self.old, "new": self.new} + + +@dataclass +class JournalEntry: + """Запись аудит-лога. Пишется только через journal.apply, только append.""" + + id: str + ts: str + host: str + action: str + entity: str + entity_id: str + label: str + changes: list[Change] = field(default_factory=list) + + @classmethod + def from_dict(cls, d: dict) -> "JournalEntry": + return cls( + id=d["id"], + ts=d.get("ts", ""), + host=d.get("host", ""), + action=d.get("action", ""), + entity=d.get("entity", ""), + entity_id=d.get("entity_id", ""), + label=d.get("label", ""), + changes=[Change.from_dict(c) for c in d.get("changes", [])], + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "ts": self.ts, + "host": self.host, + "action": self.action, + "entity": self.entity, + "entity_id": self.entity_id, + "label": self.label, + "changes": [c.to_dict() for c in self.changes], + } + + +# --- Настройки ------------------------------------------------------------ + + +@dataclass +class GitSettings: + """Токен лежит здесь, то есть внутри зашифрованной базы. + + В .git/config он не попадает — иначе лежал бы открытым рядом с шифром. + """ + + remote_url: str = "" + token: str = "" + branch: str = "main" + author_name: str = "food-market" + author_email: str = "food-market@localhost" + auto_push_minutes: int = 60 + enabled: bool = True + daily_squash: bool = True + + @classmethod + def from_dict(cls, d: dict) -> "GitSettings": + base = cls() + return cls( + remote_url=d.get("remote_url", base.remote_url), + token=d.get("token", base.token), + branch=d.get("branch", base.branch) or base.branch, + author_name=d.get("author_name") or base.author_name, + author_email=d.get("author_email") or base.author_email, + auto_push_minutes=int(d.get("auto_push_minutes", base.auto_push_minutes)), + enabled=bool(d.get("enabled", base.enabled)), + daily_squash=bool(d.get("daily_squash", base.daily_squash)), + ) + + def to_dict(self) -> dict: + return { + "remote_url": self.remote_url, + "token": self.token, + "branch": self.branch, + "author_name": self.author_name, + "author_email": self.author_email, + "auto_push_minutes": self.auto_push_minutes, + "enabled": self.enabled, + "daily_squash": self.daily_squash, + } + + +@dataclass +class Settings: + currency: str = "₽" + default_period_days: int = 14 + git: GitSettings = field(default_factory=GitSettings) + + @classmethod + def from_dict(cls, d: dict) -> "Settings": + base = cls() + return cls( + currency=d.get("currency", base.currency), + default_period_days=int( + d.get("default_period_days", base.default_period_days) + ), + git=GitSettings.from_dict(d.get("git", {})), + ) + + def to_dict(self) -> dict: + return { + "currency": self.currency, + "default_period_days": self.default_period_days, + "git": self.git.to_dict(), + } + + +# --- Документ целиком ----------------------------------------------------- + + +@dataclass +class Document: + schema_version: int = SCHEMA_VERSION + settings: Settings = field(default_factory=Settings) + products: list[Product] = field(default_factory=list) + counterparties: list[Counterparty] = field(default_factory=list) + batches: list[Batch] = field(default_factory=list) + sales: list[Sale] = field(default_factory=list) + bakery_payments: list[BakeryPayment] = field(default_factory=list) + journal: list[JournalEntry] = field(default_factory=list) + + @classmethod + def from_dict(cls, d: dict) -> "Document": + return cls( + schema_version=int(d.get("schema_version", SCHEMA_VERSION)), + settings=Settings.from_dict(d.get("settings", {})), + products=[Product.from_dict(x) for x in d.get("products", [])], + counterparties=[ + Counterparty.from_dict(x) for x in d.get("counterparties", []) + ], + batches=[Batch.from_dict(x) for x in d.get("batches", [])], + sales=[Sale.from_dict(x) for x in d.get("sales", [])], + bakery_payments=[ + BakeryPayment.from_dict(x) for x in d.get("bakery_payments", []) + ], + journal=[JournalEntry.from_dict(x) for x in d.get("journal", [])], + ) + + def to_dict(self) -> dict: + return { + "schema_version": self.schema_version, + "settings": self.settings.to_dict(), + "products": [x.to_dict() for x in self.products], + "counterparties": [x.to_dict() for x in self.counterparties], + "batches": [x.to_dict() for x in self.batches], + "sales": [x.to_dict() for x in self.sales], + "bakery_payments": [x.to_dict() for x in self.bakery_payments], + "journal": [x.to_dict() for x in self.journal], + } + + # --- поиск по id --- + + def product(self, product_id: str) -> Product | None: + return _find(self.products, product_id) + + def counterparty(self, cp_id: str) -> Counterparty | None: + return _find(self.counterparties, cp_id) + + def batch(self, batch_id: str) -> Batch | None: + return _find(self.batches, batch_id) + + def sale(self, sale_id: str) -> Sale | None: + return _find(self.sales, sale_id) + + def product_name(self, product_id: str) -> str: + p = self.product(product_id) + return p.name if p else "(удалённый товар)" + + def counterparty_name(self, cp_id: str | None) -> str: + if not cp_id: + return "" + c = self.counterparty(cp_id) + return c.name if c else "(удалённый контрагент)" + + def next_batch_number(self) -> int: + return max((b.number for b in self.batches), default=0) + 1 + + +def _find(items: list, item_id: str): + for item in items: + if item.id == item_id: + return item + return None diff --git a/app/money.py b/app/money.py new file mode 100644 index 0000000..3b0bba8 --- /dev/null +++ b/app/money.py @@ -0,0 +1,68 @@ +"""Деньги и количества. + +Всё считаем в Decimal, а не в float: 0.1 + 0.2 в float даёт 0.30000000000004, +и такие копейки накапливаются в отчётах по партиям. В JSON кладём строкой, +чтобы точность не терялась при сохранении. +""" + +from __future__ import annotations + +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP + +CENTS = Decimal("0.01") +QTY_STEP = Decimal("0.001") +ZERO = Decimal("0") + + +def money(value) -> Decimal: + """Привести что угодно к сумме денег, округлив до копеек.""" + return _to_decimal(value).quantize(CENTS, rounding=ROUND_HALF_UP) + + +def qty(value) -> Decimal: + """Привести что угодно к количеству. + + До тысячных — булки штучные, но так же работают вес и объём. + """ + return _to_decimal(value).quantize(QTY_STEP, rounding=ROUND_HALF_UP) + + +def _to_decimal(value) -> Decimal: + if isinstance(value, Decimal): + return value + if value is None or value == "": + return ZERO + if isinstance(value, float): + # Через str, иначе Decimal(0.1) даст 0.1000000000000000055511151231257827. + value = repr(value) + try: + return Decimal(str(value).replace(",", ".").replace(" ", "").replace(" ", "")) + except (InvalidOperation, ValueError) as exc: + raise ValueError(f"Не похоже на число: {value!r}") from exc + + +def dumps(value: Decimal) -> str: + """Сериализовать сумму для JSON.""" + return format(value, "f") + + +def fmt_money(value, currency: str = "₽") -> str: + """Отформатировать сумму для показа: 12 345,67 ₽.""" + value = money(value) + sign = "-" if value < 0 else "" + whole, _, frac = format(abs(value), "f").partition(".") + groups = [] + while len(whole) > 3: + groups.insert(0, whole[-3:]) + whole = whole[:-3] + groups.insert(0, whole) + text = f"{sign}{' '.join(groups)},{frac}" + # Разделители — обычные пробелы, а не неразрывные: то, что видно на экране, + # должно находиться поиском по журналу ровно так, как пользователь это набрал. + return f"{text} {currency}" if currency else text + + +def fmt_qty(value) -> str: + """Отформатировать количество, пряча незначащие нули: 5, 2,5.""" + text = format(qty(value), "f").rstrip("0").rstrip(".") + return (text or "0").replace(".", ",") diff --git a/app/paths.py b/app/paths.py new file mode 100644 index 0000000..6676609 --- /dev/null +++ b/app/paths.py @@ -0,0 +1,42 @@ +"""Где лежат данные. + +Собранный exe кладётся в корень клона репозитория, а база — в data/ рядом +с ним. При запуске из исходников корнем считается сама папка проекта, поэтому +разработка и собранное приложение работают с одним и тем же файлом. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +VAULT_NAME = "vault.fmdb" +DATA_DIR_NAME = "data" + + +def app_root() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parent.parent + + +def data_dir() -> Path: + return app_root() / DATA_DIR_NAME + + +def vault_path() -> Path: + return data_dir() / VAULT_NAME + + +def vault_rel_posix() -> str: + """Путь базы для git — всегда со слешами и относительно корня репозитория.""" + return f"{DATA_DIR_NAME}/{VAULT_NAME}" + + +def backup_path() -> Path: + return data_dir() / f"{VAULT_NAME}.bak" + + +def remote_copy_path() -> Path: + """Куда складывается удалённая версия базы при расхождении веток.""" + return data_dir() / "vault.remote.fmdb" diff --git a/app/storage.py b/app/storage.py new file mode 100644 index 0000000..28c1c1e --- /dev/null +++ b/app/storage.py @@ -0,0 +1,194 @@ +"""Загрузка, сохранение и версионирование схемы базы. + +Ключевая деталь — определение «изменилось ли что-нибудь». Шифрование +недетерминировано, поэтому сравнивать байты файла бессмысленно: одни и те же +данные каждый раз дают разный шифртекст. Вместо этого храним sha256 от +канонического (отсортированного, без пробелов) открытого JSON. Без этого git +получал бы новый коммит на каждое срабатывание автосохранения. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +from pathlib import Path + +from . import crypto +from .models import SCHEMA_VERSION, Document + + +def canonical_bytes(raw: dict) -> bytes: + """Байты, по которым считается хеш и которые уходят в шифрование. + + sort_keys обязателен: без него порядок ключей мог бы плавать и создавать + «изменения» на ровном месте. + """ + return json.dumps( + raw, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + + +def content_hash(raw: dict) -> str: + return hashlib.sha256(canonical_bytes(raw)).hexdigest() + + +# --- Миграции схемы ------------------------------------------------------- +# +# Каждая функция получает сырой словарь версии N и возвращает версию N+1. +# Нужны потому, что база уже лежит в git: старый файл должен открываться +# новой программой без ручных плясок. + +def _migrate_0_to_1(raw: dict) -> dict: + raw["schema_version"] = 1 + raw.setdefault("journal", []) + return raw + + +_MIGRATIONS = {0: _migrate_0_to_1} + + +def migrate(raw: dict) -> dict: + version = int(raw.get("schema_version", 0)) + if version > SCHEMA_VERSION: + raise crypto.UnsupportedFormat( + f"База сохранена более новой версией программы (схема {version}). " + "Обнови приложение, иначе данные можно потерять." + ) + while version < SCHEMA_VERSION: + migration = _MIGRATIONS.get(version) + if migration is None: + raise crypto.VaultError(f"Нет миграции со схемы {version}.") + raw = migration(raw) + version = int(raw["schema_version"]) + return raw + + +class Vault: + """Открытая база: документ в памяти плюс всё, что нужно для записи.""" + + def __init__(self, path: Path, password: str, doc: Document, saved_hash: str = ""): + self.path = Path(path) + self._password = password + self.doc = doc + self._saved_hash = saved_hash + # Растёт при каждой мутации. UI смотрит на него, чтобы дёшево понять, + # что пора перерисоваться и запустить отложенное автосохранение. + self.revision = 0 + + # --- открытие и создание --- + + @classmethod + def create(cls, path: Path, password: str) -> "Vault": + path = Path(path) + if path.exists(): + raise crypto.VaultError(f"Файл {path} уже существует.") + path.parent.mkdir(parents=True, exist_ok=True) + vault = cls(path, password, Document()) + vault.save(force=True) + return vault + + @classmethod + def open(cls, path: Path, password: str) -> "Vault": + path = Path(path) + blob = path.read_bytes() + plaintext = crypto.decrypt(blob, password) + try: + raw = json.loads(plaintext.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise crypto.VaultError( + "База расшифровалась, но её содержимое повреждено." + ) from exc + + raw = migrate(raw) + doc = Document.from_dict(raw) + # Хеш считаем от того, что получилось после разбора и миграции, а не от + # прочитанного сырья: иначе миграция или нормализация Decimal сразу же + # выглядели бы как несохранённое изменение. + return cls(path, password, doc, saved_hash=content_hash(doc.to_dict())) + + # --- состояние --- + + def touch(self) -> None: + """Отметить, что документ изменили. Зовётся из journal.apply.""" + self.revision += 1 + + @property + def is_dirty(self) -> bool: + return content_hash(self.doc.to_dict()) != self._saved_hash + + # --- запись --- + + def save(self, force: bool = False) -> bool: + """Записать базу на диск. Возвращает True, только если реально писали. + + Именно этот возврат решает, будет ли git-коммит: если открытые данные + не изменились, файл не трогаем и коммита не будет. + """ + raw = self.doc.to_dict() + digest = content_hash(raw) + if not force and digest == self._saved_hash: + return False + + blob = crypto.encrypt(canonical_bytes(raw), self._password) + _atomic_write(self.path, blob) + self._saved_hash = digest + return True + + def reload(self) -> None: + """Перечитать файл тем же паролем. + + Нужно после того, как файл на диске подменил git: подтянули изменения + с другой машины или откатились на серверную версию. + """ + fresh = Vault.open(self.path, self._password) + self.doc = fresh.doc + self._saved_hash = fresh._saved_hash + self.revision += 1 + + def change_password(self, new_password: str) -> None: + self._password = new_password + self.save(force=True) + + def check_password(self, password: str) -> bool: + return password == self._password + + def export_plain_json(self, path: Path) -> Path: + """Выгрузить базу в открытый JSON. + + Единственная страховка от забытого пароля, поэтому она есть в UI явной + кнопкой. Файл содержит и git-токен — его место не в общей папке. + """ + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(self.doc.to_dict(), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return path + + +def _atomic_write(path: Path, blob: bytes) -> None: + """Записать файл так, чтобы обрыв питания не оставил его наполовину. + + Пишем во временный файл, сбрасываем на диск, только потом подменяем + боевой. Предыдущая версия уезжает в .bak — дешёвая страховка от того, что + свежий файл окажется битым. + """ + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + + with open(tmp, "wb") as fh: + fh.write(blob) + fh.flush() + os.fsync(fh.fileno()) + + if path.exists(): + try: + shutil.copy2(path, path.with_suffix(path.suffix + ".bak")) + except OSError: + # Бэкап — приятный бонус, но не повод не сохранить данные. + pass + + os.replace(tmp, path) diff --git a/app/ui/__init__.py b/app/ui/__init__.py new file mode 100644 index 0000000..43ff2fd --- /dev/null +++ b/app/ui/__init__.py @@ -0,0 +1 @@ +"""Интерфейс на PySide6.""" diff --git a/app/ui/login.py b/app/ui/login.py new file mode 100644 index 0000000..6646be2 --- /dev/null +++ b/app/ui/login.py @@ -0,0 +1,159 @@ +"""Разблокировка существующей базы или создание новой.""" + +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QCheckBox, + QDialog, + QFormLayout, + QLabel, + QLineEdit, + QMessageBox, + QVBoxLayout, +) + +from .. import crypto, journal +from ..storage import Vault +from . import widgets as w + +MIN_PASSWORD_LENGTH = 4 + + +class LoginDialog(QDialog): + """Диалог входа. После accept() в self.vault лежит открытая база.""" + + def __init__(self, vault_path: Path, parent=None): + super().__init__(parent) + self.vault_path = Path(vault_path) + self.is_new = not self.vault_path.exists() + self.vault: Vault | None = None + + self.setWindowTitle("Булочная") + self.setMinimumWidth(440) + + layout = QVBoxLayout(self) + layout.setContentsMargins(24, 20, 24, 20) + layout.setSpacing(12) + + layout.addWidget(w.heading("Новая база" if self.is_new else "Булочная")) + layout.addWidget( + w.label( + "Придумай пароль. Им шифруется весь учёт — восстановить его " + "невозможно, так что запиши в надёжном месте." + if self.is_new + else str(self.vault_path), + "dim", + ) + ) + layout.itemAt(layout.count() - 1).widget().setWordWrap(True) + + form = QFormLayout() + form.setSpacing(8) + + self.password = QLineEdit() + self.password.setEchoMode(QLineEdit.Password) + self.password.returnPressed.connect(self.accept) + form.addRow("Пароль", self.password) + + if self.is_new: + self.confirm = QLineEdit() + self.confirm.setEchoMode(QLineEdit.Password) + self.confirm.returnPressed.connect(self.accept) + form.addRow("Ещё раз", self.confirm) + + 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("Токен GitLab", self.token) + + self.demo = QCheckBox("Завести три булки для примера") + self.demo.setChecked(True) + form.addRow("", self.demo) + + layout.addLayout(form) + + show = QCheckBox("Показать пароль") + show.toggled.connect(self._toggle_echo) + layout.addWidget(show) + + self.error = QLabel("") + self.error.setWordWrap(True) + self.error.setStyleSheet("color: #e0605e;") + layout.addWidget(self.error) + + layout.addWidget( + w.row( + None, + w.button("Отмена", on_click=self.reject), + w.button("Создать" if self.is_new else "Открыть", "primary", self.accept), + ) + ) + self.password.setFocus() + + def _toggle_echo(self, visible: bool) -> None: + mode = QLineEdit.Normal if visible else QLineEdit.Password + self.password.setEchoMode(mode) + if self.is_new: + self.confirm.setEchoMode(mode) + + def accept(self) -> None: + self.error.setText("") + password = self.password.text() + + if self.is_new: + if len(password) < MIN_PASSWORD_LENGTH: + self.error.setText(f"Пароль короче {MIN_PASSWORD_LENGTH} символов.") + return + if password != self.confirm.text(): + self.error.setText("Пароли не совпадают.") + return + self._create(password) + else: + self._open(password) + + def _create(self, password: str) -> None: + try: + vault = Vault.create(self.vault_path, password) + except OSError as exc: + self.error.setText(f"Не удалось создать файл: {exc}") + return + + git_settings = {} + if self.remote.text().strip(): + git_settings["remote_url"] = self.remote.text().strip() + if self.token.text(): + git_settings["token"] = self.token.text() + if git_settings: + journal.update_settings(vault, git=git_settings) + + if self.demo.isChecked(): + journal.create_product(vault, "Булка с повидлом", cost_price=30, retail_price=50) + journal.create_product(vault, "Булка с маком", cost_price=20, retail_price=35) + journal.create_product(vault, "Булка с корицей", cost_price=25, retail_price=40) + + vault.save() + self.vault = vault + super().accept() + + def _open(self, password: str) -> None: + try: + self.vault = Vault.open(self.vault_path, password) + except crypto.WrongPassword: + self.error.setText("Неверный пароль.") + self.password.selectAll() + self.password.setFocus() + return + except crypto.VaultError as exc: + QMessageBox.critical(self, "Не удалось открыть базу", str(exc)) + return + except OSError as exc: + QMessageBox.critical(self, "Не удалось прочитать файл", str(exc)) + return + super().accept() diff --git a/app/ui/main_window.py b/app/ui/main_window.py new file mode 100644 index 0000000..5a9a17b --- /dev/null +++ b/app/ui/main_window.py @@ -0,0 +1,332 @@ +"""Главное окно: боковое меню, экраны, автосохранение и фоновая синхронизация. + +Окно заодно служит контекстом для страниц: они берут отсюда базу, пересчитанный +отчёт и валюту, а после любой правки зовут 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 + +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)), + ("Журнал", 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 _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 = GitSync(paths.app_root(), paths.vault_rel_posix(), 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: + git = self.vault.doc.settings.git + sync = GitSync(paths.app_root(), paths.vault_rel_posix(), 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: + git = self.vault.doc.settings.git + self.save_now() + sync = GitSync(paths.app_root(), paths.vault_rel_posix(), 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 = GitSync(paths.app_root(), paths.vault_rel_posix(), git) + task = SyncTask(sync, paths.remote_copy_path(), self) + task.start() + task.wait(CLOSE_SYNC_WAIT_MS) + event.accept() diff --git a/app/ui/page_batches.py b/app/ui/page_batches.py new file mode 100644 index 0000000..a66a36a --- /dev/null +++ b/app/ui/page_batches.py @@ -0,0 +1,363 @@ +"""Закупки: партии булок, взятых в пекарне в долг, и их покрытие.""" + +from __future__ import annotations + +from datetime import timedelta + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QDialog, + QFormLayout, + QHBoxLayout, + QLineEdit, + QMessageBox, + QProgressBar, + QSplitter, + QVBoxLayout, + QWidget, +) + +from .. import journal, ledger +from .. import money as m +from . import theme +from . import widgets as w + + +class BatchDialog(QDialog): + """Форма партии: что взял, почём и до какого числа надо рассчитаться.""" + + def __init__(self, ctx, batch=None, parent=None): + super().__init__(parent) + self.ctx = ctx + self.batch = batch + self.setWindowTitle(f"Закупка №{batch.number}" if batch else "Новая закупка") + self.setMinimumSize(640, 480) + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 16, 20, 16) + layout.setSpacing(10) + + form = QFormLayout() + form.setSpacing(8) + + self.date = w.DateInput(batch.date if batch else None) + self.date.dateChanged.connect(self._shift_due_date) + form.addRow("Дата закупки", self.date) + + self.due = w.DateInput( + batch.due_date + if batch + else self.date.get_date() + timedelta(days=ctx.vault.doc.settings.default_period_days) + ) + form.addRow("Покрыть до", self.due) + + self.note = QLineEdit(batch.note if batch else "") + self.note.setPlaceholderText("договорились по телефону") + form.addRow("Заметка", self.note) + layout.addLayout(form) + + layout.addWidget(w.label("Что взял", "h2")) + products = [p for p in ctx.vault.doc.products if not p.archived] + self.lines = w.LinesEditor( + products, "Себестоимость", lambda p: p.cost_price, ctx.currency + ) + if batch: + self.lines.set_lines(batch.lines, "unit_cost") + elif products: + self.lines.add_line() + layout.addWidget(self.lines, 1) + + if not products: + layout.addWidget( + w.label("Сначала заведи товары на вкладке «Товары».", "dim", theme.WARN) + ) + + layout.addWidget( + w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сохранить", "primary", self.accept)) + ) + + def _shift_due_date(self) -> None: + """При смене даты закупки двигаем дедлайн, сохраняя длину периода.""" + if self.batch is not None: + return + days = self.ctx.vault.doc.settings.default_period_days + self.due.set_date(self.date.get_date() + timedelta(days=days)) + + +class PaymentDialog(QDialog): + """Возврат денег пекарне.""" + + def __init__(self, ctx, batch=None, suggested=None, parent=None): + super().__init__(parent) + self.setWindowTitle("Платёж пекарне") + self.setMinimumWidth(380) + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 16, 20, 16) + layout.setSpacing(10) + + form = QFormLayout() + form.setSpacing(8) + + self.date = w.DateInput() + form.addRow("Дата", self.date) + + self.amount = w.MoneySpin(ctx.currency) + if suggested is not None: + self.amount.set_decimal(suggested) + form.addRow("Сумма", self.amount) + + self.note = QLineEdit() + form.addRow("Заметка", self.note) + layout.addLayout(form) + + if batch is not None: + layout.addWidget(w.label(f"Зачтётся в партию №{batch.number}.", "dim")) + else: + layout.addWidget( + w.label("Без выбранной партии платёж закроет самые старые долги.", "dim") + ) + + layout.addWidget( + w.row(None, w.button("Отмена", on_click=self.reject), w.button("Записать", "primary", self.accept)) + ) + + +class BatchesPage(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.row( + w.button("+ Закупка", "primary", self.create), + w.button("Изменить", on_click=self.edit), + w.button("Платёж пекарне", on_click=self.pay), + w.button("Закрыть / открыть", on_click=self.toggle_closed), + w.button("Удалить", "danger", self.delete), + ) + ) + + splitter = QSplitter(Qt.Vertical) + + self.table = w.table( + ["№", "Дата", "Покрыть до", "Осталось", "Долг", "Отдано", "Собрано", "Покрытие", "Статус"], + stretch_column=8, + ) + self.table.itemSelectionChanged.connect(self._show_details) + self.table.doubleClicked.connect(self.edit) + splitter.addWidget(self.table) + + self.details = self._build_details() + splitter.addWidget(self.details) + splitter.setSizes([420, 260]) + layout.addWidget(splitter, 1) + + def _build_details(self) -> QWidget: + holder = QWidget() + layout = QVBoxLayout(holder) + layout.setContentsMargins(0, 8, 0, 0) + layout.setSpacing(8) + + self.details_title = w.label("Выбери партию", "h2") + layout.addWidget(self.details_title) + + self.progress = QProgressBar() + self.progress.setRange(0, 100) + layout.addWidget(self.progress) + + self.details_line = w.label("", "dim") + self.details_line.setWordWrap(True) + layout.addWidget(self.details_line) + + self.contents = w.table(["Товар", "Взято", "Осталось", "Себестоимость", "Сумма"]) + layout.addWidget(self.contents, 1) + return holder + + # --- отрисовка --- + + def refresh(self) -> None: + currency = self.ctx.currency + rows, keys = [], [] + + # Свежие партии сверху: они и есть текущая работа. + for report in sorted( + self.ctx.report.batches, key=lambda r: (r.batch.date, r.batch.number), reverse=True + ): + batch = report.batch + color = theme.STATUS_COLORS.get(report.status, "") + days = ( + "—" + if report.status == ledger.STATUS_SETTLED + else f"{report.days_left} дн." + if report.days_left >= 0 + else f"просрочено на {-report.days_left} дн." + ) + rows.append( + [ + w.sortable_num_item(str(batch.number), batch.number), + w.text_item(batch.date.strftime("%d.%m.%Y")), + w.text_item(batch.due_date.strftime("%d.%m.%Y")), + w.text_item(days, color), + w.sortable_num_item(m.fmt_money(report.cost_total, currency), report.cost_total), + w.sortable_num_item(m.fmt_money(report.paid_to_bakery, currency), report.paid_to_bakery), + w.sortable_num_item(m.fmt_money(report.cash_collected, currency), report.cash_collected), + w.sortable_num_item(f"{report.coverage_pct}%", report.coverage_pct, color), + w.text_item(ledger.STATUS_LABELS.get(report.status, ""), color), + ] + ) + keys.append(batch.id) + + w.fill(self.table, rows, keys) + self._show_details() + + def _show_details(self) -> None: + report = self._selected_report(quiet=True) + if report is None: + self.details_title.setText("Выбери партию") + self.details_line.setText("") + self.progress.setValue(0) + self.contents.setRowCount(0) + return + + currency = self.ctx.currency + color = theme.STATUS_COLORS.get(report.status, theme.ACCENT) + + self.details_title.setText(f"Партия {report.label}") + self.progress.setValue(int(min(100, max(0, report.coverage_pct)))) + self.progress.setFormat(f"собрано {report.coverage_pct}% себестоимости") + self.progress.setStyleSheet(f"QProgressBar::chunk {{ background: {color}; }}") + + parts = [ + f"Долг пекарне: {m.fmt_money(report.remaining_to_bakery, currency)} " + f"из {m.fmt_money(report.cost_total, currency)}", + f"собрано деньгами {m.fmt_money(report.cash_collected, currency)}", + f"осталось собрать {m.fmt_money(report.still_to_collect, currency)}", + ] + if report.consumed_cost > 0: + parts.append(f"съедено и подарено на {m.fmt_money(report.consumed_cost, currency)}") + self.details_line.setText(" • ".join(parts)) + + doc = self.ctx.vault.doc + rows = [] + for line in report.batch.lines: + left = report.stock.get(line.product_id, m.ZERO) + rows.append( + [ + w.text_item(doc.product_name(line.product_id)), + w.sortable_num_item(m.fmt_qty(line.qty), line.qty), + w.sortable_num_item( + m.fmt_qty(left), left, theme.MUTED if left <= 0 else "" + ), + w.sortable_num_item(m.fmt_money(line.unit_cost, currency), line.unit_cost), + w.sortable_num_item(m.fmt_money(line.total, currency), line.total), + ] + ) + w.fill(self.contents, rows) + + # --- действия --- + + def _selected_report(self, quiet: bool = False): + batch_id = w.selected_key(self.table) + report = self.ctx.report.batch_report(batch_id) if batch_id else None + if report is None and not quiet: + QMessageBox.information(self, "Не выбрано", "Выбери партию в списке.") + return report + + def create(self) -> None: + if not [p for p in self.ctx.vault.doc.products if not p.archived]: + QMessageBox.information( + self, "Нет товаров", "Сначала заведи булки на вкладке «Товары»." + ) + return + + dialog = BatchDialog(self.ctx, parent=self) + if dialog.exec() != QDialog.Accepted: + return + try: + journal.create_batch( + self.ctx.vault, + dialog.date.get_date(), + dialog.due.get_date(), + dialog.lines.lines("unit_cost"), + dialog.note.text(), + ) + except journal.ValidationError as exc: + QMessageBox.warning(self, "Не получилось", str(exc)) + return + self.ctx.changed() + + def edit(self) -> None: + report = self._selected_report() + if report is None: + return + + dialog = BatchDialog(self.ctx, report.batch, parent=self) + if dialog.exec() != QDialog.Accepted: + return + try: + journal.update_batch( + self.ctx.vault, + report.batch.id, + dialog.date.get_date(), + dialog.due.get_date(), + dialog.lines.lines("unit_cost"), + dialog.note.text(), + ) + except journal.ValidationError as exc: + QMessageBox.warning(self, "Не получилось", str(exc)) + return + self.ctx.changed() + + def pay(self) -> None: + report = self._selected_report(quiet=True) + batch = report.batch if report else None + dialog = PaymentDialog( + self.ctx, batch, report.remaining_to_bakery if report else None, parent=self + ) + if dialog.exec() != QDialog.Accepted: + return + try: + journal.add_bakery_payment( + self.ctx.vault, + dialog.amount.value_decimal(), + dialog.date.get_date(), + batch.id if batch else None, + dialog.note.text(), + ) + except journal.ValidationError as exc: + QMessageBox.warning(self, "Не получилось", str(exc)) + return + self.ctx.changed() + + def toggle_closed(self) -> None: + report = self._selected_report() + if report is None: + return + journal.set_batch_closed(self.ctx.vault, report.batch.id, not report.batch.closed) + self.ctx.changed() + + def delete(self) -> None: + report = self._selected_report() + if report is None: + return + + confirm = QMessageBox.question( + self, + "Удалить партию?", + f"Удалить партию {report.label}?\n\n" + "Платежи пекарне останутся в учёте — они просто потеряют привязку.", + ) + if confirm != QMessageBox.Yes: + return + try: + journal.delete_batch(self.ctx.vault, report.batch.id) + except journal.ValidationError as exc: + QMessageBox.warning(self, "Не получилось", str(exc)) + return + self.ctx.changed() diff --git a/app/ui/page_dashboard.py b/app/ui/page_dashboard.py new file mode 100644 index 0000000..4f6fff9 --- /dev/null +++ b/app/ui/page_dashboard.py @@ -0,0 +1,205 @@ +"""Сводка: успеваю ли я рассчитаться с пекарней.""" + +from __future__ import annotations + +from PySide6.QtWidgets import ( + QGridLayout, + QProgressBar, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from .. import ledger +from .. import money as m +from . import theme +from . import widgets as w + + +class DashboardPage(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) + + self.body = QWidget() + self.layout = QVBoxLayout(self.body) + self.layout.setContentsMargins(20, 18, 20, 18) + self.layout.setSpacing(14) + scroll.setWidget(self.body) + + def refresh(self) -> None: + # Сводка целиком собирается заново: цифр немного, а следить за тем, + # какая карточка устарела, вышло бы дороже, чем перерисовать всё. + while self.layout.count(): + item = self.layout.takeAt(0) + widget = item.widget() + if widget is not None: + # setParent(None) обязателен: deleteLater откладывает удаление + # до следующего прохода цикла событий, и до тех пор старые + # карточки остаются детьми body. + widget.setParent(None) + widget.deleteLater() + + report = self.ctx.report + summary = report.summary + currency = self.ctx.currency + + self.layout.addWidget(w.heading("Сводка")) + + if report.warnings: + self.layout.addWidget(w.warning_banner(report.warnings)) + + self.layout.addWidget(self._metrics(summary, currency)) + + if summary.next_due is not None: + self.layout.addWidget(self._deadline_card(summary.next_due, currency)) + + self.layout.addWidget(w.label("Открытые партии", "h2")) + self.layout.addWidget(self._open_batches(report, currency)) + + self.layout.addWidget(w.label("Остатки", "h2")) + self.layout.addWidget(self._stock(report, currency)) + self.layout.addStretch(1) + + def _metrics(self, summary, currency: str) -> QWidget: + holder = QWidget() + grid = QGridLayout(holder) + grid.setContentsMargins(0, 0, 0, 0) + grid.setSpacing(10) + + overdue_hint = ( + f"просрочено партий: {summary.overdue_count}" if summary.overdue_count else "всё в срок" + ) + cards = [ + ( + "Долг пекарне", + m.fmt_money(summary.owed_to_bakery, currency), + overdue_hint, + theme.DANGER if summary.overdue_count else theme.TEXT, + ), + ( + "Мне должны", + m.fmt_money(summary.receivable, currency), + "по неоплаченным продажам", + theme.WARN if summary.receivable > 0 else theme.TEXT, + ), + ( + "Прибыль", + m.fmt_money(summary.profit, currency), + f"наценка {m.fmt_money(summary.gross_margin, currency)} " + f"− съедено {m.fmt_money(summary.consumed_cost, currency)}", + theme.OK if summary.profit > 0 else theme.DANGER if summary.profit < 0 else theme.TEXT, + ), + ( + "Остаток на складе", + m.fmt_qty(summary.stock_qty), + f"по себестоимости {m.fmt_money(summary.stock_cost, currency)}", + theme.TEXT, + ), + ] + for index, (title, value, hint, color) in enumerate(cards): + grid.addWidget(w.metric_card(title, value, hint, color), 0, index) + grid.setColumnStretch(index, 1) + return holder + + def _deadline_card(self, report, currency: str) -> QWidget: + card = w.card() + layout = QVBoxLayout(card) + layout.setContentsMargins(16, 14, 16, 14) + layout.setSpacing(8) + + color = theme.STATUS_COLORS.get(report.status, theme.ACCENT) + when = ( + f"осталось {report.days_left} дн." + if report.days_left >= 0 + else f"просрочена на {-report.days_left} дн." + ) + layout.addWidget( + w.label( + f"Ближайший срок — партия {report.label}, до " + f"{report.batch.due_date.strftime('%d.%m.%Y')} ({when})", + "h2", + color, + ) + ) + + bar = QProgressBar() + bar.setRange(0, 100) + bar.setValue(int(min(100, max(0, report.coverage_pct)))) + bar.setFormat(f"собрано {report.coverage_pct}% себестоимости") + bar.setStyleSheet(f"QProgressBar::chunk {{ background: {color}; }}") + layout.addWidget(bar) + + layout.addWidget( + w.label( + f"Надо вернуть {m.fmt_money(report.remaining_to_bakery, currency)} • " + f"собрано деньгами {m.fmt_money(report.cash_collected, currency)} • " + f"осталось собрать {m.fmt_money(report.still_to_collect, currency)}", + "dim", + ) + ) + return card + + def _open_batches(self, report, currency: str) -> QWidget: + table = w.table( + ["№", "Дата", "Покрыть до", "Осталось", "Долг", "Собрано", "Покрытие", "Статус"], + stretch_column=7, + ) + table.setMaximumHeight(220) + + rows = [] + for item in sorted(report.open_batches, key=lambda r: r.batch.due_date): + color = theme.STATUS_COLORS.get(item.status, "") + days = ( + f"{item.days_left} дн." + if item.days_left >= 0 + else f"просрочено на {-item.days_left} дн." + ) + rows.append( + [ + w.sortable_num_item(str(item.batch.number), item.batch.number), + w.text_item(item.batch.date.strftime("%d.%m.%Y")), + w.text_item(item.batch.due_date.strftime("%d.%m.%Y")), + w.text_item(days, color), + w.sortable_num_item( + m.fmt_money(item.remaining_to_bakery, currency), item.remaining_to_bakery + ), + w.sortable_num_item(m.fmt_money(item.cash_collected, currency), item.cash_collected), + w.sortable_num_item(f"{item.coverage_pct}%", item.coverage_pct, color), + w.text_item(ledger.STATUS_LABELS.get(item.status, ""), color), + ] + ) + w.fill(table, rows) + + if not rows: + return w.label("Открытых партий нет — пекарня может печь дальше.", "dim") + return table + + def _stock(self, report, currency: str) -> QWidget: + rows = [row for row in report.stock if row.qty > 0] + if not rows: + return w.label("Булок не осталось.", "dim") + + table = w.table(["Товар", "Остаток", "Ед.", "По себестоимости"]) + table.setMaximumHeight(220) + w.fill( + table, + [ + [ + w.text_item(row.name), + w.sortable_num_item(m.fmt_qty(row.qty), row.qty), + w.text_item(row.unit), + w.sortable_num_item(m.fmt_money(row.cost, currency), row.cost), + ] + for row in rows + ], + ) + return table diff --git a/app/ui/page_debts.py b/app/ui/page_debts.py new file mode 100644 index 0000000..8476763 --- /dev/null +++ b/app/ui/page_debts.py @@ -0,0 +1,191 @@ +"""Долги: кто и сколько должен за взятые булки.""" + +from __future__ import annotations + +from decimal import Decimal + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QDialog, + QFormLayout, + QHeaderView, + QLineEdit, + QMessageBox, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +from .. import journal +from .. import money as m +from ..models import SALE_KIND_LABELS +from . import theme +from . import widgets as w + +ROLE_SALE_ID = Qt.UserRole + 2 + + +class PaymentDialog(QDialog): + def __init__(self, suggested: Decimal, currency: str, parent=None): + super().__init__(parent) + self.setWindowTitle("Принять оплату") + self.setMinimumWidth(340) + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 16, 20, 16) + layout.setSpacing(10) + + form = QFormLayout() + form.setSpacing(8) + + self.date = w.DateInput() + form.addRow("Дата", self.date) + + self.amount = w.MoneySpin(currency) + self.amount.set_decimal(suggested) + self.amount.selectAll() + form.addRow("Сумма", self.amount) + + self.note = QLineEdit() + form.addRow("Заметка", self.note) + layout.addLayout(form) + + layout.addWidget(w.label(f"Долг по этой продаже: {m.fmt_money(suggested, currency)}", "dim")) + layout.addWidget( + w.row(None, w.button("Отмена", on_click=self.reject), w.button("Принять", "primary", self.accept)) + ) + self.amount.setFocus() + + +def ask_payment(parent, suggested: Decimal, currency: str) -> Decimal | None: + """Спросить сумму оплаты. Возвращает None, если отменили.""" + dialog = PaymentDialog(suggested, currency, parent) + if dialog.exec() != QDialog.Accepted: + return None + return dialog.amount.value_decimal() + + +class DebtsPage(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("Долги")) + self.total_label = w.label("", "h2") + layout.addWidget( + w.row( + w.button("Принять оплату", "primary", self.take_payment), + w.button("Погасить целиком", on_click=self.settle_full), + None, + self.total_label, + ) + ) + + self.tree = QTreeWidget() + self.tree.setHeaderLabels(["Кто / что", "Дата", "Сумма", "Оплачено", "Долг"]) + self.tree.setAlternatingRowColors(True) + self.tree.setRootIsDecorated(True) + self.tree.header().setStretchLastSection(False) + self.tree.header().setSectionResizeMode(0, QHeaderView.Stretch) + self.tree.itemDoubleClicked.connect(self.take_payment) + layout.addWidget(self.tree, 1) + + self.empty = w.label("Никто ничего не должен.", "dim") + layout.addWidget(self.empty) + + # --- отрисовка --- + + def refresh(self) -> None: + doc = self.ctx.vault.doc + currency = self.ctx.currency + debtors = self.ctx.report.debtors + + self.tree.clear() + for debtor in debtors: + parent = QTreeWidgetItem( + [debtor.name, "", "", "", m.fmt_money(debtor.debt, currency)] + ) + font = parent.font(0) + font.setBold(True) + parent.setFont(0, font) + parent.setFont(4, font) + parent.setForeground(4, QColor(theme.WARN)) + parent.setTextAlignment(4, Qt.AlignRight | Qt.AlignVCenter) + + for sale in sorted(debtor.sales, key=lambda s: s.date): + what = ", ".join( + f"{doc.product_name(line.product_id)} × {m.fmt_qty(line.qty)}" + for line in sale.lines + ) + child = QTreeWidgetItem( + [ + f"{SALE_KIND_LABELS.get(sale.kind, sale.kind)}: {what}", + sale.date.strftime("%d.%m.%Y"), + m.fmt_money(sale.total, currency), + m.fmt_money(sale.paid, currency), + m.fmt_money(sale.debt, currency), + ] + ) + child.setData(0, ROLE_SALE_ID, sale.id) + for column in (2, 3, 4): + child.setTextAlignment(column, Qt.AlignRight | Qt.AlignVCenter) + parent.addChild(child) + + self.tree.addTopLevelItem(parent) + parent.setExpanded(True) + + for column in range(1, 5): + self.tree.resizeColumnToContents(column) + + total = self.ctx.report.summary.receivable + self.total_label.setText(f"Мне должны: {m.fmt_money(total, currency)}") + self.empty.setVisible(not debtors) + self.tree.setVisible(bool(debtors)) + + # --- действия --- + + def _selected_sale(self): + item = self.tree.currentItem() + if item is None: + QMessageBox.information(self, "Не выбрано", "Выбери конкретную продажу.") + return None + + sale_id = item.data(0, ROLE_SALE_ID) + if sale_id is None: + QMessageBox.information( + self, "Выбери продажу", "Раскрой человека и выбери, за что он платит." + ) + return None + return self.ctx.vault.doc.sale(sale_id) + + def take_payment(self) -> None: + sale = self._selected_sale() + if sale is None: + return + + amount = ask_payment(self, sale.debt, self.ctx.currency) + if amount is None: + return + try: + journal.add_sale_payment(self.ctx.vault, sale.id, amount) + except journal.ValidationError as exc: + QMessageBox.warning(self, "Не получилось", str(exc)) + return + self.ctx.changed() + + def settle_full(self) -> None: + sale = self._selected_sale() + if sale is None: + return + if sale.debt <= 0: + return + journal.add_sale_payment(self.ctx.vault, sale.id, sale.debt) + self.ctx.changed() diff --git a/app/ui/page_journal.py b/app/ui/page_journal.py new file mode 100644 index 0000000..74bf401 --- /dev/null +++ b/app/ui/page_journal.py @@ -0,0 +1,148 @@ +"""Журнал изменений. + +История, которую 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}" + ) diff --git a/app/ui/page_products.py b/app/ui/page_products.py new file mode 100644 index 0000000..a8bbd93 --- /dev/null +++ b/app/ui/page_products.py @@ -0,0 +1,277 @@ +"""Номенклатура: товары, их цены и история изменения цен.""" + +from __future__ import annotations + +from PySide6.QtWidgets import ( + QCheckBox, + QDialog, + QFormLayout, + QHeaderView, + QLineEdit, + QMessageBox, + QPlainTextEdit, + QVBoxLayout, + QWidget, +) + +from .. import journal +from .. import money as m +from . import theme +from . import widgets as w + + +class ProductDialog(QDialog): + """Карточка товара. Цены здесь только при создании — потом через историю.""" + + def __init__(self, currency: str, product=None, parent=None): + super().__init__(parent) + self.product = product + self.setWindowTitle("Товар" if product else "Новый товар") + self.setMinimumWidth(400) + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 16, 20, 16) + layout.setSpacing(10) + + form = QFormLayout() + form.setSpacing(8) + + self.name = QLineEdit(product.name if product else "") + self.name.setPlaceholderText("Булка с повидлом") + form.addRow("Название", self.name) + + self.unit = QLineEdit(product.unit if product else "шт") + form.addRow("Единица", self.unit) + + if product is None: + self.cost = w.MoneySpin(currency) + form.addRow("Себестоимость", self.cost) + self.retail = w.MoneySpin(currency) + form.addRow("Цена продажи", self.retail) + + self.note = QPlainTextEdit(product.note if product else "") + self.note.setMaximumHeight(70) + form.addRow("Заметка", self.note) + + layout.addLayout(form) + if product is not None: + layout.addWidget( + w.label("Цены меняются отдельной кнопкой — так сохраняется их история.", "dim") + ) + layout.addWidget( + w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сохранить", "primary", self.accept)) + ) + + +class PriceDialog(QDialog): + """Смена цен с записью в историю.""" + + def __init__(self, currency: str, product, parent=None): + super().__init__(parent) + self.product = product + self.setWindowTitle(f"Цены — {product.name}") + self.setMinimumWidth(520) + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 16, 20, 16) + layout.setSpacing(10) + + form = QFormLayout() + form.setSpacing(8) + + self.cost = w.MoneySpin(currency) + self.cost.set_decimal(product.cost_price) + form.addRow("Себестоимость", self.cost) + + self.retail = w.MoneySpin(currency) + self.retail.set_decimal(product.retail_price) + form.addRow("Цена продажи", self.retail) + + self.date = w.DateInput() + form.addRow("С даты", self.date) + + self.note = QLineEdit() + self.note.setPlaceholderText("подорожала мука") + form.addRow("Причина", self.note) + + layout.addLayout(form) + layout.addWidget(w.label("История цен", "h2")) + + history = w.table(["Дата", "Себестоимость", "Цена продажи", "Причина"], stretch_column=3) + history.setSortingEnabled(False) + history.setMaximumHeight(220) + rows = [ + [ + w.text_item(point.date.strftime("%d.%m.%Y")), + w.num_item(m.fmt_money(point.cost_price, currency), point.cost_price), + w.num_item(m.fmt_money(point.retail_price, currency), point.retail_price), + w.text_item(point.note), + ] + for point in reversed(product.price_history) + ] + w.fill(history, rows) + layout.addWidget(history) + + layout.addWidget( + w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сохранить", "primary", self.accept)) + ) + + +class ProductsPage(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.row( + w.button("+ Товар", "primary", self.create), + w.button("Изменить", on_click=self.edit), + w.button("Цены и история", on_click=self.edit_prices), + w.button("В архив / вернуть", on_click=self.toggle_archive), + w.button("Удалить", "danger", self.delete), + None, + self._archive_checkbox(), + ) + ) + + self.table = w.table( + ["Название", "Ед.", "Себестоимость", "Цена продажи", "Наценка", "Остаток", "Статус"] + ) + self.table.doubleClicked.connect(self.edit_prices) + layout.addWidget(self.table, 1) + + def _archive_checkbox(self) -> QCheckBox: + self.show_archived = QCheckBox("Показывать архив") + self.show_archived.toggled.connect(self.refresh) + return self.show_archived + + # --- отрисовка --- + + def refresh(self) -> None: + currency = self.ctx.currency + stock = {row.product_id: row.qty for row in self.ctx.report.stock} + + rows, keys = [], [] + for product in sorted(self.ctx.vault.doc.products, key=lambda p: p.name.lower()): + if product.archived and not self.show_archived.isChecked(): + continue + + margin = m.money(product.retail_price - product.cost_price) + left = stock.get(product.id, m.ZERO) + rows.append( + [ + w.text_item(product.name, theme.MUTED if product.archived else ""), + w.text_item(product.unit), + w.sortable_num_item(m.fmt_money(product.cost_price, currency), product.cost_price), + w.sortable_num_item(m.fmt_money(product.retail_price, currency), product.retail_price), + w.sortable_num_item( + m.fmt_money(margin, currency), + margin, + theme.OK if margin > 0 else theme.DANGER if margin < 0 else "", + ), + w.sortable_num_item(m.fmt_qty(left), left, theme.MUTED if left <= 0 else ""), + w.text_item("в архиве" if product.archived else "", theme.MUTED), + ] + ) + keys.append(product.id) + + w.fill(self.table, rows, keys) + + # --- действия --- + + def _selected(self): + product_id = w.selected_key(self.table) + product = self.ctx.vault.doc.product(product_id) if product_id else None + if product is None: + QMessageBox.information(self, "Не выбрано", "Выбери товар в списке.") + return product + + def create(self) -> None: + dialog = ProductDialog(self.ctx.currency, parent=self) + if dialog.exec() != QDialog.Accepted: + return + try: + journal.create_product( + self.ctx.vault, + dialog.name.text(), + dialog.unit.text(), + dialog.cost.value_decimal(), + dialog.retail.value_decimal(), + dialog.note.toPlainText(), + ) + except journal.ValidationError as exc: + QMessageBox.warning(self, "Не получилось", str(exc)) + return + self.ctx.changed() + + def edit(self) -> None: + product = self._selected() + if product is None: + return + + dialog = ProductDialog(self.ctx.currency, product, parent=self) + if dialog.exec() != QDialog.Accepted: + return + try: + journal.update_product( + self.ctx.vault, + product.id, + name=dialog.name.text(), + unit=dialog.unit.text(), + note=dialog.note.toPlainText(), + ) + except journal.ValidationError as exc: + QMessageBox.warning(self, "Не получилось", str(exc)) + return + self.ctx.changed() + + def edit_prices(self) -> None: + product = self._selected() + if product is None: + return + + dialog = PriceDialog(self.ctx.currency, product, parent=self) + if dialog.exec() != QDialog.Accepted: + return + try: + journal.change_prices( + self.ctx.vault, + product.id, + dialog.cost.value_decimal(), + dialog.retail.value_decimal(), + dialog.date.get_date(), + dialog.note.text(), + ) + except journal.ValidationError as exc: + QMessageBox.warning(self, "Не получилось", str(exc)) + return + self.ctx.changed() + + def toggle_archive(self) -> None: + product = self._selected() + if product is None: + return + journal.update_product(self.ctx.vault, product.id, archived=not product.archived) + self.ctx.changed() + + def delete(self) -> None: + product = self._selected() + if product is None: + return + + confirm = QMessageBox.question( + self, "Удалить товар?", f"Удалить «{product.name}» без возможности вернуть?" + ) + if confirm != QMessageBox.Yes: + return + try: + journal.delete_product(self.ctx.vault, product.id) + except journal.ValidationError as exc: + QMessageBox.warning(self, "Удалить нельзя", str(exc)) + return + self.ctx.changed() diff --git a/app/ui/page_sales.py b/app/ui/page_sales.py new file mode 100644 index 0000000..5fba0e9 --- /dev/null +++ b/app/ui/page_sales.py @@ -0,0 +1,322 @@ +"""Продажи и прочее выбытие: розница, друзья по себестоимости, съел сам.""" + +from __future__ import annotations + +from PySide6.QtWidgets import ( + QComboBox, + QDialog, + QFormLayout, + QLineEdit, + QMessageBox, + QVBoxLayout, + QWidget, +) + +from .. import journal +from .. import money as m +from ..models import ( + CONSUMPTION_KINDS, + KIND_FRIEND, + KIND_RETAIL, + SALE_KIND_LABELS, + SALE_KINDS, +) +from . import theme +from . import widgets as w + + +def price_source_for(kind: str): + """Откуда берётся цена по умолчанию для этого типа выбытия. + + Ради этого типы и заведены: друг платит себестоимость, съеденное не стоит + ничего, розница идёт по цене продажи. + """ + if kind == KIND_FRIEND: + return lambda product: product.cost_price + if kind in CONSUMPTION_KINDS: + return lambda product: m.ZERO + return lambda product: product.retail_price + + +class SaleDialog(QDialog): + def __init__(self, ctx, sale=None, parent=None): + super().__init__(parent) + self.ctx = ctx + self.sale = sale + self.setWindowTitle("Продажа" if sale else "Новая продажа") + self.setMinimumSize(640, 520) + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 16, 20, 16) + layout.setSpacing(10) + + form = QFormLayout() + form.setSpacing(8) + + self.date = w.DateInput(sale.date if sale else None) + form.addRow("Дата", self.date) + + self.kind = QComboBox() + for kind in SALE_KINDS: + self.kind.addItem(SALE_KIND_LABELS[kind], kind) + self.kind.setCurrentIndex(SALE_KINDS.index(sale.kind) if sale else 0) + self.kind.currentIndexChanged.connect(self._on_kind_changed) + form.addRow("Тип", self.kind) + + self.counterparty = QComboBox() + self.counterparty.addItem("— не указан —", None) + for cp in sorted(ctx.vault.doc.counterparties, key=lambda c: c.name.lower()): + self.counterparty.addItem(cp.name, cp.id) + if sale and sale.counterparty_id: + index = self.counterparty.findData(sale.counterparty_id) + if index >= 0: + self.counterparty.setCurrentIndex(index) + form.addRow("Кому", w.row(self.counterparty, w.button("+ Новый", on_click=self._add_counterparty), stretch_at=0)) + + self.note = QLineEdit(sale.note if sale else "") + form.addRow("Заметка", self.note) + layout.addLayout(form) + + layout.addWidget(w.label("Что ушло", "h2")) + products = [p for p in ctx.vault.doc.products if not p.archived] + self.lines = w.LinesEditor( + products, "Цена", price_source_for(self.current_kind()), ctx.currency + ) + self.lines.changed.connect(self._sync_payment_default) + if sale: + self.lines.set_lines(sale.lines, "unit_price") + elif products: + self.lines.add_line() + layout.addWidget(self.lines, 1) + + # Оплату задаём только при создании: у существующей продажи платежи + # живут своей жизнью и правятся на экране долгов. + self.paid = None + if sale is None: + pay_form = QFormLayout() + self.paid = w.MoneySpin(ctx.currency) + pay_form.addRow("Оплачено сразу", self.paid) + layout.addLayout(pay_form) + self.paid_hint = w.label("", "dim") + layout.addWidget(self.paid_hint) + + layout.addWidget( + w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сохранить", "primary", self.accept)) + ) + self._on_kind_changed() + + def current_kind(self) -> str: + return self.kind.currentData() + + def _on_kind_changed(self) -> None: + kind = self.current_kind() + self.lines.price_source = price_source_for(kind) + self.lines.refresh_prices() + + consumption = kind in CONSUMPTION_KINDS + if self.paid is not None: + self.paid.setEnabled(not consumption) + if consumption: + self.paid.set_decimal(0) + self._sync_payment_default() + + def _sync_payment_default(self) -> None: + if self.paid is None: + return + + kind = self.current_kind() + if kind in CONSUMPTION_KINDS: + self.paid_hint.setText("Денег нет по определению, но себестоимость пекарне вернуть надо.") + return + + total = self.lines.total() + # Полная оплата — самый частый случай, поэтому она и подставляется. + # Уменьшил сумму — получил долг, который поедет на экран «Долги». + if not self.paid.hasFocus(): + self.paid.set_decimal(total) + + debt = m.money(total - self.paid.value_decimal()) + if debt > 0: + self.paid_hint.setText(f"В долг уйдёт {m.fmt_money(debt, self.ctx.currency)}.") + else: + self.paid_hint.setText("Оплачено полностью.") + + def _add_counterparty(self) -> None: + from PySide6.QtWidgets import QInputDialog + + name, ok = QInputDialog.getText(self, "Новый контрагент", "Имя") + if not ok or not name.strip(): + return + try: + cp = journal.create_counterparty(self.ctx.vault, name) + except journal.ValidationError as exc: + QMessageBox.warning(self, "Не получилось", str(exc)) + return + self.counterparty.addItem(cp.name, cp.id) + self.counterparty.setCurrentIndex(self.counterparty.count() - 1) + + +class SalesPage(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("Продажи")) + + self.filter = QComboBox() + self.filter.addItem("Все типы", None) + for kind in SALE_KINDS: + self.filter.addItem(SALE_KIND_LABELS[kind], kind) + self.filter.currentIndexChanged.connect(self.refresh) + + layout.addWidget( + w.row( + w.button("+ Продажа", "primary", self.create), + w.button("Изменить", on_click=self.edit), + w.button("Принять оплату", on_click=self.take_payment), + w.button("Удалить", "danger", self.delete), + None, + self.filter, + ) + ) + + self.table = w.table( + ["Дата", "Тип", "Кому", "Что", "Сумма", "Оплачено", "Долг"], stretch_column=3 + ) + self.table.doubleClicked.connect(self.edit) + layout.addWidget(self.table, 1) + + # --- отрисовка --- + + def refresh(self) -> None: + doc = self.ctx.vault.doc + currency = self.ctx.currency + wanted = self.filter.currentData() + + rows, keys = [], [] + for sale in sorted(doc.sales, key=lambda s: s.date, reverse=True): + if wanted and sale.kind != wanted: + continue + + what = ", ".join( + f"{doc.product_name(line.product_id)} × {m.fmt_qty(line.qty)}" + for line in sale.lines + ) + debt = sale.debt + rows.append( + [ + w.text_item(sale.date.strftime("%d.%m.%Y")), + w.text_item( + SALE_KIND_LABELS.get(sale.kind, sale.kind), + theme.MUTED if sale.is_consumption else "", + ), + w.text_item(doc.counterparty_name(sale.counterparty_id)), + w.text_item(what), + w.sortable_num_item(m.fmt_money(sale.total, currency), sale.total), + w.sortable_num_item(m.fmt_money(sale.paid, currency), sale.paid), + w.sortable_num_item( + m.fmt_money(debt, currency) if debt > 0 else "", + debt, + theme.WARN if debt > 0 else "", + ), + ] + ) + keys.append(sale.id) + + w.fill(self.table, rows, keys) + + # --- действия --- + + def _selected(self): + sale_id = w.selected_key(self.table) + sale = self.ctx.vault.doc.sale(sale_id) if sale_id else None + if sale is None: + QMessageBox.information(self, "Не выбрано", "Выбери продажу в списке.") + return sale + + def create(self) -> None: + if not [p for p in self.ctx.vault.doc.products if not p.archived]: + QMessageBox.information(self, "Нет товаров", "Сначала заведи булки на вкладке «Товары».") + return + + dialog = SaleDialog(self.ctx, parent=self) + if dialog.exec() != QDialog.Accepted: + return + try: + journal.create_sale( + self.ctx.vault, + dialog.date.get_date(), + dialog.current_kind(), + dialog.lines.lines("unit_price"), + dialog.counterparty.currentData(), + dialog.note.text(), + dialog.paid.value_decimal(), + ) + except journal.ValidationError as exc: + QMessageBox.warning(self, "Не получилось", str(exc)) + return + self.ctx.changed() + + def edit(self) -> None: + sale = self._selected() + if sale is None: + return + + dialog = SaleDialog(self.ctx, sale, parent=self) + if dialog.exec() != QDialog.Accepted: + return + try: + journal.update_sale( + self.ctx.vault, + sale.id, + dialog.date.get_date(), + dialog.current_kind(), + dialog.lines.lines("unit_price"), + dialog.counterparty.currentData() or "", + dialog.note.text(), + ) + except journal.ValidationError as exc: + QMessageBox.warning(self, "Не получилось", str(exc)) + return + self.ctx.changed() + + def take_payment(self) -> None: + sale = self._selected() + if sale is None: + return + if sale.debt <= 0: + QMessageBox.information(self, "Долга нет", "Эта продажа уже оплачена.") + return + + from .page_debts import ask_payment + + amount = ask_payment(self, sale.debt, self.ctx.currency) + if amount is None: + return + try: + journal.add_sale_payment(self.ctx.vault, sale.id, amount) + except journal.ValidationError as exc: + QMessageBox.warning(self, "Не получилось", str(exc)) + return + self.ctx.changed() + + def delete(self) -> None: + sale = self._selected() + if sale is None: + return + + confirm = QMessageBox.question( + self, "Удалить продажу?", "Товар вернётся в остатки, деньги — исчезнут из учёта." + ) + if confirm != QMessageBox.Yes: + return + try: + journal.delete_sale(self.ctx.vault, sale.id) + except journal.ValidationError as exc: + QMessageBox.warning(self, "Не получилось", str(exc)) + return + self.ctx.changed() diff --git a/app/ui/page_settings.py b/app/ui/page_settings.py new file mode 100644 index 0000000..66d6a4b --- /dev/null +++ b/app/ui/page_settings.py @@ -0,0 +1,362 @@ +"""Настройки: 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() diff --git a/app/ui/theme.py b/app/ui/theme.py new file mode 100644 index 0000000..38537b9 --- /dev/null +++ b/app/ui/theme.py @@ -0,0 +1,181 @@ +"""Тёмная тема. + +Палитра задаётся через QPalette, а не только через QSS: иначе системные +диалоги и всплывающие подсказки остались бы светлыми и резали глаза. +""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QPalette + +BG = "#1c1f24" +BG_ALT = "#23272e" +SURFACE = "#2b3038" +BORDER = "#3a4048" +TEXT = "#e6e8ea" +TEXT_DIM = "#98a0aa" +ACCENT = "#4c9aff" + +OK = "#4caf7d" +WARN = "#d9a441" +DANGER = "#e0605e" +MUTED = "#6c757d" + +STATUS_COLORS = { + "settled": OK, + "open": ACCENT, + "due_soon": WARN, + "overdue": DANGER, +} + + +def apply(app) -> None: + app.setStyle("Fusion") + + palette = QPalette() + palette.setColor(QPalette.Window, QColor(BG)) + palette.setColor(QPalette.WindowText, QColor(TEXT)) + palette.setColor(QPalette.Base, QColor(BG_ALT)) + palette.setColor(QPalette.AlternateBase, QColor(SURFACE)) + palette.setColor(QPalette.Text, QColor(TEXT)) + palette.setColor(QPalette.Button, QColor(SURFACE)) + palette.setColor(QPalette.ButtonText, QColor(TEXT)) + palette.setColor(QPalette.ToolTipBase, QColor(SURFACE)) + palette.setColor(QPalette.ToolTipText, QColor(TEXT)) + palette.setColor(QPalette.Highlight, QColor(ACCENT)) + palette.setColor(QPalette.HighlightedText, QColor("#ffffff")) + palette.setColor(QPalette.PlaceholderText, QColor(TEXT_DIM)) + palette.setColor(QPalette.Link, QColor(ACCENT)) + palette.setColor(QPalette.Disabled, QPalette.Text, QColor(MUTED)) + palette.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(MUTED)) + palette.setColor(QPalette.Disabled, QPalette.WindowText, QColor(MUTED)) + app.setPalette(palette) + + app.setStyleSheet(QSS) + + +QSS = f""" +QWidget {{ + font-size: 13px; +}} + +QLabel[role="h1"] {{ font-size: 21px; font-weight: 600; }} +QLabel[role="h2"] {{ font-size: 15px; font-weight: 600; }} +QLabel[role="dim"] {{ color: {TEXT_DIM}; }} +QLabel[role="metric"] {{ font-size: 24px; font-weight: 600; }} + +QFrame[role="card"] {{ + background: {SURFACE}; + border: 1px solid {BORDER}; + border-radius: 8px; +}} + +QFrame[role="banner"] {{ + background: #3a2f1c; + border: 1px solid {WARN}; + border-radius: 6px; +}} + +QListWidget#sidebar {{ + background: {BG_ALT}; + border: none; + border-right: 1px solid {BORDER}; + outline: none; + padding-top: 8px; +}} +QListWidget#sidebar::item {{ + padding: 10px 16px; + margin: 1px 6px; + border-radius: 6px; + color: {TEXT_DIM}; +}} +QListWidget#sidebar::item:selected {{ + background: {SURFACE}; + color: {TEXT}; + font-weight: 600; +}} +QListWidget#sidebar::item:hover {{ color: {TEXT}; }} + +QPushButton {{ + background: {SURFACE}; + border: 1px solid {BORDER}; + border-radius: 6px; + padding: 6px 14px; +}} +QPushButton:hover {{ border-color: {ACCENT}; }} +QPushButton:disabled {{ color: {MUTED}; border-color: {BORDER}; }} +QPushButton[role="primary"] {{ + background: {ACCENT}; + border-color: {ACCENT}; + color: #ffffff; + font-weight: 600; +}} +QPushButton[role="primary"]:hover {{ background: #5aa6ff; }} +QPushButton[role="danger"]:hover {{ border-color: {DANGER}; color: {DANGER}; }} + +QLineEdit, QDateEdit, QComboBox, QSpinBox, QDoubleSpinBox, QPlainTextEdit, QTextEdit {{ + background: {BG_ALT}; + border: 1px solid {BORDER}; + border-radius: 6px; + padding: 5px 8px; + selection-background-color: {ACCENT}; +}} +QLineEdit:focus, QDateEdit:focus, QComboBox:focus, +QSpinBox:focus, QDoubleSpinBox:focus, QPlainTextEdit:focus {{ + border-color: {ACCENT}; +}} +QComboBox::drop-down {{ border: none; width: 18px; }} +QComboBox QAbstractItemView {{ + background: {BG_ALT}; + border: 1px solid {BORDER}; + selection-background-color: {ACCENT}; +}} + +QTableWidget, QTreeWidget {{ + background: {BG_ALT}; + alternate-background-color: {SURFACE}; + border: 1px solid {BORDER}; + border-radius: 6px; + gridline-color: {BORDER}; + selection-background-color: {ACCENT}; +}} +QHeaderView::section {{ + background: {SURFACE}; + color: {TEXT_DIM}; + border: none; + border-bottom: 1px solid {BORDER}; + padding: 7px 8px; + font-weight: 600; +}} +QTableWidget::item, QTreeWidget::item {{ padding: 4px 6px; }} + +QProgressBar {{ + background: {BG}; + border: 1px solid {BORDER}; + border-radius: 6px; + height: 14px; + text-align: center; + color: {TEXT}; +}} +QProgressBar::chunk {{ border-radius: 5px; }} + +QStatusBar {{ border-top: 1px solid {BORDER}; }} +QStatusBar::item {{ border: none; }} + +QScrollBar:vertical {{ background: transparent; width: 10px; margin: 0; }} +QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 5px; min-height: 30px; }} +QScrollBar::handle:vertical:hover {{ background: {MUTED}; }} +QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; width: 0; }} +QScrollBar:horizontal {{ background: transparent; height: 10px; }} +QScrollBar::handle:horizontal {{ background: {BORDER}; border-radius: 5px; min-width: 30px; }} + +QTabWidget::pane {{ border: 1px solid {BORDER}; border-radius: 6px; }} +QTabBar::tab {{ + background: transparent; + padding: 7px 14px; + color: {TEXT_DIM}; + border-bottom: 2px solid transparent; +}} +QTabBar::tab:selected {{ color: {TEXT}; border-bottom-color: {ACCENT}; }} +""" diff --git a/app/ui/widgets.py b/app/ui/widgets.py new file mode 100644 index 0000000..0bcb172 --- /dev/null +++ b/app/ui/widgets.py @@ -0,0 +1,436 @@ +"""Общие элементы интерфейса. + +Здесь всё, что иначе копировалось бы по экранам: поля денег и количеств, +настроенные таблицы и редактор строк документа. +""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +from PySide6.QtCore import QDate, Qt, Signal +from PySide6.QtGui import QColor, QFont +from PySide6.QtWidgets import ( + QComboBox, + QCompleter, + QDateEdit, + QDoubleSpinBox, + QFrame, + QHBoxLayout, + QHeaderView, + QLabel, + QPushButton, + QSizePolicy, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from .. import money as m +from . import theme + +MAX_MONEY = 99_999_999.0 + + +# --- поля ввода ----------------------------------------------------------- + + +class MoneySpin(QDoubleSpinBox): + def __init__(self, currency: str = "₽", parent=None): + super().__init__(parent) + self.setDecimals(2) + self.setRange(0, MAX_MONEY) + self.setGroupSeparatorShown(True) + self.setSuffix(f" {currency}" if currency else "") + self.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + + def value_decimal(self) -> Decimal: + return m.money(self.value()) + + def set_decimal(self, value) -> None: + self.setValue(float(m.money(value))) + + +class QtySpin(QDoubleSpinBox): + def __init__(self, parent=None): + super().__init__(parent) + self.setDecimals(3) + self.setRange(0, 1_000_000) + self.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + # Дробные количества нужны для веса, но булки штучные — прячем нули. + self.setStepType(QDoubleSpinBox.DefaultStepType) + + def textFromValue(self, value: float) -> str: + return m.fmt_qty(value) + + def valueFromText(self, text: str) -> float: + return float(m.qty(text)) + + def value_decimal(self) -> Decimal: + return m.qty(self.value()) + + def set_decimal(self, value) -> None: + self.setValue(float(m.qty(value))) + + +class DateInput(QDateEdit): + def __init__(self, value: date | None = None, parent=None): + super().__init__(parent) + self.setCalendarPopup(True) + self.setDisplayFormat("dd.MM.yyyy") + self.set_date(value or date.today()) + + def set_date(self, value: date) -> None: + self.setDate(QDate(value.year, value.month, value.day)) + + def get_date(self) -> date: + qd = self.date() + return date(qd.year(), qd.month(), qd.day()) + + +class ProductCombo(QComboBox): + """Выпадающий список товаров с поиском по набранному тексту.""" + + def __init__(self, products, parent=None): + super().__init__(parent) + self.setEditable(True) + self.setInsertPolicy(QComboBox.NoInsert) + # Поиск по подстроке, а не только по началу: «мак» должен находить + # «Булка с маком». + completer = self.completer() + if completer is not None: + completer.setFilterMode(Qt.MatchContains) + completer.setCompletionMode(QCompleter.PopupCompletion) + for product in products: + self.addItem(product.name, product.id) + + def current_product_id(self) -> str | None: + return self.currentData() + + def select_product(self, product_id: str) -> None: + index = self.findData(product_id) + if index >= 0: + self.setCurrentIndex(index) + + +# --- мелкие сборки -------------------------------------------------------- + + +def label(text: str, role: str = "", color: str = "") -> QLabel: + widget = QLabel(text) + if role: + widget.setProperty("role", role) + if color: + widget.setStyleSheet(f"color: {color};") + return widget + + +def heading(text: str) -> QLabel: + return label(text, "h1") + + +def card() -> QFrame: + frame = QFrame() + frame.setProperty("role", "card") + return frame + + +def button(text: str, role: str = "", on_click=None) -> QPushButton: + widget = QPushButton(text) + if role: + widget.setProperty("role", role) + if on_click is not None: + widget.clicked.connect(on_click) + return widget + + +def row(*widgets, stretch_at: int | None = None) -> QWidget: + holder = QWidget() + layout = QHBoxLayout(holder) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + for index, widget in enumerate(widgets): + if widget is None: + layout.addStretch(1) + continue + layout.addWidget(widget) + if index == stretch_at: + layout.setStretch(index, 1) + return holder + + +def metric_card(title: str, value: str, hint: str = "", color: str = "") -> QFrame: + """Крупная цифра для сводки.""" + frame = card() + layout = QVBoxLayout(frame) + layout.setContentsMargins(14, 12, 14, 12) + layout.setSpacing(2) + layout.addWidget(label(title, "dim")) + layout.addWidget(label(value, "metric", color)) + if hint: + layout.addWidget(label(hint, "dim")) + frame.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + return frame + + +def warning_banner(messages: list[str]) -> QFrame: + frame = QFrame() + frame.setProperty("role", "banner") + layout = QVBoxLayout(frame) + layout.setContentsMargins(12, 10, 12, 10) + layout.setSpacing(3) + for text in messages: + item = QLabel(f"⚠ {text}") + item.setWordWrap(True) + layout.addWidget(item) + return frame + + +# --- таблицы -------------------------------------------------------------- + + +def table(headers: list[str], stretch_column: int = 0) -> QTableWidget: + widget = QTableWidget(0, len(headers)) + widget.setHorizontalHeaderLabels(headers) + widget.verticalHeader().setVisible(False) + widget.setAlternatingRowColors(True) + widget.setSelectionBehavior(QTableWidget.SelectRows) + widget.setSelectionMode(QTableWidget.SingleSelection) + widget.setEditTriggers(QTableWidget.NoEditTriggers) + widget.setSortingEnabled(True) + widget.setWordWrap(False) + + header = widget.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeToContents) + header.setSectionResizeMode(stretch_column, QHeaderView.Stretch) + header.setHighlightSections(False) + return widget + + +def text_item(text: str, color: str = "", bold: bool = False) -> QTableWidgetItem: + item = QTableWidgetItem(text) + if color: + item.setForeground(QColor(color)) + if bold: + font = item.font() + font.setBold(True) + item.setFont(font) + return item + + +def num_item(text: str, value, color: str = "", bold: bool = False) -> QTableWidgetItem: + """Ячейка с числом. + + Отображаемый текст отформатирован, а сортировка идёт по настоящему + значению — иначе «1 200 ₽» сортировалось бы как строка и оказалось бы + меньше «900 ₽». + """ + item = text_item(text, color, bold) + item.setData(Qt.UserRole + 1, float(value)) + item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + return item + + +class SortableItem(QTableWidgetItem): + def __lt__(self, other): + mine = self.data(Qt.UserRole + 1) + theirs = other.data(Qt.UserRole + 1) + if mine is not None and theirs is not None: + return mine < theirs + return super().__lt__(other) + + +def sortable_num_item(text: str, value, color: str = "", bold: bool = False) -> SortableItem: + item = SortableItem(text) + if color: + item.setForeground(QColor(color)) + if bold: + font = item.font() + font.setBold(True) + item.setFont(font) + item.setData(Qt.UserRole + 1, float(value)) + item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + return item + + +def fill(widget: QTableWidget, rows: list[list[QTableWidgetItem]], keys: list = None) -> None: + """Заполнить таблицу, сохранив идентификатор строки в UserRole нулевой ячейки.""" + was_sorting = widget.isSortingEnabled() + widget.setSortingEnabled(False) + widget.setRowCount(len(rows)) + for r, cells in enumerate(rows): + for c, cell in enumerate(cells): + widget.setItem(r, c, cell) + if keys is not None and cells: + cells[0].setData(Qt.UserRole, keys[r]) + widget.setSortingEnabled(was_sorting) + + +def selected_key(widget: QTableWidget): + row_index = widget.currentRow() + if row_index < 0: + return None + item = widget.item(row_index, 0) + return item.data(Qt.UserRole) if item else None + + +# --- редактор строк документа --------------------------------------------- + + +class LinesEditor(QWidget): + """Таблица строк закупки или продажи. + + Один виджет на оба случая: отличается только подпись колонки цены и то, + откуда берётся подстановка — себестоимость или цена продажи. + """ + + changed = Signal() + + COL_PRODUCT, COL_QTY, COL_PRICE, COL_TOTAL = range(4) + + def __init__(self, products, price_title: str, price_source, currency: str = "₽", parent=None): + super().__init__(parent) + self.products = list(products) + self.price_source = price_source + self.currency = currency + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + self.table = QTableWidget(0, 4) + self.table.setHorizontalHeaderLabels(["Товар", "Кол-во", price_title, "Сумма"]) + self.table.verticalHeader().setVisible(False) + header = self.table.horizontalHeader() + header.setSectionResizeMode(self.COL_PRODUCT, QHeaderView.Stretch) + for column in (self.COL_QTY, self.COL_PRICE, self.COL_TOTAL): + header.setSectionResizeMode(column, QHeaderView.ResizeToContents) + self.table.setMinimumHeight(180) + layout.addWidget(self.table) + + self.total_label = label("", "h2") + layout.addWidget( + row( + button("+ Строка", on_click=self.add_line), + button("Удалить строку", role="danger", on_click=self.remove_current), + None, + self.total_label, + ) + ) + + # --- содержимое --- + + def add_line(self, product_id: str | None = None, quantity=1, price=None) -> None: + if not self.products: + return + + r = self.table.rowCount() + self.table.insertRow(r) + + combo = ProductCombo(self.products) + if product_id: + combo.select_product(product_id) + combo.currentIndexChanged.connect(lambda _, rr=r: self._on_product_changed(rr)) + self.table.setCellWidget(r, self.COL_PRODUCT, combo) + + qty_spin = QtySpin() + qty_spin.set_decimal(quantity) + qty_spin.valueChanged.connect(self._recalc) + self.table.setCellWidget(r, self.COL_QTY, qty_spin) + + price_spin = MoneySpin(self.currency) + price_spin.set_decimal( + price if price is not None else self._suggested_price(combo.current_product_id()) + ) + price_spin.valueChanged.connect(self._recalc) + self.table.setCellWidget(r, self.COL_PRICE, price_spin) + + self.table.setItem(r, self.COL_TOTAL, text_item("")) + self._recalc() + + def remove_current(self) -> None: + r = self.table.currentRow() + if r >= 0: + self.table.removeRow(r) + self._recalc() + + def set_lines(self, lines: list, value_attr: str) -> None: + self.table.setRowCount(0) + for line in lines: + self.add_line(line.product_id, line.qty, getattr(line, value_attr)) + + def lines(self, value_key: str) -> list[dict]: + result = [] + for r in range(self.table.rowCount()): + combo = self.table.cellWidget(r, self.COL_PRODUCT) + product_id = combo.current_product_id() if combo else None + if not product_id: + continue + result.append( + { + "product_id": product_id, + "qty": self.table.cellWidget(r, self.COL_QTY).value_decimal(), + value_key: self.table.cellWidget(r, self.COL_PRICE).value_decimal(), + } + ) + return result + + def total(self) -> Decimal: + total = m.ZERO + for r in range(self.table.rowCount()): + qty_widget = self.table.cellWidget(r, self.COL_QTY) + price_widget = self.table.cellWidget(r, self.COL_PRICE) + if qty_widget and price_widget: + total += qty_widget.value_decimal() * price_widget.value_decimal() + return m.money(total) + + def refresh_prices(self) -> None: + """Пересобрать подстановку цен — например, после смены типа продажи.""" + for r in range(self.table.rowCount()): + combo = self.table.cellWidget(r, self.COL_PRODUCT) + price_widget = self.table.cellWidget(r, self.COL_PRICE) + if combo and price_widget: + price_widget.set_decimal(self._suggested_price(combo.current_product_id())) + self._recalc() + + # --- внутреннее --- + + def _suggested_price(self, product_id: str | None) -> Decimal: + product = next((p for p in self.products if p.id == product_id), None) + return self.price_source(product) if product else m.ZERO + + def _on_product_changed(self, r: int) -> None: + combo = self.table.cellWidget(r, self.COL_PRODUCT) + price_widget = self.table.cellWidget(r, self.COL_PRICE) + if combo and price_widget: + price_widget.set_decimal(self._suggested_price(combo.current_product_id())) + self._recalc() + + def _recalc(self) -> None: + for r in range(self.table.rowCount()): + qty_widget = self.table.cellWidget(r, self.COL_QTY) + price_widget = self.table.cellWidget(r, self.COL_PRICE) + if not qty_widget or not price_widget: + continue + total = m.money(qty_widget.value_decimal() * price_widget.value_decimal()) + item = self.table.item(r, self.COL_TOTAL) + if item is None: + item = text_item("") + self.table.setItem(r, self.COL_TOTAL, item) + item.setText(m.fmt_money(total, self.currency)) + item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + + self.total_label.setText(f"Итого: {m.fmt_money(self.total(), self.currency)}") + self.changed.emit() + + +def progress_color(status: str) -> str: + return theme.STATUS_COLORS.get(status, theme.ACCENT) + + +def monospace(widget) -> None: + font = QFont("Consolas") + font.setStyleHint(QFont.Monospace) + widget.setFont(font) diff --git a/food-market.code-workspace b/food-market.code-workspace new file mode 100644 index 0000000..ab48a50 --- /dev/null +++ b/food-market.code-workspace @@ -0,0 +1,12 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "workbench.editorAssociations": { + "*.sprite": "d2rmodding.spriteViewer" + } + } +} \ No newline at end of file diff --git a/food-market.spec b/food-market.spec new file mode 100644 index 0000000..5bc73ee --- /dev/null +++ b/food-market.spec @@ -0,0 +1,89 @@ +# -*- mode: python ; coding: utf-8 -*- +"""Сборка одного exe: pyinstaller food-market.spec + +Готовый food-market.exe кладётся в корень клона репозитория — рядом с папкой +data, где лежит зашифрованная база. Приложение ищет data/vault.fmdb +относительно самого exe (см. app/paths.py). +""" + +# Qt тянет за собой много того, что этому приложению не нужно. Без чистки +# onefile раздувается до ~150 МБ, с ней укладывается примерно вдвое меньше. +EXCLUDES = [ + "PySide6.QtWebEngineCore", + "PySide6.QtWebEngineWidgets", + "PySide6.QtWebEngineQuick", + "PySide6.QtQuick", + "PySide6.QtQuick3D", + "PySide6.QtQuickWidgets", + "PySide6.QtQml", + "PySide6.Qt3DCore", + "PySide6.Qt3DRender", + "PySide6.Qt3DInput", + "PySide6.Qt3DLogic", + "PySide6.Qt3DAnimation", + "PySide6.Qt3DExtras", + "PySide6.QtMultimedia", + "PySide6.QtMultimediaWidgets", + "PySide6.QtCharts", + "PySide6.QtDataVisualization", + "PySide6.QtBluetooth", + "PySide6.QtNfc", + "PySide6.QtPositioning", + "PySide6.QtSerialPort", + "PySide6.QtSensors", + "PySide6.QtTest", + "PySide6.QtSql", + "PySide6.QtDesigner", + "PySide6.QtHelp", + "PySide6.QtUiTools", + "PySide6.QtPdf", + "PySide6.QtPdfWidgets", + "PySide6.QtSpatialAudio", + "PySide6.QtTextToSpeech", + "PySide6.QtWebChannel", + "PySide6.QtWebSockets", + # Не наши зависимости — если утащатся транзитом, только раздуют файл. + "tkinter", + "unittest", + "pydoc", + "pytest", + "numpy", + "matplotlib", + "PIL", +] + +a = Analysis( + ["run.py"], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + runtime_hooks=[], + excludes=EXCLUDES, + noarchive=False, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="food-market", + debug=False, + bootloader_ignore_signals=False, + strip=False, + # UPX выключен намеренно: сжатые им exe регулярно ловят ложные + # срабатывания антивирусов, а выигрыш в размере того не стоит. + upx=False, + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a19f9d3 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=8.0 +pyinstaller>=6.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d093a7f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PySide6>=6.7 +cryptography>=42.0 diff --git a/run.py b/run.py new file mode 100644 index 0000000..c7725f5 --- /dev/null +++ b/run.py @@ -0,0 +1,12 @@ +"""Точка входа для запуска и для сборки exe. + +Отдельный файл нужен потому, что app/main.py пользуется относительными +импортами: как скрипт он не запускается, а как модуль пакета — да. +""" + +import sys + +from app.main import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..eb7abef --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +from datetime import date, timedelta + +import pytest + +from app import journal +from app.storage import Vault + + +@pytest.fixture +def vault(tmp_path): + return Vault.create(tmp_path / "vault.fmdb", "pw") + + +@pytest.fixture +def buns(vault): + """Три булки из примера пользователя, с фиксированными ценами поставщика.""" + return { + "повидло": journal.create_product(vault, "Булка с повидлом", cost_price=30, retail_price=50), + "мак": journal.create_product(vault, "Булка с маком", cost_price=20, retail_price=35), + "корица": journal.create_product(vault, "Булка с корицей", cost_price=25, retail_price=40), + } + + +@pytest.fixture +def today(): + return date.today() + + +@pytest.fixture +def in_two_weeks(today): + return today + timedelta(days=14) diff --git a/tests/test_crypto.py b/tests/test_crypto.py new file mode 100644 index 0000000..7a5cbd7 --- /dev/null +++ b/tests/test_crypto.py @@ -0,0 +1,78 @@ +import pytest + +from app import crypto + + +def test_round_trip(): + data = '{"привет": "мир"}'.encode("utf-8") + blob = crypto.encrypt(data, "пароль123") + assert crypto.decrypt(blob, "пароль123") == data + + +def test_wrong_password(): + blob = crypto.encrypt(b"secret", "правильный") + with pytest.raises(crypto.WrongPassword): + crypto.decrypt(blob, "неправильный") + + +def test_empty_password_still_works(): + blob = crypto.encrypt(b"secret", "") + assert crypto.decrypt(blob, "") == b"secret" + + +def test_same_data_gives_different_bytes(): + """Соль и nonce случайны, поэтому шифртекст не детерминирован. + + Это причина, по которой storage сравнивает хеш открытого JSON, а не байты + файла — иначе git получал бы новый коммит на каждое сохранение. + """ + a = crypto.encrypt(b"same", "pw") + b = crypto.encrypt(b"same", "pw") + assert a != b + assert crypto.decrypt(a, "pw") == crypto.decrypt(b, "pw") + + +def test_header_is_authenticated(): + """Подмена соли в заголовке ловится как ошибка, а не молча меняет ключ.""" + blob = bytearray(crypto.encrypt(b"secret", "pw")) + blob[9] ^= 0xFF # первый байт соли + with pytest.raises(crypto.WrongPassword): + crypto.decrypt(bytes(blob), "pw") + + +def test_tampered_ciphertext_rejected(): + blob = bytearray(crypto.encrypt(b"secret", "pw")) + blob[-1] ^= 0xFF + with pytest.raises(crypto.WrongPassword): + crypto.decrypt(bytes(blob), "pw") + + +def test_not_a_vault_file(): + with pytest.raises(crypto.UnsupportedFormat): + crypto.decrypt(b"\x00" * 100, "pw") + + +def test_truncated_file(): + with pytest.raises(crypto.UnsupportedFormat): + crypto.decrypt(b"FMDB\x01", "pw") + + +def test_future_format_version_reports_clearly(): + blob = bytearray(crypto.encrypt(b"secret", "pw")) + blob[4] = 99 + with pytest.raises(crypto.UnsupportedFormat, match="новой версией"): + crypto.decrypt(bytes(blob), "pw") + + +def test_absurd_scrypt_params_rejected_without_allocating(): + """Чужой файл не должен заставить нас выделить гигабайты под scrypt.""" + blob = bytearray(crypto.encrypt(b"secret", "pw")) + blob[6] = 40 # log2(n) = 40 + with pytest.raises(crypto.UnsupportedFormat): + crypto.decrypt(bytes(blob), "pw") + + +def test_compression_actually_helps(): + data = b'{"bun": "poppy"}' * 500 + blob = crypto.encrypt(data, "pw") + assert len(blob) < len(data) // 4 diff --git a/tests/test_gitsync.py b/tests/test_gitsync.py new file mode 100644 index 0000000..4d495b1 --- /dev/null +++ b/tests/test_gitsync.py @@ -0,0 +1,282 @@ +"""Тесты синхронизации против локального bare-репозитория — без сети.""" + +import subprocess + +import pytest + +from app import gitsync +from app.gitsync import Diverged, GitSync, auth_url, commit_message, scrub +from app.models import GitSettings + +pytestmark = pytest.mark.skipif( + gitsync.git_executable() is None, reason="git не установлен" +) + +REL = "data/vault.fmdb" + + +def git(cwd, *args): + proc = subprocess.run( + ["git", *args], cwd=cwd, capture_output=True, text=True, encoding="utf-8" + ) + assert proc.returncode == 0, proc.stderr + return proc.stdout.strip() + + +@pytest.fixture +def remote(tmp_path): + path = tmp_path / "remote.git" + path.mkdir() + git(path, "init", "--bare", "-b", "main") + return path + + +@pytest.fixture +def sync(tmp_path, remote): + work = tmp_path / "work" + (work / "data").mkdir(parents=True) + (work / "data" / "vault.fmdb").write_bytes(b"encrypted-v1") + settings = GitSettings(remote_url=remote.as_posix(), branch="main") + return GitSync(work, REL, settings) + + +def write_vault(sync, content: bytes): + (sync.repo_dir / REL).write_bytes(content) + + +def remote_log(remote): + return git(remote, "log", "--format=%H %s").splitlines() + + +def remote_vault(remote): + proc = subprocess.run( + ["git", "show", f"main:{REL}"], cwd=remote, capture_output=True + ) + assert proc.returncode == 0, proc.stderr + return proc.stdout + + +# --- базовый цикл --------------------------------------------------------- + + +def test_first_sync_creates_repo_and_pushes(sync, remote): + result = sync.sync() + assert result.committed and result.pushed + assert remote_vault(remote) == b"encrypted-v1" + assert len(remote_log(remote)) == 1 + + +def test_sync_without_changes_does_nothing(sync, remote): + sync.sync() + result = sync.sync() + assert result.committed is False + assert result.pushed is False + assert len(remote_log(remote)) == 1 + + +def test_commit_touches_only_the_vault(sync, remote): + """Незакоммиченные правки исходников не должны уезжать вместе с данными.""" + (sync.repo_dir / "app.py").write_text("print('работа в процессе')", encoding="utf-8") + (sync.repo_dir / "notes.txt").write_text("черновик", encoding="utf-8") + + sync.sync() + files = git(remote, "show", "--name-only", "--format=", "main").split() + assert files == [REL] + + +def test_commit_message_leaks_no_business_numbers(sync, remote): + sync.sync() + subject = remote_log(remote)[0].split(" ", 1)[1] + assert subject == commit_message() + assert subject.startswith("vault ") + + +# --- схлопывание коммитов по дням ----------------------------------------- + + +def test_same_day_syncs_collapse_into_one_commit(sync, remote): + """Ради этого всё и затевалось: не 24 копии файла в сутки, а одна.""" + sync.sync() + for i in range(2, 5): + write_vault(sync, f"encrypted-v{i}".encode()) + result = sync.sync() + assert result.amended is True + + assert len(remote_log(remote)) == 1 + assert remote_vault(remote) == b"encrypted-v4" + + +def test_squash_can_be_turned_off(sync, remote): + sync.settings.daily_squash = False + sync.sync() + write_vault(sync, b"encrypted-v2") + result = sync.sync() + + assert result.amended is False + assert len(remote_log(remote)) == 2 + + +def test_no_amend_when_head_is_not_on_the_server(sync): + """Переписывать можно только ту вершину, которая точно наша и уже запушена.""" + sync.ensure_repo() + sync._run(["add", "--", REL]) + sync._run(["commit", "-m", commit_message(), "--", REL]) + + assert sync._can_amend(remote=None) is False + assert sync._can_amend(remote="0" * 40) is False + + +def test_no_amend_over_a_foreign_commit(sync): + """Чужой коммит не наш формат — трогать его нельзя.""" + sync.ensure_repo() + (sync.repo_dir / "readme.md").write_text("привет", encoding="utf-8") + sync._run(["add", "-A"]) + sync._run(["commit", "-m", "правки руками"]) + + assert sync._can_amend(remote=sync.head_sha()) is False + + +def test_no_amend_over_a_commit_touching_other_files(sync): + """Amend утащил бы чужие изменения из той же вершины.""" + sync.ensure_repo() + (sync.repo_dir / "readme.md").write_text("привет", encoding="utf-8") + sync._run(["add", "-A"]) + sync._run(["commit", "-m", commit_message()]) + + assert sync._can_amend(remote=sync.head_sha()) is False + + +def test_yesterdays_commit_is_not_amended(sync, remote): + sync.sync() + sync._run(["commit", "--amend", "-m", "vault 2020-01-01"]) + assert sync._can_amend(remote=sync.head_sha()) is False + + +# --- защита от затирания чужой работы ------------------------------------- + + +def test_stale_lease_is_refused_and_remote_survives(sync, remote, tmp_path): + """Ключевая проверка безопасности схлопывания. + + Между нашим fetch и push другая машина успела запушить своё. Мы уже + переписали локальную вершину — force-with-lease обязан это поймать. + """ + sync.sync() + stale = sync.head_sha() + + other = tmp_path / "other" + git(tmp_path, "clone", remote.as_posix(), "other") + (other / "data" / "vault.fmdb").write_bytes(b"encrypted-from-other-machine") + git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01") + git(other, "push", "origin", "main") + theirs = git(other, "rev-parse", "HEAD") + + write_vault(sync, b"encrypted-ours") + committed, amended, lease = sync._commit(remote=stale) + assert (committed, amended, lease) == (True, True, stale) + + with pytest.raises(Diverged): + sync._push(amended=True, lease=lease) + + # Чужой коммит на месте, наш форс его не снёс. + assert git(remote, "rev-parse", "main") == theirs + assert remote_vault(remote) == b"encrypted-from-other-machine" + + +def test_remote_ahead_and_clean_fast_forwards(sync, remote, tmp_path): + sync.sync() + + other = tmp_path / "other" + git(tmp_path, "clone", remote.as_posix(), "other") + (other / "data" / "vault.fmdb").write_bytes(b"encrypted-from-other-machine") + git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01") + git(other, "push", "origin", "main") + + result = sync.sync() + assert result.pulled is True + assert (sync.repo_dir / REL).read_bytes() == b"encrypted-from-other-machine" + + +def test_edits_on_both_machines_raise_and_keep_both_copies(sync, remote, tmp_path): + """Ничего не сливаем и ничего не теряем — решение за пользователем.""" + sync.sync() + + other = tmp_path / "other" + git(tmp_path, "clone", remote.as_posix(), "other") + (other / "data" / "vault.fmdb").write_bytes(b"encrypted-from-other-machine") + git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01") + git(other, "push", "origin", "main") + + write_vault(sync, b"encrypted-ours") + copy = sync.repo_dir / "data" / "vault.remote.fmdb" + + with pytest.raises(Diverged): + sync.sync(remote_copy_path=copy) + + assert (sync.repo_dir / REL).read_bytes() == b"encrypted-ours" + assert copy.read_bytes() == b"encrypted-from-other-machine" + + +# --- обращение с токеном -------------------------------------------------- + + +def test_auth_url_injects_token(): + url = auth_url("https://gt.ser.gay/kizya/food-market.git", "glpat-secret") + assert url == "https://oauth2:glpat-secret@gt.ser.gay/kizya/food-market.git" + + +def test_auth_url_replaces_existing_credentials(): + url = auth_url("https://old:creds@gt.ser.gay/kizya/food-market.git", "glpat-new") + assert url == "https://oauth2:glpat-new@gt.ser.gay/kizya/food-market.git" + + +def test_auth_url_escapes_special_characters(): + url = auth_url("https://gt.ser.gay/x.git", "a/b@c:d") + assert "a%2Fb%40c%3Ad" in url + assert url.count("@") == 1 + + +def test_auth_url_left_alone_for_local_paths(): + assert auth_url("/srv/repo.git", "glpat-secret") == "/srv/repo.git" + + +def test_token_is_not_written_into_git_config(sync): + sync.settings.token = "glpat-secret" + sync.ensure_repo() + config = (sync.repo_dir / ".git" / "config").read_text(encoding="utf-8") + assert "glpat-secret" not in config + + +def test_scrub_hides_token_in_messages(): + assert scrub("fatal: https://oauth2:glpat-x@host", "glpat-x") == "fatal: https://oauth2:***@host" + + +def test_errors_do_not_leak_the_token(sync): + sync.settings.remote_url = "https://127.0.0.1:1/nope.git" + sync.settings.token = "glpat-supersecret" + with pytest.raises(gitsync.GitError) as exc: + sync.sync() + assert "glpat-supersecret" not in str(exc.value) + + +# --- прочее --------------------------------------------------------------- + + +def test_sync_without_remote_is_a_clear_error(sync): + sync.settings.remote_url = "" + with pytest.raises(gitsync.GitError, match="адрес репозитория"): + sync.sync() + + +def test_ensure_repo_is_idempotent(sync): + sync.ensure_repo() + sync.ensure_repo() + assert sync.is_repo() + assert git(sync.repo_dir, "remote", "get-url", "origin") + + +def test_ensure_repo_updates_a_changed_remote(sync, tmp_path): + sync.ensure_repo() + sync.settings.remote_url = (tmp_path / "elsewhere.git").as_posix() + sync.ensure_repo() + assert git(sync.repo_dir, "remote", "get-url", "origin").endswith("elsewhere.git") diff --git a/tests/test_journal.py b/tests/test_journal.py new file mode 100644 index 0000000..a32f0c2 --- /dev/null +++ b/tests/test_journal.py @@ -0,0 +1,267 @@ +import json +from datetime import date, timedelta + +import pytest + +from app import journal +from app.models import KIND_FRIEND, KIND_RETAIL, KIND_SELF + + +def actions(vault): + return [e.action for e in vault.doc.journal] + + +def last(vault): + return vault.doc.journal[-1] + + +def changed(entry): + return {c.field: (c.old, c.new) for c in entry.changes} + + +# --- запись вообще происходит --------------------------------------------- + + +def test_create_product_is_logged(vault): + journal.create_product(vault, "Булка с маком", cost_price=20, retail_price=35) + entry = last(vault) + assert entry.action == "product.create" + assert entry.label == "Булка с маком" + assert changed(entry)["себестоимость"][1] == "20,00 ₽" + + +def test_price_change_records_old_and_new(vault, buns): + journal.change_prices(vault, buns["мак"].id, cost_price=25, retail_price=40) + entry = last(vault) + assert entry.action == "product.price_change" + assert changed(entry)["себестоимость"] == ("20,00 ₽", "25,00 ₽") + assert changed(entry)["цена продажи"] == ("35,00 ₽", "40,00 ₽") + + +def test_price_change_appends_to_history(vault, buns): + product = buns["мак"] + assert len(product.price_history) == 1 # точка при создании + journal.change_prices(vault, product.id, cost_price=25, retail_price=40) + assert len(product.price_history) == 2 + assert product.price_history[-1].cost_price == 25 + + +def test_every_operation_touches_the_vault(vault, buns, today, in_two_weeks): + """revision растёт на каждой мутации — на него опирается автосохранение.""" + before = vault.revision + journal.create_batch( + vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}] + ) + assert vault.revision > before + + +def test_journal_covers_the_full_lifecycle(vault, buns, today, in_two_weeks): + batch = journal.create_batch( + vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}] + ) + sale = journal.create_sale( + vault, today, KIND_RETAIL, [{"product_id": buns["мак"].id, "qty": 2, "unit_price": 35}], + paid_amount=0, + ) + journal.add_sale_payment(vault, sale.id, 70) + journal.add_bakery_payment(vault, 200, batch_id=batch.id) + journal.delete_sale(vault, sale.id) + + assert actions(vault)[-5:] == [ + "batch.create", + "sale.create", + "sale.payment_add", + "bakery_payment.add", + "sale.delete", + ] + + +# --- пустые правки не создают шума ---------------------------------------- + + +def test_noop_update_writes_nothing(vault, buns): + count = len(vault.doc.journal) + journal.update_product(vault, buns["мак"].id, name="Булка с маком") + assert len(vault.doc.journal) == count + + +def test_noop_price_change_writes_nothing(vault, buns): + count = len(vault.doc.journal) + journal.change_prices(vault, buns["мак"].id, cost_price=20, retail_price=35) + assert len(vault.doc.journal) == count + assert len(buns["мак"].price_history) == 1 + + +def test_noop_edit_leaves_vault_clean(vault, buns): + """Иначе открыл-закрыл форму — и в git уехал пустой коммит.""" + vault.save() + journal.update_product(vault, buns["мак"].id, note="") + assert vault.is_dirty is False + + +# --- секреты не утекают в журнал ------------------------------------------ + + +def test_token_value_never_reaches_the_journal(vault): + secret = "glpat-СУПЕРСЕКРЕТ12345" + journal.update_settings(vault, git={"token": secret}) + + entry = last(vault) + assert entry.action == "settings.update" + assert changed(entry)["git.token"] == ("скрыто", "изменён") + + # Токен обязан быть в настройках, но нигде в журнале — включая открытый экспорт. + assert vault.doc.settings.git.token == secret + dumped = json.dumps([e.to_dict() for e in vault.doc.journal], ensure_ascii=False) + assert secret not in dumped + + +def test_non_secret_git_settings_are_logged_plainly(vault): + journal.update_settings(vault, git={"remote_url": "https://gt.ser.gay/kizya/food-market.git"}) + assert "gt.ser.gay" in changed(last(vault))["git.remote_url"][1] + + +def test_settings_keep_types(vault): + journal.update_settings(vault, git={"auto_push_minutes": "30", "enabled": False}) + assert vault.doc.settings.git.auto_push_minutes == 30 + assert vault.doc.settings.git.enabled is False + + +# --- проверки корректности ------------------------------------------------ + + +def test_product_in_use_cannot_be_deleted(vault, buns, today, in_two_weeks): + journal.create_batch( + vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}] + ) + with pytest.raises(journal.ValidationError, match="архив"): + journal.delete_product(vault, buns["мак"].id) + + +def test_unused_product_can_be_deleted(vault, buns): + journal.delete_product(vault, buns["мак"].id) + assert vault.doc.product(buns["мак"].id) is None + assert last(vault).action == "product.delete" + + +def test_batch_due_date_must_not_precede_purchase(vault, buns, today): + with pytest.raises(journal.ValidationError, match="раньше"): + journal.create_batch( + vault, + today, + today - timedelta(days=1), + [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}], + ) + + +def test_empty_batch_rejected(vault, today, in_two_weeks): + with pytest.raises(journal.ValidationError, match="хотя бы одна"): + journal.create_batch(vault, today, in_two_weeks, []) + + +def test_zero_quantity_rejected(vault, buns, today, in_two_weeks): + with pytest.raises(journal.ValidationError, match="больше нуля"): + journal.create_batch( + vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 0, "unit_cost": 20}] + ) + + +def test_counterparty_with_sales_cannot_be_deleted(vault, buns, today): + vasya = journal.create_counterparty(vault, "Вася") + journal.create_sale( + vault, today, KIND_FRIEND, + [{"product_id": buns["мак"].id, "qty": 1, "unit_price": 20}], + counterparty_id=vasya.id, + ) + with pytest.raises(journal.ValidationError, match="удалить нельзя"): + journal.delete_counterparty(vault, vasya.id) + + +def test_batch_numbers_increment(vault, buns, today, in_two_weeks): + lines = [{"product_id": buns["мак"].id, "qty": 1, "unit_cost": 20}] + first = journal.create_batch(vault, today, in_two_weeks, lines) + second = journal.create_batch(vault, today, in_two_weeks, lines) + assert (first.number, second.number) == (1, 2) + + +# --- поведение вокруг оплат ----------------------------------------------- + + +def test_sale_is_paid_in_full_by_default(vault, buns, today): + sale = journal.create_sale( + vault, today, KIND_RETAIL, [{"product_id": buns["мак"].id, "qty": 2, "unit_price": 35}] + ) + assert sale.paid == sale.total == 70 + assert sale.debt == 0 + + +def test_sale_on_credit_creates_debt(vault, buns, today): + sale = journal.create_sale( + vault, today, KIND_FRIEND, [{"product_id": buns["мак"].id, "qty": 5, "unit_price": 20}], + paid_amount=0, + ) + assert sale.paid == 0 + assert sale.debt == 100 + + journal.add_sale_payment(vault, sale.id, 60) + assert sale.debt == 40 + + +def test_consumption_never_records_payment(vault, buns, today): + """Съел сам — денег нет по определению, даже если форма что-то прислала.""" + sale = journal.create_sale( + vault, today, KIND_SELF, [{"product_id": buns["мак"].id, "qty": 2, "unit_price": 0}], + paid_amount=999, + ) + assert sale.payments == [] + assert sale.debt == 0 + + +def test_overpayment_does_not_create_negative_debt(vault, buns, today): + sale = journal.create_sale( + vault, today, KIND_RETAIL, [{"product_id": buns["мак"].id, "qty": 1, "unit_price": 35}], + paid_amount=0, + ) + journal.add_sale_payment(vault, sale.id, 100) + assert sale.debt == 0 + + +# --- удаление партии не теряет деньги ------------------------------------- + + +def test_deleting_batch_keeps_bakery_payments(vault, buns, today, in_two_weeks): + """Деньги пекарне реально ушли — они не должны исчезнуть вместе с партией.""" + batch = journal.create_batch( + vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}] + ) + payment = journal.add_bakery_payment(vault, 200, batch_id=batch.id) + + journal.delete_batch(vault, batch.id) + assert payment in vault.doc.bakery_payments + assert payment.batch_id is None + + +# --- обслуживание --------------------------------------------------------- + + +def test_prune_journal(vault, buns): + for entry in vault.doc.journal: + entry.ts = "2020-01-01T00:00:00+03:00" + removed = journal.prune_journal(vault, date(2024, 1, 1)) + assert removed == 3 + assert [e.action for e in vault.doc.journal] == ["journal.prune"] + + +def test_prune_nothing_to_remove_is_silent(vault, buns): + count = len(vault.doc.journal) + assert journal.prune_journal(vault, date(2000, 1, 1)) == 0 + assert len(vault.doc.journal) == count + + +def test_journal_entries_survive_save_and_reload(vault, buns): + from app.storage import Vault + + vault.save() + reopened = Vault.open(vault.path, "pw") + assert [e.action for e in reopened.doc.journal] == ["product.create"] * 3 + assert reopened.doc.journal[0].host diff --git a/tests/test_ledger.py b/tests/test_ledger.py new file mode 100644 index 0000000..85d9c34 --- /dev/null +++ b/tests/test_ledger.py @@ -0,0 +1,314 @@ +from datetime import date, timedelta +from decimal import Decimal + +import pytest + +from app import journal, ledger +from app.models import KIND_FRIEND, KIND_GIFT, KIND_RETAIL, KIND_SELF + + +def line(product, qty, price): + return {"product_id": product.id, "qty": qty, "unit_price": price} + + +def buy(product, qty, cost): + return {"product_id": product.id, "qty": qty, "unit_cost": cost} + + +# --- Сценарий из постановки задачи ---------------------------------------- + + +@pytest.fixture +def scenario(vault, buns, today, in_two_weeks): + """Ровно то, что описал пользователь. + + Пришёл в пекарню, взял 5 булок с повидлом, 10 с маком, 20 с корицей. + Часть продал, часть отдал другу в долг по себестоимости, пару съел сам. + """ + batch = journal.create_batch( + vault, + today, + in_two_weeks, + [buy(buns["повидло"], 5, 30), buy(buns["мак"], 10, 20), buy(buns["корица"], 20, 25)], + ) + vasya = journal.create_counterparty(vault, "Вася") + + journal.create_sale(vault, today, KIND_RETAIL, [line(buns["повидло"], 3, 50)]) + journal.create_sale( + vault, today, KIND_FRIEND, [line(buns["мак"], 5, 20)], + counterparty_id=vasya.id, paid_amount=0, + ) + journal.create_sale(vault, today, KIND_SELF, [line(buns["корица"], 2, 0)]) + + return {"vault": vault, "batch": batch, "vasya": vasya, "buns": buns} + + +def test_batch_debt_is_the_purchase_cost(scenario, today): + report = ledger.build(scenario["vault"].doc, today) + batch = report.batch_report(scenario["batch"].id) + # 5×30 + 10×20 + 20×25 + assert batch.cost_total == Decimal("850.00") + assert batch.remaining_to_bakery == Decimal("850.00") + + +def test_only_actually_received_money_counts_as_coverage(scenario, today): + """Долг Васи не покрывает партию: пекарне нужны деньги, а не начисления.""" + report = ledger.build(scenario["vault"].doc, today) + batch = report.batch_report(scenario["batch"].id) + + assert batch.revenue == Decimal("250.00") # 150 продажа + 100 долг Васи + assert batch.cash_collected == Decimal("150.00") + assert batch.still_to_collect == Decimal("700.00") + assert batch.coverage_pct == Decimal("17.65") + + +def test_self_consumed_buns_are_a_loss_against_margin(scenario, today): + summary = ledger.build(scenario["vault"].doc, today).summary + assert summary.gross_margin == Decimal("60.00") # 3 × (50 − 30) + assert summary.consumed_cost == Decimal("50.00") # 2 корицы × 25 + assert summary.profit == Decimal("10.00") + + +def test_stock_and_receivables(scenario, today): + report = ledger.build(scenario["vault"].doc, today) + batch = report.batch_report(scenario["batch"].id) + + assert batch.qty_sold == Decimal("10.000") + assert batch.qty_left == Decimal("25.000") + # 2×30 + 5×20 + 18×25 + assert report.summary.stock_cost == Decimal("610.00") + assert report.summary.receivable == Decimal("100.00") + assert [(d.name, d.debt) for d in report.debtors] == [("Вася", Decimal("100.00"))] + + +def test_paying_the_bakery_closes_the_batch(scenario, today): + vault = scenario["vault"] + journal.add_bakery_payment(vault, 850, batch_id=scenario["batch"].id) + + batch = ledger.build(vault.doc, today).batch_report(scenario["batch"].id) + assert batch.remaining_to_bakery == 0 + assert batch.status == ledger.STATUS_SETTLED + assert ledger.build(vault.doc, today).summary.owed_to_bakery == 0 + + +# --- FIFO по партиям ------------------------------------------------------ + + +def test_sale_spills_from_the_older_batch_into_the_newer(vault, buns, today): + """Привязка выручки к партии — единственная причина считать FIFO.""" + old = journal.create_batch( + vault, today - timedelta(days=10), today + timedelta(days=4), + [buy(buns["мак"], 10, 20)], + ) + new = journal.create_batch( + vault, today - timedelta(days=1), today + timedelta(days=13), + [buy(buns["мак"], 10, 20)], + ) + journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 14, 35)]) + + report = ledger.build(vault.doc, today) + assert report.batch_report(old.id).qty_sold == Decimal("10.000") + assert report.batch_report(new.id).qty_sold == Decimal("4.000") + assert report.batch_report(old.id).cash_collected == Decimal("350.00") # 10 × 35 + assert report.batch_report(new.id).cash_collected == Decimal("140.00") # 4 × 35 + + +def test_batch_order_follows_date_not_entry_order(vault, buns, today): + later = journal.create_batch( + vault, today, today + timedelta(days=14), [buy(buns["мак"], 5, 20)] + ) + earlier = journal.create_batch( + vault, today - timedelta(days=5), today + timedelta(days=9), [buy(buns["мак"], 5, 20)] + ) + journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 5, 35)]) + + report = ledger.build(vault.doc, today) + assert report.batch_report(earlier.id).qty_sold == Decimal("5.000") + assert report.batch_report(later.id).qty_sold == 0 + + +def test_allocation_is_deterministic(vault, buns, today, in_two_weeks): + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) + for _ in range(3): + journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)]) + + first = ledger.build(vault.doc, today).allocations + second = ledger.build(vault.doc, today).allocations + assert [(a.sale_id, a.batch_id, a.qty) for a in first] == [ + (a.sale_id, a.batch_id, a.qty) for a in second + ] + + +def test_selling_more_than_bought_warns_instead_of_crashing(vault, buns, today, in_two_weeks): + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 5, 20)]) + journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 8, 35)]) + + report = ledger.build(vault.doc, today) + assert [(s.product_id, s.qty) for s in report.shortfalls] == [ + (buns["мак"].id, Decimal("3.000")) + ] + assert any("больше" in w for w in report.warnings) + + +# --- Частичная оплата ----------------------------------------------------- + + +def test_partial_payment_counts_proportionally(vault, buns, today, in_two_weeks): + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) + sale = journal.create_sale( + vault, today, KIND_RETAIL, [line(buns["мак"], 10, 35)], paid_amount=0 + ) + journal.add_sale_payment(vault, sale.id, 140) # 40% от 350 + + report = ledger.build(vault.doc, today) + assert report.batches[0].revenue == Decimal("350.00") + assert report.batches[0].cash_collected == Decimal("140.00") + assert report.summary.receivable == Decimal("210.00") + + +def test_paying_off_the_debt_completes_coverage(vault, buns, today, in_two_weeks): + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) + sale = journal.create_sale( + vault, today, KIND_RETAIL, [line(buns["мак"], 10, 35)], paid_amount=0 + ) + journal.add_sale_payment(vault, sale.id, 350) + + report = ledger.build(vault.doc, today) + assert report.batches[0].cash_collected == Decimal("350.00") + assert report.summary.receivable == 0 + assert report.debtors == [] + + +# --- Платежи пекарне ------------------------------------------------------ + + +def test_unassigned_payment_pays_the_oldest_batch_first(vault, buns, today): + old = journal.create_batch( + vault, today - timedelta(days=10), today + timedelta(days=4), [buy(buns["мак"], 10, 20)] + ) + new = journal.create_batch( + vault, today, today + timedelta(days=14), [buy(buns["мак"], 10, 20)] + ) + journal.add_bakery_payment(vault, 250) # 200 закрывают старую, 50 идут в новую + + report = ledger.build(vault.doc, today) + assert report.batch_report(old.id).remaining_to_bakery == 0 + assert report.batch_report(new.id).remaining_to_bakery == Decimal("150.00") + + +def test_overpaying_one_batch_spills_over_instead_of_going_negative(vault, buns, today): + first = journal.create_batch( + vault, today - timedelta(days=5), today + timedelta(days=9), [buy(buns["мак"], 5, 20)] + ) + second = journal.create_batch( + vault, today, today + timedelta(days=14), [buy(buns["мак"], 5, 20)] + ) + journal.add_bakery_payment(vault, 180, batch_id=first.id) # партия стоит всего 100 + + report = ledger.build(vault.doc, today) + assert report.batch_report(first.id).remaining_to_bakery == 0 + assert report.batch_report(second.id).remaining_to_bakery == Decimal("20.00") + + +def test_paying_more_than_everything_is_reported_as_advance(vault, buns, today, in_two_weeks): + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 5, 20)]) + journal.add_bakery_payment(vault, 300) + + report = ledger.build(vault.doc, today) + assert report.unassigned_bakery_payment == Decimal("200.00") + assert report.summary.owed_to_bakery == 0 + assert any("аванс" in w for w in report.warnings) + + +# --- Статусы и дедлайны --------------------------------------------------- + + +def test_status_open(vault, buns, today): + journal.create_batch(vault, today, today + timedelta(days=14), [buy(buns["мак"], 5, 20)]) + assert ledger.build(vault.doc, today).batches[0].status == ledger.STATUS_OPEN + + +def test_status_due_soon(vault, buns, today): + journal.create_batch(vault, today, today + timedelta(days=2), [buy(buns["мак"], 5, 20)]) + report = ledger.build(vault.doc, today).batches[0] + assert report.status == ledger.STATUS_DUE_SOON + assert report.days_left == 2 + + +def test_status_overdue(vault, buns, today): + journal.create_batch( + vault, today - timedelta(days=20), today - timedelta(days=1), [buy(buns["мак"], 5, 20)] + ) + report = ledger.build(vault.doc, today) + assert report.batches[0].status == ledger.STATUS_OVERDUE + assert report.summary.overdue_count == 1 + + +def test_manually_closed_batch_is_settled_even_if_unpaid(vault, buns, today, in_two_weeks): + """Иногда с пекарней договариваются на словах — учёт не должен спорить.""" + batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 5, 20)]) + journal.set_batch_closed(vault, batch.id, True) + assert ledger.build(vault.doc, today).batches[0].status == ledger.STATUS_SETTLED + + +def test_next_due_picks_the_nearest_open_deadline(vault, buns, today): + journal.create_batch(vault, today, today + timedelta(days=14), [buy(buns["мак"], 5, 20)]) + urgent = journal.create_batch(vault, today, today + timedelta(days=2), [buy(buns["мак"], 5, 20)]) + + summary = ledger.build(vault.doc, today).summary + assert summary.next_due.batch.id == urgent.id + + +def test_settled_batches_are_not_offered_as_next_due(vault, buns, today): + urgent = journal.create_batch(vault, today, today + timedelta(days=1), [buy(buns["мак"], 5, 20)]) + later = journal.create_batch(vault, today, today + timedelta(days=20), [buy(buns["мак"], 5, 20)]) + journal.add_bakery_payment(vault, 100, batch_id=urgent.id) + + summary = ledger.build(vault.doc, today).summary + assert summary.next_due.batch.id == later.id + + +# --- Прочее --------------------------------------------------------------- + + +def test_gifts_count_as_consumption_not_revenue(vault, buns, today, in_two_weeks): + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) + journal.create_sale(vault, today, KIND_GIFT, [line(buns["мак"], 3, 0)]) + + summary = ledger.build(vault.doc, today).summary + assert summary.revenue == 0 + assert summary.consumed_cost == Decimal("60.00") + assert summary.profit == Decimal("-60.00") + + +def test_friend_price_yields_no_margin(vault, buns, today, in_two_weeks): + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) + journal.create_sale(vault, today, KIND_FRIEND, [line(buns["мак"], 10, 20)]) + + summary = ledger.build(vault.doc, today).summary + assert summary.cash_collected == Decimal("200.00") + assert summary.gross_margin == 0 + assert summary.profit == 0 + + +def test_debts_without_counterparty_are_grouped_separately(vault, buns, today, in_two_weeks): + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) + journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)], paid_amount=0) + + debtors = ledger.build(vault.doc, today).debtors + assert [(d.name, d.debt) for d in debtors] == [("Без контрагента", Decimal("70.00"))] + + +def test_empty_database_reports_zeroes(vault, today): + report = ledger.build(vault.doc, today) + assert report.summary.owed_to_bakery == 0 + assert report.summary.profit == 0 + assert report.summary.next_due is None + assert report.warnings == [] + + +def test_stock_lists_products_with_remaining_quantity(scenario, today): + rows = {r.name: r.qty for r in ledger.build(scenario["vault"].doc, today).stock} + assert rows["Булка с повидлом"] == Decimal("2.000") + assert rows["Булка с маком"] == Decimal("5.000") + assert rows["Булка с корицей"] == Decimal("18.000") diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..a5bcadd --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,134 @@ +import json +from decimal import Decimal + +import pytest + +from app import crypto, journal +from app.storage import Vault, content_hash, migrate + + +def test_create_and_reopen(tmp_path): + path = tmp_path / "vault.fmdb" + vault = Vault.create(path, "pw") + journal.create_product(vault, "Булка с маком", cost_price=20, retail_price=35) + vault.save() + + reopened = Vault.open(path, "pw") + assert [p.name for p in reopened.doc.products] == ["Булка с маком"] + assert reopened.doc.products[0].cost_price == Decimal("20.00") + + +def test_open_with_wrong_password(tmp_path): + path = tmp_path / "vault.fmdb" + Vault.create(path, "pw") + with pytest.raises(crypto.WrongPassword): + Vault.open(path, "nope") + + +def test_create_refuses_to_clobber(tmp_path): + path = tmp_path / "vault.fmdb" + Vault.create(path, "pw") + with pytest.raises(crypto.VaultError): + Vault.create(path, "other") + + +def test_save_skips_write_when_nothing_changed(tmp_path): + """Главная защита от мусорных коммитов в git. + + Шифртекст каждый раз новый, поэтому решение «писать или нет» принимается + по хешу открытых данных, а не по байтам файла. + """ + path = tmp_path / "vault.fmdb" + vault = Vault.create(path, "pw") + journal.create_product(vault, "Булка", cost_price=10, retail_price=20) + + assert vault.save() is True + assert vault.save() is False + assert vault.save() is False + + +def test_save_detects_real_change(tmp_path): + vault = Vault.create(tmp_path / "vault.fmdb", "pw") + vault.save() + journal.create_product(vault, "Булка", cost_price=10, retail_price=20) + assert vault.is_dirty is True + assert vault.save() is True + assert vault.is_dirty is False + + +def test_freshly_opened_vault_is_not_dirty(tmp_path): + """Иначе каждое открытие программы порождало бы коммит на пустом месте.""" + path = tmp_path / "vault.fmdb" + vault = Vault.create(path, "pw") + journal.create_product(vault, "Булка", cost_price=10, retail_price=20) + vault.save() + + reopened = Vault.open(path, "pw") + assert reopened.is_dirty is False + assert reopened.save() is False + + +def test_content_hash_ignores_key_order(): + assert content_hash({"a": 1, "b": 2}) == content_hash({"b": 2, "a": 1}) + + +def test_change_password(tmp_path): + path = tmp_path / "vault.fmdb" + vault = Vault.create(path, "old") + journal.create_product(vault, "Булка", cost_price=10, retail_price=20) + vault.save() + + vault.change_password("new") + with pytest.raises(crypto.WrongPassword): + Vault.open(path, "old") + assert Vault.open(path, "new").doc.products[0].name == "Булка" + + +def test_atomic_write_leaves_backup(tmp_path): + path = tmp_path / "vault.fmdb" + vault = Vault.create(path, "pw") + journal.create_product(vault, "Булка", cost_price=10, retail_price=20) + vault.save() + + assert path.with_suffix(".fmdb.bak").exists() + assert not path.with_suffix(".fmdb.tmp").exists() + + +def test_decimals_survive_round_trip(tmp_path): + """Деньги ходят через строки, поэтому копейки не должны разъезжаться.""" + path = tmp_path / "vault.fmdb" + vault = Vault.create(path, "pw") + journal.create_product(vault, "Булка", cost_price="33.33", retail_price="66.67") + vault.save() + + product = Vault.open(path, "pw").doc.products[0] + assert product.cost_price == Decimal("33.33") + assert product.retail_price == Decimal("66.67") + + +def test_export_plain_json(tmp_path): + vault = Vault.create(tmp_path / "vault.fmdb", "pw") + journal.create_product(vault, "Булка с маком", cost_price=20, retail_price=35) + + out = vault.export_plain_json(tmp_path / "export.json") + data = json.loads(out.read_text(encoding="utf-8")) + assert data["products"][0]["name"] == "Булка с маком" + + +def test_migration_adds_journal(): + raw = migrate({"schema_version": 0, "products": []}) + assert raw["schema_version"] == 1 + assert raw["journal"] == [] + + +def test_future_schema_refuses_to_open(): + with pytest.raises(crypto.UnsupportedFormat, match="новой версией"): + migrate({"schema_version": 999}) + + +def test_corrupted_json_inside_valid_encryption(tmp_path): + """GCM подтвердил целостность, но внутри не JSON — сообщаем внятно.""" + path = tmp_path / "vault.fmdb" + path.write_bytes(crypto.encrypt(b"not json at all", "pw")) + with pytest.raises(crypto.VaultError, match="повреждено"): + Vault.open(path, "pw") diff --git a/tests/test_ui_smoke.py b/tests/test_ui_smoke.py new file mode 100644 index 0000000..99aae68 --- /dev/null +++ b/tests/test_ui_smoke.py @@ -0,0 +1,391 @@ +"""Дымовые тесты интерфейса: окно собирается, экраны рисуются, формы отдают данные. + +Гоняются в offscreen-режиме, поэтому идут и в консоли, и без монитора. +Диалоги не показываются через exec() — вместо этого проверяется их содержимое +и то, что из него получается. +""" + +import os +from datetime import timedelta +from decimal import Decimal + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication # noqa: E402 + +from app import journal # noqa: E402 +from app.models import KIND_FRIEND, KIND_RETAIL, KIND_SELF # noqa: E402 +from app.ui import theme # noqa: E402 +from app.ui.main_window import MainWindow # noqa: E402 +from app.ui.page_batches import BatchDialog # noqa: E402 +from app.ui.page_journal import JournalPage, describe # noqa: E402 +from app.ui.page_products import PriceDialog, ProductDialog # noqa: E402 +from app.ui.page_sales import SaleDialog, price_source_for # noqa: E402 + + +@pytest.fixture(scope="session") +def qapp(): + app = QApplication.instance() or QApplication([]) + theme.apply(app) + return app + + +@pytest.fixture +def window(qapp, vault, buns, today, in_two_weeks): + """Окно на живых данных: партия, розница, друг в долг, съеденное.""" + batch = journal.create_batch( + vault, + today, + in_two_weeks, + [ + {"product_id": buns["повидло"].id, "qty": 5, "unit_cost": 30}, + {"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}, + {"product_id": buns["корица"].id, "qty": 20, "unit_cost": 25}, + ], + ) + vasya = journal.create_counterparty(vault, "Вася") + journal.create_sale( + vault, today, KIND_RETAIL, [{"product_id": buns["повидло"].id, "qty": 3, "unit_price": 50}] + ) + journal.create_sale( + vault, today, KIND_FRIEND, [{"product_id": buns["мак"].id, "qty": 5, "unit_price": 20}], + counterparty_id=vasya.id, paid_amount=0, + ) + journal.create_sale( + vault, today, KIND_SELF, [{"product_id": buns["корица"].id, "qty": 2, "unit_price": 0}] + ) + + win = MainWindow(vault) + win.batch = batch + win.vasya = vasya + yield win + win.autosave_timer.stop() + win.sync_timer.stop() + win.close() + win.deleteLater() + + +# --- окно целиком --------------------------------------------------------- + + +def test_window_builds_with_all_pages(window): + titles = [title for title, _ in window.pages] + assert titles == ["Сводка", "Закупки", "Продажи", "Долги", "Товары", "Журнал", "Настройки"] + assert window.stack.count() == 7 + + +def test_every_page_refreshes_without_errors(window): + for index in range(len(window.pages)): + window.sidebar.setCurrentRow(index) + window.pages[index][1].refresh() + + +def test_changed_rebuilds_the_report(window, buns): + before = window.report.summary.owed_to_bakery + journal.add_bakery_payment(window.vault, 100, batch_id=window.batch.id) + window.changed() + assert window.report.summary.owed_to_bakery == before - 100 + + +def test_autosave_writes_only_after_a_change(window): + window.save_now() + stamp = window.vault.path.stat().st_mtime_ns + window._autosave() + assert window.vault.path.stat().st_mtime_ns == stamp # файл не трогали + + journal.create_product(window.vault, "Булка с изюмом", cost_price=22, retail_price=38) + assert window.vault.revision != window._saved_revision + window._autosave() + assert window.vault.revision == window._saved_revision + + +def test_sync_is_skipped_without_a_remote(window): + """Без адреса репозитория фоновый тик не должен ничего делать.""" + window.sync_now(quiet=True) + assert window._syncing is False + + +# --- таблицы наполняются -------------------------------------------------- + + +def test_batches_table_shows_the_batch(window): + page = window.pages[1][1] + page.refresh() + assert page.table.rowCount() == 1 + assert page.table.item(0, 0).text() == "1" + assert "850" in page.table.item(0, 4).text() + + +def test_batch_details_show_coverage(window): + page = window.pages[1][1] + page.refresh() + page.table.selectRow(0) + page._show_details() + + assert "Партия" in page.details_title.text() + assert page.progress.value() == 17 # 150 из 850 + assert page.contents.rowCount() == 3 + + +def test_sales_table_lists_every_kind(window): + page = window.pages[2][1] + page.refresh() + assert page.table.rowCount() == 3 + + +def test_sales_filter_narrows_the_list(window): + page = window.pages[2][1] + page.filter.setCurrentIndex(page.filter.findData(KIND_SELF)) + page.refresh() + assert page.table.rowCount() == 1 + + +def test_debts_tree_groups_by_person(window): + page = window.pages[3][1] + page.refresh() + assert page.tree.topLevelItemCount() == 1 + + person = page.tree.topLevelItem(0) + assert person.text(0) == "Вася" + assert "100" in person.text(4) + assert person.childCount() == 1 + + +def test_products_table_shows_stock_and_margin(window): + page = window.pages[4][1] + page.refresh() + names = [page.table.item(r, 0).text() for r in range(page.table.rowCount())] + assert "Булка с маком" in names + + row = names.index("Булка с маком") + assert "15" in page.table.item(row, 4).text() # 35 − 20 + assert page.table.item(row, 5).text() == "5" # 10 закуплено − 5 отдано + + +def test_archived_products_are_hidden_by_default(window, buns): + page = window.pages[4][1] + journal.update_product(window.vault, buns["мак"].id, archived=True) + window.changed() + + visible = [page.table.item(r, 0).text() for r in range(page.table.rowCount())] + assert "Булка с маком" not in visible + + page.show_archived.setChecked(True) + visible = [page.table.item(r, 0).text() for r in range(page.table.rowCount())] + assert "Булка с маком" in visible + + +# --- сводка --------------------------------------------------------------- + + +def test_dashboard_renders_metrics_and_deadline(window): + from PySide6.QtWidgets import QLabel, QProgressBar + + page = window.pages[0][1] + page.refresh() + + texts = " ".join(label.text() for label in page.body.findChildren(QLabel)) + assert "Долг пекарне" in texts + assert "850,00 ₽" in texts # вся себестоимость партии ещё не возвращена + assert "Мне должны" in texts + assert "100,00 ₽" in texts # долг Васи + + bars = page.body.findChildren(QProgressBar) + assert len(bars) == 1 + assert bars[0].value() == 17 # собрано 150 из 850 + + +def test_dashboard_shows_a_warning_when_oversold(window, buns): + page = window.pages[0][1] + journal.create_sale( + window.vault, window.report.today, KIND_RETAIL, + [{"product_id": buns["повидло"].id, "qty": 99, "unit_price": 50}], + ) + window.changed() + assert any("больше" in warning for warning in window.report.warnings) + + +# --- журнал --------------------------------------------------------------- + + +def test_journal_page_lists_entries(window): + page = window.pages[5][1] + page.refresh() + assert page.table.rowCount() == len(window.vault.doc.journal) + + +def test_journal_search_filters(window, buns): + page = window.pages[5][1] + journal.change_prices(window.vault, buns["мак"].id, cost_price=25, retail_price=40) + window.changed() + + page.search.setText("цены изменены") + page.refresh() + assert page.table.rowCount() == 1 + assert "20,00 ₽ → 25,00 ₽" in page.table.item(0, 4).text() + + +def test_journal_search_matches_what_is_displayed(window, buns): + """Формат на экране и то, что ищется, должны совпадать до пробела.""" + page = window.pages[5][1] + journal.change_prices(window.vault, buns["мак"].id, cost_price=1500, retail_price=2000) + window.changed() + + page.search.setText("1 500,00") + page.refresh() + assert page.table.rowCount() == 1 + + +def test_journal_entity_filter(window): + page = window.pages[5][1] + page.entity.setCurrentIndex(page.entity.findData("batch")) + page.refresh() + assert page.table.rowCount() == 1 + + +def test_describe_renders_a_transition(): + from app.models import Change, JournalEntry + + entry = JournalEntry( + id="j_1", ts="", host="", action="product.price_change", + entity="product", entity_id="p_1", label="Булка", + changes=[Change("себестоимость", "20,00 ₽", "25,00 ₽")], + ) + assert describe(entry) == "себестоимость: 20,00 ₽ → 25,00 ₽" + + +# --- формы ---------------------------------------------------------------- + + +def test_batch_dialog_round_trips_lines(window): + dialog = BatchDialog(window, window.batch) + lines = dialog.lines.lines("unit_cost") + + assert len(lines) == 3 + assert dialog.lines.total() == Decimal("850.00") + assert lines[0]["unit_cost"] == Decimal("30.00") + dialog.deleteLater() + + +def test_batch_dialog_defaults_the_deadline_from_settings(window): + window.vault.doc.settings.default_period_days = 21 + dialog = BatchDialog(window) + assert (dialog.due.get_date() - dialog.date.get_date()).days == 21 + dialog.deleteLater() + + +def test_batch_dialog_deadline_follows_the_purchase_date(window): + dialog = BatchDialog(window) + dialog.date.set_date(dialog.date.get_date() + timedelta(days=5)) + days = window.vault.doc.settings.default_period_days + assert (dialog.due.get_date() - dialog.date.get_date()).days == days + dialog.deleteLater() + + +def test_sale_dialog_substitutes_price_by_kind(window, buns): + """Ради этого и заведены типы: другу — себестоимость, себе — ноль.""" + dialog = SaleDialog(window) + dialog.lines.table.cellWidget(0, 0).select_product(buns["мак"].id) + + dialog.kind.setCurrentIndex(0) # розница + assert dialog.lines.lines("unit_price")[0]["unit_price"] == Decimal("35.00") + + dialog.kind.setCurrentIndex(1) # другу по себестоимости + assert dialog.lines.lines("unit_price")[0]["unit_price"] == Decimal("20.00") + + dialog.kind.setCurrentIndex(2) # съел сам + assert dialog.lines.lines("unit_price")[0]["unit_price"] == Decimal("0.00") + dialog.deleteLater() + + +def test_sale_dialog_disables_payment_for_consumption(window): + dialog = SaleDialog(window) + dialog.kind.setCurrentIndex(2) # съел сам + assert dialog.paid.isEnabled() is False + assert dialog.paid.value_decimal() == 0 + dialog.deleteLater() + + +def test_sale_dialog_prefills_full_payment(window, buns): + dialog = SaleDialog(window) + dialog.lines.table.cellWidget(0, 0).select_product(buns["мак"].id) + dialog.lines.table.cellWidget(0, 1).set_decimal(4) + + assert dialog.paid.value_decimal() == Decimal("140.00") # 4 × 35 + assert "полностью" in dialog.paid_hint.text() + dialog.deleteLater() + + +def test_price_source_for_each_kind(buns): + assert price_source_for(KIND_RETAIL)(buns["мак"]) == Decimal("35.00") + assert price_source_for(KIND_FRIEND)(buns["мак"]) == Decimal("20.00") + assert price_source_for(KIND_SELF)(buns["мак"]) == 0 + + +def test_price_dialog_shows_history(window, buns): + journal.change_prices(window.vault, buns["мак"].id, cost_price=25, retail_price=40) + dialog = PriceDialog("₽", window.vault.doc.product(buns["мак"].id)) + + assert dialog.cost.value_decimal() == Decimal("25.00") + dialog.deleteLater() + + +def test_product_dialog_hides_prices_when_editing(window, buns): + """Цены правятся только через историю — иначе она бы врала.""" + editing = ProductDialog("₽", window.vault.doc.product(buns["мак"].id)) + assert not hasattr(editing, "cost") + editing.deleteLater() + + creating = ProductDialog("₽") + assert hasattr(creating, "cost") + creating.deleteLater() + + +def test_lines_editor_add_and_remove(window, buns): + dialog = BatchDialog(window) + editor = dialog.lines + start = editor.table.rowCount() + + editor.add_line(buns["мак"].id, 7, 20) + assert len(editor.lines("unit_cost")) == start + 1 + + editor.table.setCurrentCell(editor.table.rowCount() - 1, 0) + editor.remove_current() + assert len(editor.lines("unit_cost")) == start + dialog.deleteLater() + + +# --- настройки ------------------------------------------------------------ + + +def test_settings_shows_database_facts(window): + page = window.pages[6][1] + page.refresh() + assert "товаров 3" in page.db_info.text() + assert "закупок 1" in page.db_info.text() + + +def test_settings_saves_git_without_wiping_the_token(window): + """Пустое поле токена означает «не трогать», а не «стереть».""" + page = window.pages[6][1] + journal.update_settings(window.vault, git={"token": "glpat-keepme"}) + page.refresh() + + page.remote.setText("https://gt.ser.gay/kizya/food-market.git") + page.token.clear() + page.save_git() + + assert window.vault.doc.settings.git.token == "glpat-keepme" + assert window.vault.doc.settings.git.remote_url == "https://gt.ser.gay/kizya/food-market.git" + + +def test_conflict_block_is_hidden_until_there_is_one(window): + page = window.pages[6][1] + page.refresh() + assert page.conflict_box.isVisibleTo(page) is False + + window.conflict_message = "версии разошлись" + page.refresh() + assert page.conflict_box.isVisibleTo(page) is True