From 92adaa9c48e8e4e4c0d35cb5039656d778ac3937 Mon Sep 17 00:00:00 2001 From: food-market Date: Sat, 22 Aug 2026 21:49:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A2=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B1?= =?UTF-8?q?=D0=B0=D0=B7=D0=B0:=20=D0=B8=D1=81=D1=85=D0=BE=D0=B4=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B5=D1=85=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=B2=20food-market?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Репозиторий предназначен для одной вещи — зашифрованной базы vault.fmdb. Исходники попали сюда по ошибке: приложение пушило ветку, в которой лежал и код, и данные. Теперь папка с базой — самостоятельный клон этого репозитория, а программа живёт в gt.ser.gay/kizya/food-market. База перенесена из data/vault.fmdb в корень и помечена binary, чтобы git не испортил её переводом строк. --- .gitattributes | 10 +- .gitignore | 23 +- .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 ----------------- data/vault.fmdb => vault.fmdb | Bin 40 files changed, 7 insertions(+), 7507 deletions(-) delete mode 100644 .vscode/settings.json delete mode 100644 README.md delete mode 100644 app/__init__.py delete mode 100644 app/crypto.py delete mode 100644 app/gitsync.py delete mode 100644 app/journal.py delete mode 100644 app/ledger.py delete mode 100644 app/main.py delete mode 100644 app/models.py delete mode 100644 app/money.py delete mode 100644 app/paths.py delete mode 100644 app/storage.py delete mode 100644 app/ui/__init__.py delete mode 100644 app/ui/login.py delete mode 100644 app/ui/main_window.py delete mode 100644 app/ui/page_batches.py delete mode 100644 app/ui/page_dashboard.py delete mode 100644 app/ui/page_debts.py delete mode 100644 app/ui/page_journal.py delete mode 100644 app/ui/page_products.py delete mode 100644 app/ui/page_sales.py delete mode 100644 app/ui/page_settings.py delete mode 100644 app/ui/theme.py delete mode 100644 app/ui/widgets.py delete mode 100644 food-market.code-workspace delete mode 100644 food-market.spec delete mode 100644 pytest.ini delete mode 100644 requirements-dev.txt delete mode 100644 requirements.txt delete mode 100644 run.py delete mode 100644 tests/conftest.py delete mode 100644 tests/test_crypto.py delete mode 100644 tests/test_gitsync.py delete mode 100644 tests/test_journal.py delete mode 100644 tests/test_ledger.py delete mode 100644 tests/test_storage.py delete mode 100644 tests/test_ui_smoke.py rename data/vault.fmdb => vault.fmdb (100%) diff --git a/.gitattributes b/.gitattributes index d71a005..a2b1407 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,4 @@ -# База зашифрована. Если git примет её за текст и подставит CRLF, файл -# перестанет расшифровываться. Помечаем явно. +# База зашифрована. Если git примет её за текст и подставит CRLF, +# файл перестанет расшифровываться. Помечаем явно. *.fmdb binary -data/vault.fmdb binary - -* text=auto eol=lf -*.png binary -*.ico binary +vault.fmdb binary diff --git a/.gitignore b/.gitignore index 4ed4f49..26fdb6e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,5 @@ -__pycache__/ -*.py[cod] -.pytest_cache/ -.venv/ -venv/ - -build/ -dist/ -*.spec.bak - -# Собранный exe весит ~50 МБ. Репозиторий и так растёт от снимков базы — -# бинарник в нём не нужен, он собирается из исходников одной командой. -/food-market.exe - -# Рабочие артефакты базы. Сама база data/vault.fmdb НЕ игнорируется — -# она и есть предмет синхронизации. +# Рабочие копии базы. Синхронизируется только vault.fmdb. +*.bak *.tmp -data/*.bak -data/vault.remote.fmdb -data/export-*.json +vault.remote.fmdb +export-*.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index c103c2d..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "workbench.editorAssociations": { - "*.sprite": "d2rmodding.spriteViewer" - } -} \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 416afc9..0000000 --- a/README.md +++ /dev/null @@ -1,114 +0,0 @@ -# Булочная - -Десктопный учёт закупки и перепродажи булок. Вся база — один файл, -зашифрованный паролем, который раз в час уезжает в этот репозиторий. - -## Что считает - -Булки берутся в пекарне **в долг**: к дедлайну надо вернуть себестоимость -партии, иначе новых не напекут. Наценка остаётся тебе. Часть булок уходит -друзьям по себестоимости, часть съедается — и за них пекарне тоже платить. - -Программа отвечает на главный вопрос: **успеваю ли я собрать себестоимость -конкретной партии до её срока**. - -- **Закупки** — партии с составом, ценами и датой, до которой надо рассчитаться. -- **Продажи** — розница, «другу по себестоимости», «съел сам», подарок, списание. - Цена подставляется по типу. -- **Долги** — кто сколько должен, с частичными оплатами. -- **Товары** — номенклатура с историей изменения цен. -- **Журнал** — что менялось в базе, когда и с какой машины. -- **Сводка** — долг пекарне, дебиторка, прибыль, остатки, ближайший дедлайн. - -Продажи разносятся по партиям методом 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 deleted file mode 100644 index 328e688..0000000 --- a/app/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Булочная — учёт закупки и перепродажи булок.""" - -APP_NAME = "Булочная" -APP_VERSION = "1.0.0" diff --git a/app/crypto.py b/app/crypto.py deleted file mode 100644 index 1923874..0000000 --- a/app/crypto.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Формат зашифрованного файла базы (.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 deleted file mode 100644 index 725f37b..0000000 --- a/app/gitsync.py +++ /dev/null @@ -1,399 +0,0 @@ -"""Синхронизация зашифрованной базы через системный 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 deleted file mode 100644 index d3493d5..0000000 --- a/app/journal.py +++ /dev/null @@ -1,798 +0,0 @@ -"""Единственный путь записи в базу. - -Ни один экран не меняет документ напрямую — всё идёт через функции этого -модуля. Каждая из них снимает состояние сущности до и после, считает разницу -и дописывает запись в аудит-лог. - -Так сделано потому, что 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 deleted file mode 100644 index 79753a8..0000000 --- a/app/ledger.py +++ /dev/null @@ -1,485 +0,0 @@ -"""Расчёты по партиям, долгам и остаткам. Чистые функции, без 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 deleted file mode 100644 index 84fde9e..0000000 --- a/app/main.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Точка входа.""" - -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 deleted file mode 100644 index 6c57495..0000000 --- a/app/models.py +++ /dev/null @@ -1,574 +0,0 @@ -"""Структура документа базы и сериализация в/из 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 deleted file mode 100644 index 3b0bba8..0000000 --- a/app/money.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Деньги и количества. - -Всё считаем в 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 deleted file mode 100644 index 6676609..0000000 --- a/app/paths.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Где лежат данные. - -Собранный 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 deleted file mode 100644 index 28c1c1e..0000000 --- a/app/storage.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Загрузка, сохранение и версионирование схемы базы. - -Ключевая деталь — определение «изменилось ли что-нибудь». Шифрование -недетерминировано, поэтому сравнивать байты файла бессмысленно: одни и те же -данные каждый раз дают разный шифртекст. Вместо этого храним 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 deleted file mode 100644 index 43ff2fd..0000000 --- a/app/ui/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Интерфейс на PySide6.""" diff --git a/app/ui/login.py b/app/ui/login.py deleted file mode 100644 index 6646be2..0000000 --- a/app/ui/login.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Разблокировка существующей базы или создание новой.""" - -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 deleted file mode 100644 index 5a9a17b..0000000 --- a/app/ui/main_window.py +++ /dev/null @@ -1,332 +0,0 @@ -"""Главное окно: боковое меню, экраны, автосохранение и фоновая синхронизация. - -Окно заодно служит контекстом для страниц: они берут отсюда базу, пересчитанный -отчёт и валюту, а после любой правки зовут 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 deleted file mode 100644 index a66a36a..0000000 --- a/app/ui/page_batches.py +++ /dev/null @@ -1,363 +0,0 @@ -"""Закупки: партии булок, взятых в пекарне в долг, и их покрытие.""" - -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 deleted file mode 100644 index 4f6fff9..0000000 --- a/app/ui/page_dashboard.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Сводка: успеваю ли я рассчитаться с пекарней.""" - -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 deleted file mode 100644 index 8476763..0000000 --- a/app/ui/page_debts.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Долги: кто и сколько должен за взятые булки.""" - -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 deleted file mode 100644 index 74bf401..0000000 --- a/app/ui/page_journal.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Журнал изменений. - -История, которую 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 deleted file mode 100644 index a8bbd93..0000000 --- a/app/ui/page_products.py +++ /dev/null @@ -1,277 +0,0 @@ -"""Номенклатура: товары, их цены и история изменения цен.""" - -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 deleted file mode 100644 index 5fba0e9..0000000 --- a/app/ui/page_sales.py +++ /dev/null @@ -1,322 +0,0 @@ -"""Продажи и прочее выбытие: розница, друзья по себестоимости, съел сам.""" - -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 deleted file mode 100644 index 66d6a4b..0000000 --- a/app/ui/page_settings.py +++ /dev/null @@ -1,362 +0,0 @@ -"""Настройки: 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 deleted file mode 100644 index 38537b9..0000000 --- a/app/ui/theme.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Тёмная тема. - -Палитра задаётся через 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 deleted file mode 100644 index 0bcb172..0000000 --- a/app/ui/widgets.py +++ /dev/null @@ -1,436 +0,0 @@ -"""Общие элементы интерфейса. - -Здесь всё, что иначе копировалось бы по экранам: поля денег и количеств, -настроенные таблицы и редактор строк документа. -""" - -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 deleted file mode 100644 index ab48a50..0000000 --- a/food-market.code-workspace +++ /dev/null @@ -1,12 +0,0 @@ -{ - "folders": [ - { - "path": "." - } - ], - "settings": { - "workbench.editorAssociations": { - "*.sprite": "d2rmodding.spriteViewer" - } - } -} \ No newline at end of file diff --git a/food-market.spec b/food-market.spec deleted file mode 100644 index 5bc73ee..0000000 --- a/food-market.spec +++ /dev/null @@ -1,89 +0,0 @@ -# -*- 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 deleted file mode 100644 index c7b23ec..0000000 --- a/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -pythonpath = . -testpaths = tests diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index a19f9d3..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,3 +0,0 @@ --r requirements.txt -pytest>=8.0 -pyinstaller>=6.0 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index d093a7f..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -PySide6>=6.7 -cryptography>=42.0 diff --git a/run.py b/run.py deleted file mode 100644 index c7725f5..0000000 --- a/run.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Точка входа для запуска и для сборки 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 deleted file mode 100644 index eb7abef..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 7a5cbd7..0000000 --- a/tests/test_crypto.py +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index 4d495b1..0000000 --- a/tests/test_gitsync.py +++ /dev/null @@ -1,282 +0,0 @@ -"""Тесты синхронизации против локального 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 deleted file mode 100644 index a32f0c2..0000000 --- a/tests/test_journal.py +++ /dev/null @@ -1,267 +0,0 @@ -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 deleted file mode 100644 index 85d9c34..0000000 --- a/tests/test_ledger.py +++ /dev/null @@ -1,314 +0,0 @@ -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 deleted file mode 100644 index a5bcadd..0000000 --- a/tests/test_storage.py +++ /dev/null @@ -1,134 +0,0 @@ -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 deleted file mode 100644 index 99aae68..0000000 --- a/tests/test_ui_smoke.py +++ /dev/null @@ -1,391 +0,0 @@ -"""Дымовые тесты интерфейса: окно собирается, экраны рисуются, формы отдают данные. - -Гоняются в 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 diff --git a/data/vault.fmdb b/vault.fmdb similarity index 100% rename from data/vault.fmdb rename to vault.fmdb