Только база: исходники переехали в food-market

Репозиторий предназначен для одной вещи — зашифрованной базы vault.fmdb.
Исходники попали сюда по ошибке: приложение пушило ветку, в которой лежал
и код, и данные. Теперь папка с базой — самостоятельный клон этого
репозитория, а программа живёт в gt.ser.gay/kizya/food-market.

База перенесена из data/vault.fmdb в корень и помечена binary, чтобы git
не испортил её переводом строк.
This commit is contained in:
food-market 2026-08-22 21:49:01 +03:00
parent 8479f800f7
commit 92adaa9c48
40 changed files with 7 additions and 7507 deletions

10
.gitattributes vendored
View File

@ -1,8 +1,4 @@
# База зашифрована. Если git примет её за текст и подставит CRLF, файл # База зашифрована. Если git примет её за текст и подставит CRLF,
# перестанет расшифровываться. Помечаем явно. # файл перестанет расшифровываться. Помечаем явно.
*.fmdb binary *.fmdb binary
data/vault.fmdb binary vault.fmdb binary
* text=auto eol=lf
*.png binary
*.ico binary

23
.gitignore vendored
View File

@ -1,20 +1,5 @@
__pycache__/ # Рабочие копии базы. Синхронизируется только vault.fmdb.
*.py[cod] *.bak
.pytest_cache/
.venv/
venv/
build/
dist/
*.spec.bak
# Собранный exe весит ~50 МБ. Репозиторий и так растёт от снимков базы —
# бинарник в нём не нужен, он собирается из исходников одной командой.
/food-market.exe
# Рабочие артефакты базы. Сама база data/vault.fmdb НЕ игнорируется —
# она и есть предмет синхронизации.
*.tmp *.tmp
data/*.bak vault.remote.fmdb
data/vault.remote.fmdb export-*.json
data/export-*.json

View File

@ -1,5 +0,0 @@
{
"workbench.editorAssociations": {
"*.sprite": "d2rmodding.spriteViewer"
}
}

114
README.md
View File

@ -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`, которые сами считают разницу и
дописывают запись в журнал. Иначе новый экран однажды забыл бы залогировать
изменение, и заметили бы это только когда история понадобится.

View File

@ -1,4 +0,0 @@
"""Булочная — учёт закупки и перепродажи булок."""
APP_NAME = "Булочная"
APP_VERSION = "1.0.0"

View File

@ -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

View File

@ -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/<branch>. Возвращает её 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(
"Локальная база и база на сервере разошлись — их правили с двух машин. "
"Слить зашифрованные файлы автоматически нельзя. "
"Удалённая версия сохранена рядом, выбери нужную в настройках."
)

View File

@ -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

View File

@ -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

View File

@ -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())

View File

@ -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

View File

@ -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(".", ",")

View File

@ -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"

View File

@ -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)

View File

@ -1 +0,0 @@
"""Интерфейс на PySide6."""

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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}"
)

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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}; }}
"""

View File

@ -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)

View File

@ -1,12 +0,0 @@
{
"folders": [
{
"path": "."
}
],
"settings": {
"workbench.editorAssociations": {
"*.sprite": "d2rmodding.spriteViewer"
}
}
}

View File

@ -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,
)

View File

@ -1,3 +0,0 @@
[pytest]
pythonpath = .
testpaths = tests

View File

@ -1,3 +0,0 @@
-r requirements.txt
pytest>=8.0
pyinstaller>=6.0

View File

@ -1,2 +0,0 @@
PySide6>=6.7
cryptography>=42.0

12
run.py
View File

@ -1,12 +0,0 @@
"""Точка входа для запуска и для сборки exe.
Отдельный файл нужен потому, что app/main.py пользуется относительными
импортами: как скрипт он не запускается, а как модуль пакета да.
"""
import sys
from app.main import main
if __name__ == "__main__":
sys.exit(main())

View File

@ -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)

View File

@ -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

View File

@ -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")

View File

@ -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

View File

@ -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")

View File

@ -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")

View File

@ -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