Учёт закупки и перепродажи булок
Десктопное приложение на PySide6. Вся база — один JSON-документ, зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит в data/vault.fmdb и раз в час уезжает в этот репозиторий. Основное: - партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж по партиям и прогресс покрытия к сроку; - типы выбытия: розница, другу по себестоимости, съел сам, подарок, списание — съеденное вычитается из прибыли, за него платить всё равно; - долги контрагентов с частичными оплатами; - номенклатура с историей изменения цен; - журнал изменений внутри базы: git хранит непрозрачные снимки, поэтому настоящая история ведётся здесь. Синхронизация коммитит только путь базы и схлопывает часовые пуши в один коммит на день через amend + force-with-lease. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
c8b9f5048b
8
.gitattributes
vendored
Normal file
8
.gitattributes
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# База зашифрована. Если git примет её за текст и подставит CRLF, файл
|
||||
# перестанет расшифровываться. Помечаем явно.
|
||||
*.fmdb binary
|
||||
data/vault.fmdb binary
|
||||
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.ico binary
|
||||
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
build/
|
||||
dist/
|
||||
*.spec.bak
|
||||
|
||||
# Собранный exe весит ~50 МБ. Репозиторий и так растёт от снимков базы —
|
||||
# бинарник в нём не нужен, он собирается из исходников одной командой.
|
||||
/food-market.exe
|
||||
|
||||
# Рабочие артефакты базы. Сама база data/vault.fmdb НЕ игнорируется —
|
||||
# она и есть предмет синхронизации.
|
||||
*.tmp
|
||||
data/*.bak
|
||||
data/vault.remote.fmdb
|
||||
data/export-*.json
|
||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"workbench.editorAssociations": {
|
||||
"*.sprite": "d2rmodding.spriteViewer"
|
||||
}
|
||||
}
|
||||
114
README.md
Normal file
114
README.md
Normal file
@ -0,0 +1,114 @@
|
||||
# Булочная
|
||||
|
||||
Десктопный учёт закупки и перепродажи булок. Вся база — один файл,
|
||||
зашифрованный паролем, который раз в час уезжает в этот репозиторий.
|
||||
|
||||
## Что считает
|
||||
|
||||
Булки берутся в пекарне **в долг**: к дедлайну надо вернуть себестоимость
|
||||
партии, иначе новых не напекут. Наценка остаётся тебе. Часть булок уходит
|
||||
друзьям по себестоимости, часть съедается — и за них пекарне тоже платить.
|
||||
|
||||
Программа отвечает на главный вопрос: **успеваю ли я собрать себестоимость
|
||||
конкретной партии до её срока**.
|
||||
|
||||
- **Закупки** — партии с составом, ценами и датой, до которой надо рассчитаться.
|
||||
- **Продажи** — розница, «другу по себестоимости», «съел сам», подарок, списание.
|
||||
Цена подставляется по типу.
|
||||
- **Долги** — кто сколько должен, с частичными оплатами.
|
||||
- **Товары** — номенклатура с историей изменения цен.
|
||||
- **Журнал** — что менялось в базе, когда и с какой машины.
|
||||
- **Сводка** — долг пекарне, дебиторка, прибыль, остатки, ближайший дедлайн.
|
||||
|
||||
Продажи разносятся по партиям методом FIFO. Цены у поставщика фиксированные,
|
||||
поэтому на себестоимость это не влияет — FIFO нужен только чтобы понимать,
|
||||
деньги за какую партию уже пришли.
|
||||
|
||||
Прибыль считается как наценка с проданного **минус** себестоимость съеденного
|
||||
и подаренного: за них пекарне платить всё равно, и покрывается это из маржи.
|
||||
|
||||
## Как запустить
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python run.py
|
||||
```
|
||||
|
||||
Собрать один exe:
|
||||
|
||||
```bash
|
||||
pip install -r requirements-dev.txt
|
||||
pyinstaller food-market.spec
|
||||
```
|
||||
|
||||
Готовый `dist/food-market.exe` (~50 МБ) положи в корень клона этого
|
||||
репозитория. Базу он ищет в `data/vault.fmdb` рядом с собой, поэтому достаточно
|
||||
склонировать репозиторий, положить exe в корень и запустить.
|
||||
|
||||
## Хранение и шифрование
|
||||
|
||||
`data/vault.fmdb` — единственный файл с данными:
|
||||
|
||||
```
|
||||
JSON → gzip → AES-256-GCM (ключ: scrypt от пароля) → файл
|
||||
```
|
||||
|
||||
Заголовок файла идёт в GCM как AAD, так что подменить соль или параметры KDF
|
||||
незаметно нельзя. Запись атомарная, предыдущая версия остаётся в `.bak`.
|
||||
|
||||
**Забытый пароль восстановить невозможно.** В настройках есть кнопка выгрузки
|
||||
открытого JSON — держи такую копию отдельно. В ней лежит и токен GitLab.
|
||||
|
||||
## Синхронизация
|
||||
|
||||
Раз в час (интервал настраивается) приложение сохраняет базу, коммитит
|
||||
**только** `data/vault.fmdb` и пушит. Незакоммиченные правки исходников в
|
||||
автокоммит не попадают.
|
||||
|
||||
Токен GitLab хранится внутри зашифрованной базы и подставляется в URL только на
|
||||
время вызова — в `.git/config` он не пишется и в журнал не попадает.
|
||||
|
||||
### Один коммит на день
|
||||
|
||||
Дельта-сжатие на шифртексте не работает: каждый коммит хранит полную копию
|
||||
файла. При часовых пушах это ~1800 коммитов и порядка 60 МБ в год. Поэтому в
|
||||
течение суток приложение дописывает в ту же вершину через `--amend` — выходит
|
||||
около 13 МБ в год.
|
||||
|
||||
Форс-пуш делается только с `--force-with-lease` и только когда все условия
|
||||
выполнены: вершина имеет наш формат сообщения, датирована сегодня, трогает ровно
|
||||
`data/vault.fmdb` и совпадает с тем, что на сервере. Иначе — обычный коммит без
|
||||
переписывания истории.
|
||||
|
||||
Плата за схлопывание: откатиться через git можно на границу суток, а не на любой
|
||||
час. Детализацию внутри дня даёт журнал.
|
||||
|
||||
### Работа с двух машин
|
||||
|
||||
Слить два зашифрованных блоба автоматически невозможно. Если версии разошлись,
|
||||
автопуш останавливается, версия с сервера кладётся рядом как
|
||||
`data/vault.remote.fmdb`, а выбор предлагается в настройках. Ничего не
|
||||
затирается молча.
|
||||
|
||||
## Разработка
|
||||
|
||||
```bash
|
||||
pytest -q
|
||||
```
|
||||
|
||||
Модули:
|
||||
|
||||
| файл | зачем |
|
||||
|---|---|
|
||||
| `app/crypto.py` | формат файла, scrypt + AES-GCM |
|
||||
| `app/models.py` | структура документа, деньги в Decimal |
|
||||
| `app/storage.py` | загрузка, атомарная запись, миграции схемы, хеш «грязности» |
|
||||
| `app/journal.py` | **единственный путь записи** в базу + аудит-лог |
|
||||
| `app/ledger.py` | FIFO, покрытие партий, долги, остатки, сводка |
|
||||
| `app/gitsync.py` | git, схлопывание коммитов, разрешение расхождений |
|
||||
| `app/ui/` | экраны на PySide6 |
|
||||
|
||||
Главное архитектурное правило: **ни один экран не меняет документ напрямую**.
|
||||
Всё идёт через функции `app/journal.py`, которые сами считают разницу и
|
||||
дописывают запись в журнал. Иначе новый экран однажды забыл бы залогировать
|
||||
изменение, и заметили бы это только когда история понадобится.
|
||||
4
app/__init__.py
Normal file
4
app/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""Булочная — учёт закупки и перепродажи булок."""
|
||||
|
||||
APP_NAME = "Булочная"
|
||||
APP_VERSION = "1.0.0"
|
||||
170
app/crypto.py
Normal file
170
app/crypto.py
Normal file
@ -0,0 +1,170 @@
|
||||
"""Формат зашифрованного файла базы (.fmdb).
|
||||
|
||||
Вся база — один JSON-документ. На диск он попадает так:
|
||||
|
||||
json → utf-8 → gzip → AES-256-GCM → файл
|
||||
|
||||
Ключ выводится из пароля через scrypt. Заголовок файла (37 байт) передаётся
|
||||
в GCM как AAD, поэтому подменить параметры KDF или соль незаметно нельзя —
|
||||
расшифровка упадёт с InvalidTag так же, как при неверном пароле.
|
||||
|
||||
смещение размер содержимое
|
||||
0 4 магия b"FMDB"
|
||||
4 1 версия формата
|
||||
5 1 id функции выведения ключа (1 = scrypt)
|
||||
6 3 параметры scrypt: log2(n), r, p
|
||||
9 16 salt
|
||||
25 12 nonce
|
||||
37 … ciphertext + тег GCM (16 байт)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import os
|
||||
import struct
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
||||
|
||||
MAGIC = b"FMDB"
|
||||
FORMAT_VERSION = 1
|
||||
KDF_SCRYPT = 1
|
||||
|
||||
SALT_SIZE = 16
|
||||
NONCE_SIZE = 12
|
||||
KEY_SIZE = 32
|
||||
HEADER_SIZE = 37
|
||||
|
||||
# n=2**15 — примерно 100 мс на вывод ключа. Достаточно, чтобы перебор паролей
|
||||
# был дорогим, и незаметно при открытии базы.
|
||||
SCRYPT_LOG_N = 15
|
||||
SCRYPT_R = 8
|
||||
SCRYPT_P = 1
|
||||
|
||||
_HEADER_STRUCT = struct.Struct(f"!4sBBBBB{SALT_SIZE}s{NONCE_SIZE}s")
|
||||
assert _HEADER_STRUCT.size == HEADER_SIZE
|
||||
|
||||
|
||||
class VaultError(Exception):
|
||||
"""Базовая ошибка работы с файлом базы."""
|
||||
|
||||
|
||||
class WrongPassword(VaultError):
|
||||
"""Пароль неверный либо файл повреждён/подделан.
|
||||
|
||||
Намеренно не различаем эти случаи: по тексту ошибки не должно быть видно,
|
||||
угадана ли часть пароля.
|
||||
"""
|
||||
|
||||
|
||||
class UnsupportedFormat(VaultError):
|
||||
"""Файл создан более новой версией программы либо это вообще не база."""
|
||||
|
||||
|
||||
class Header:
|
||||
"""Разобранный заголовок файла."""
|
||||
|
||||
__slots__ = ("version", "kdf_id", "log_n", "r", "p", "salt", "nonce", "raw")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
version: int,
|
||||
kdf_id: int,
|
||||
log_n: int,
|
||||
r: int,
|
||||
p: int,
|
||||
salt: bytes,
|
||||
nonce: bytes,
|
||||
) -> None:
|
||||
self.version = version
|
||||
self.kdf_id = kdf_id
|
||||
self.log_n = log_n
|
||||
self.r = r
|
||||
self.p = p
|
||||
self.salt = salt
|
||||
self.nonce = nonce
|
||||
self.raw = _HEADER_STRUCT.pack(
|
||||
MAGIC, version, kdf_id, log_n, r, p, salt, nonce
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def new(cls) -> "Header":
|
||||
"""Свежий заголовок со случайными salt и nonce."""
|
||||
return cls(
|
||||
version=FORMAT_VERSION,
|
||||
kdf_id=KDF_SCRYPT,
|
||||
log_n=SCRYPT_LOG_N,
|
||||
r=SCRYPT_R,
|
||||
p=SCRYPT_P,
|
||||
salt=os.urandom(SALT_SIZE),
|
||||
nonce=os.urandom(NONCE_SIZE),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def parse(cls, blob: bytes) -> "Header":
|
||||
if len(blob) < HEADER_SIZE:
|
||||
raise UnsupportedFormat("Файл слишком короткий, это не база.")
|
||||
|
||||
magic, version, kdf_id, log_n, r, p, salt, nonce = _HEADER_STRUCT.unpack(
|
||||
blob[:HEADER_SIZE]
|
||||
)
|
||||
if magic != MAGIC:
|
||||
raise UnsupportedFormat("Это не файл базы (неверная сигнатура).")
|
||||
if version > FORMAT_VERSION:
|
||||
raise UnsupportedFormat(
|
||||
f"Файл создан более новой версией программы (формат {version}). "
|
||||
"Обнови приложение."
|
||||
)
|
||||
if kdf_id != KDF_SCRYPT:
|
||||
raise UnsupportedFormat(f"Неизвестный способ выведения ключа: {kdf_id}.")
|
||||
# Верхняя граница защищает от файла, который просит гигабайты памяти.
|
||||
if not 10 <= log_n <= 22:
|
||||
raise UnsupportedFormat(f"Недопустимый параметр scrypt: log2(n)={log_n}.")
|
||||
if not 1 <= r <= 32 or not 1 <= p <= 16:
|
||||
raise UnsupportedFormat("Недопустимые параметры scrypt.")
|
||||
|
||||
return cls(version, kdf_id, log_n, r, p, salt, nonce)
|
||||
|
||||
|
||||
def derive_key(password: str, header: Header) -> bytes:
|
||||
"""Вывести ключ шифрования из пароля по параметрам из заголовка."""
|
||||
kdf = Scrypt(
|
||||
salt=header.salt,
|
||||
length=KEY_SIZE,
|
||||
n=2**header.log_n,
|
||||
r=header.r,
|
||||
p=header.p,
|
||||
)
|
||||
return kdf.derive(password.encode("utf-8"))
|
||||
|
||||
|
||||
def encrypt(plaintext: bytes, password: str) -> bytes:
|
||||
"""Сжать и зашифровать открытые данные, вернуть содержимое файла целиком.
|
||||
|
||||
Каждый вызов берёт новые salt и nonce, поэтому одни и те же данные дают
|
||||
разные байты. Проверять «изменилось ли что-то» по этому результату нельзя —
|
||||
для этого storage хранит хеш открытого JSON.
|
||||
"""
|
||||
header = Header.new()
|
||||
key = derive_key(password, header)
|
||||
packed = gzip.compress(plaintext, compresslevel=9, mtime=0)
|
||||
ciphertext = AESGCM(key).encrypt(header.nonce, packed, header.raw)
|
||||
return header.raw + ciphertext
|
||||
|
||||
|
||||
def decrypt(blob: bytes, password: str) -> bytes:
|
||||
"""Расшифровать содержимое файла, вернуть открытые данные."""
|
||||
header = Header.parse(blob)
|
||||
key = derive_key(password, header)
|
||||
try:
|
||||
packed = AESGCM(key).decrypt(header.nonce, blob[HEADER_SIZE:], header.raw)
|
||||
except InvalidTag as exc:
|
||||
raise WrongPassword("Неверный пароль или файл повреждён.") from exc
|
||||
|
||||
try:
|
||||
return gzip.decompress(packed)
|
||||
except (OSError, EOFError) as exc:
|
||||
# Сюда попасть почти невозможно: GCM уже подтвердил целостность.
|
||||
raise VaultError("Данные расшифровались, но распаковать их не удалось.") from exc
|
||||
399
app/gitsync.py
Normal file
399
app/gitsync.py
Normal file
@ -0,0 +1,399 @@
|
||||
"""Синхронизация зашифрованной базы через системный git.
|
||||
|
||||
Git тут — канал бэкапа и переноса между машинами, а не источник истории:
|
||||
зашифрованный блоб непрозрачен, `git diff` по нему бесполезен. Историю ведёт
|
||||
журнал внутри базы (см. journal.py).
|
||||
|
||||
Два решения, определяющие всё остальное:
|
||||
|
||||
1. **Коммитим только путь базы.** Все команды заканчиваются на
|
||||
`-- data/vault.fmdb`, поэтому незакоммиченные правки исходников никогда не
|
||||
уедут в автокоммит вместе с данными.
|
||||
|
||||
2. **Один коммит на день.** Дельта-сжатие на шифртексте не работает, каждый
|
||||
коммит хранит полную копию файла. При часовых пушах это ~1800 коммитов и
|
||||
порядка 60 МБ в год. Поэтому в течение суток дописываем в ту же вершину
|
||||
через --amend. Форс — только --force-with-lease и только когда мы точно
|
||||
знаем, что вершина наша и никем не сдвинута.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urlsplit, urlunsplit
|
||||
|
||||
from .models import GitSettings
|
||||
|
||||
COMMIT_PREFIX = "vault"
|
||||
COMMIT_RE = re.compile(r"^vault \d{4}-\d{2}-\d{2}$")
|
||||
|
||||
LOCAL_TIMEOUT = 30
|
||||
NETWORK_TIMEOUT = 180
|
||||
|
||||
|
||||
class GitError(Exception):
|
||||
"""Любая проблема с git. Текст показывается пользователю как есть."""
|
||||
|
||||
|
||||
class GitNotAvailable(GitError):
|
||||
pass
|
||||
|
||||
|
||||
class Diverged(GitError):
|
||||
"""Локальная и удалённая версии разошлись.
|
||||
|
||||
Слить два зашифрованных блоба автоматически невозможно, поэтому выбор
|
||||
всегда за пользователем — молча затирать чью-то работу нельзя.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncResult:
|
||||
committed: bool = False
|
||||
amended: bool = False
|
||||
pushed: bool = False
|
||||
pulled: bool = False
|
||||
message: str = ""
|
||||
|
||||
|
||||
def git_executable() -> str | None:
|
||||
return shutil.which("git")
|
||||
|
||||
|
||||
def commit_message(today: date | None = None) -> str:
|
||||
"""Сообщение коммита без единой цифры о бизнесе.
|
||||
|
||||
Дата нужна, чтобы понимать, можно ли дописать в сегодняшнюю вершину.
|
||||
Количества партий и суммы сюда не попадают: история git может быть видна
|
||||
тому, у кого нет пароля от базы.
|
||||
"""
|
||||
return f"{COMMIT_PREFIX} {(today or date.today()).isoformat()}"
|
||||
|
||||
|
||||
def auth_url(remote_url: str, token: str) -> str:
|
||||
"""Подставить токен в URL.
|
||||
|
||||
В .git/config токен не пишем — иначе он лежал бы открытым текстом рядом
|
||||
с зашифрованной базой, обесценивая всё шифрование. Расплата: на время
|
||||
вызова URL виден в списке процессов.
|
||||
"""
|
||||
if not token or not remote_url.startswith(("http://", "https://")):
|
||||
return remote_url
|
||||
|
||||
parts = urlsplit(remote_url)
|
||||
host = parts.netloc.rsplit("@", 1)[-1]
|
||||
netloc = f"oauth2:{quote(token, safe='')}@{host}"
|
||||
return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
|
||||
|
||||
|
||||
def scrub(text: str, token: str) -> str:
|
||||
"""Убрать токен из текста перед показом или логированием."""
|
||||
if not token:
|
||||
return text
|
||||
return text.replace(token, "***").replace(quote(token, safe=""), "***")
|
||||
|
||||
|
||||
class GitSync:
|
||||
"""Обёртка над git для одного репозитория с одним синхронизируемым файлом."""
|
||||
|
||||
def __init__(self, repo_dir: Path, rel_path: str, settings: GitSettings):
|
||||
self.repo_dir = Path(repo_dir)
|
||||
self.rel_path = rel_path
|
||||
self.settings = settings
|
||||
|
||||
# --- запуск git ---
|
||||
|
||||
def _run(self, args: list[str], timeout: int = LOCAL_TIMEOUT, check: bool = True) -> subprocess.CompletedProcess:
|
||||
exe = git_executable()
|
||||
if exe is None:
|
||||
raise GitNotAvailable(
|
||||
"git не найден. Установи Git для Windows — без него "
|
||||
"синхронизация не работает, но приложение считает всё локально."
|
||||
)
|
||||
|
||||
env = dict(os.environ)
|
||||
# Без этого git может молча повиснуть на запросе логина в фоновом потоке.
|
||||
env["GIT_TERMINAL_PROMPT"] = "0"
|
||||
env["GCM_INTERACTIVE"] = "never"
|
||||
env["GIT_ASKPASS"] = ""
|
||||
env["LC_ALL"] = "C"
|
||||
|
||||
kwargs = {}
|
||||
if sys.platform == "win32":
|
||||
# Иначе при каждом тике синхронизации мигает окно консоли.
|
||||
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||
|
||||
cmd = [exe, "-c", "credential.helper=", "-c", "core.autocrlf=false", *args]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=self.repo_dir,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
**kwargs,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise GitError(f"git не ответил за {timeout} с — проверь сеть.") from exc
|
||||
|
||||
if check and proc.returncode != 0:
|
||||
stderr = proc.stderr.decode("utf-8", "replace").strip()
|
||||
raise GitError(scrub(stderr or "неизвестная ошибка git", self.settings.token))
|
||||
return proc
|
||||
|
||||
def _out(self, args: list[str], **kwargs) -> str:
|
||||
return self._run(args, **kwargs).stdout.decode("utf-8", "replace").strip()
|
||||
|
||||
# --- состояние репозитория ---
|
||||
|
||||
@property
|
||||
def branch(self) -> str:
|
||||
return self.settings.branch or "main"
|
||||
|
||||
@property
|
||||
def _remote_ref(self) -> str:
|
||||
return f"refs/remotes/origin/{self.branch}"
|
||||
|
||||
def is_repo(self) -> bool:
|
||||
return (self.repo_dir / ".git").exists()
|
||||
|
||||
def head_sha(self) -> str | None:
|
||||
proc = self._run(["rev-parse", "HEAD"], check=False)
|
||||
if proc.returncode != 0:
|
||||
return None # репозиторий без единого коммита
|
||||
return proc.stdout.decode().strip()
|
||||
|
||||
def remote_sha(self) -> str | None:
|
||||
proc = self._run(["rev-parse", self._remote_ref], check=False)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
return proc.stdout.decode().strip()
|
||||
|
||||
def _is_ancestor(self, maybe_ancestor: str, descendant: str) -> bool:
|
||||
return self._run(
|
||||
["merge-base", "--is-ancestor", maybe_ancestor, descendant], check=False
|
||||
).returncode == 0
|
||||
|
||||
def has_uncommitted_vault_changes(self) -> bool:
|
||||
return bool(self._out(["status", "--porcelain", "--", self.rel_path]))
|
||||
|
||||
# --- подготовка ---
|
||||
|
||||
def ensure_repo(self) -> None:
|
||||
"""Создать репозиторий и прописать remote, если их ещё нет."""
|
||||
if not self.is_repo():
|
||||
self.repo_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._run(["init", "-b", self.branch])
|
||||
|
||||
self._run(["config", "user.name", self.settings.author_name or "food-market"])
|
||||
self._run(["config", "user.email", self.settings.author_email or "food-market@localhost"])
|
||||
|
||||
if self.settings.remote_url:
|
||||
# Кладём чистый URL: токен подставляется только в момент вызова.
|
||||
existing = self._run(["remote", "get-url", "origin"], check=False)
|
||||
if existing.returncode != 0:
|
||||
self._run(["remote", "add", "origin", self.settings.remote_url])
|
||||
elif existing.stdout.decode().strip() != self.settings.remote_url:
|
||||
self._run(["remote", "set-url", "origin", self.settings.remote_url])
|
||||
|
||||
# --- обмен с сервером ---
|
||||
|
||||
def fetch(self) -> str | None:
|
||||
"""Забрать удалённую ветку в origin/<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(
|
||||
"Локальная база и база на сервере разошлись — их правили с двух машин. "
|
||||
"Слить зашифрованные файлы автоматически нельзя. "
|
||||
"Удалённая версия сохранена рядом, выбери нужную в настройках."
|
||||
)
|
||||
798
app/journal.py
Normal file
798
app/journal.py
Normal file
@ -0,0 +1,798 @@
|
||||
"""Единственный путь записи в базу.
|
||||
|
||||
Ни один экран не меняет документ напрямую — всё идёт через функции этого
|
||||
модуля. Каждая из них снимает состояние сущности до и после, считает разницу
|
||||
и дописывает запись в аудит-лог.
|
||||
|
||||
Так сделано потому, что git хранит зашифрованные снимки: `git diff` по ним
|
||||
бесполезен, а после схлопывания коммитов по дням истории внутри суток там нет
|
||||
вовсе. Журнал — единственное место, где видно «когда я поднял цену на булку
|
||||
с маком». Если бы запись шла в обход, новый экран однажды забыл бы залогировать
|
||||
изменение, и дыру заметили бы только когда история понадобится.
|
||||
|
||||
Побочный, но важный эффект: правки, ничего не меняющие по существу, журнал
|
||||
не засоряют — при пустой разнице запись не создаётся и база не помечается
|
||||
изменённой, то есть лишнего коммита в git тоже не будет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
from datetime import date, datetime
|
||||
|
||||
from . import money as m
|
||||
from .models import (
|
||||
SALE_KIND_LABELS,
|
||||
BakeryPayment,
|
||||
Batch,
|
||||
BatchLine,
|
||||
Change,
|
||||
Counterparty,
|
||||
JournalEntry,
|
||||
Payment,
|
||||
PricePoint,
|
||||
Product,
|
||||
Sale,
|
||||
SaleLine,
|
||||
dump_date,
|
||||
new_id,
|
||||
parse_date,
|
||||
)
|
||||
|
||||
# Значения этих настроек в журнал не попадают — только факт изменения.
|
||||
SECRET_SETTINGS = frozenset({"token"})
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Действие невозможно: UI показывает текст пользователю как есть."""
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _host() -> str:
|
||||
try:
|
||||
return socket.gethostname()
|
||||
except OSError:
|
||||
return "?"
|
||||
|
||||
|
||||
def _record(
|
||||
vault,
|
||||
action: str,
|
||||
entity: str,
|
||||
entity_id: str,
|
||||
label: str,
|
||||
changes: list[Change],
|
||||
) -> JournalEntry:
|
||||
entry = JournalEntry(
|
||||
id=new_id("j"),
|
||||
ts=_now_iso(),
|
||||
host=_host(),
|
||||
action=action,
|
||||
entity=entity,
|
||||
entity_id=entity_id,
|
||||
label=label,
|
||||
changes=changes,
|
||||
)
|
||||
vault.doc.journal.append(entry)
|
||||
vault.touch()
|
||||
return entry
|
||||
|
||||
|
||||
def _diff(before: dict[str, str], after: dict[str, str]) -> list[Change]:
|
||||
keys = list(before) + [k for k in after if k not in before]
|
||||
return [
|
||||
Change(field=k, old=before.get(k, ""), new=after.get(k, ""))
|
||||
for k in keys
|
||||
if before.get(k, "") != after.get(k, "")
|
||||
]
|
||||
|
||||
|
||||
def _as_created(flat: dict[str, str]) -> list[Change]:
|
||||
return [Change(field=k, old="", new=v) for k, v in flat.items() if v]
|
||||
|
||||
|
||||
def _as_deleted(flat: dict[str, str]) -> list[Change]:
|
||||
return [Change(field=k, old=v, new="") for k, v in flat.items() if v]
|
||||
|
||||
|
||||
# --- Плоские снимки сущностей для журнала ---------------------------------
|
||||
#
|
||||
# Значения сразу человекочитаемые: журнал читает человек, а не программа,
|
||||
# и «30,00 ₽» полезнее, чем «30».
|
||||
|
||||
|
||||
def _flat_product(doc, p: Product) -> dict[str, str]:
|
||||
return {
|
||||
"название": p.name,
|
||||
"единица": p.unit,
|
||||
"себестоимость": m.fmt_money(p.cost_price, doc.settings.currency),
|
||||
"цена продажи": m.fmt_money(p.retail_price, doc.settings.currency),
|
||||
"в архиве": "да" if p.archived else "",
|
||||
"заметка": p.note,
|
||||
}
|
||||
|
||||
|
||||
def _flat_counterparty(doc, c: Counterparty) -> dict[str, str]:
|
||||
return {"имя": c.name, "тип": c.kind, "заметка": c.note}
|
||||
|
||||
|
||||
def _lines_text(doc, lines, price_attr: str) -> str:
|
||||
cur = doc.settings.currency
|
||||
return "; ".join(
|
||||
f"{doc.product_name(x.product_id)} × {m.fmt_qty(x.qty)} "
|
||||
f"по {m.fmt_money(getattr(x, price_attr), cur)}"
|
||||
for x in lines
|
||||
)
|
||||
|
||||
|
||||
def _flat_batch(doc, b: Batch) -> dict[str, str]:
|
||||
return {
|
||||
"дата": dump_date(b.date),
|
||||
"покрыть до": dump_date(b.due_date),
|
||||
"состав": _lines_text(doc, b.lines, "unit_cost"),
|
||||
"сумма": m.fmt_money(b.cost_total, doc.settings.currency),
|
||||
"закрыта": "да" if b.closed else "",
|
||||
"заметка": b.note,
|
||||
}
|
||||
|
||||
|
||||
def _flat_sale(doc, s: Sale) -> dict[str, str]:
|
||||
cur = doc.settings.currency
|
||||
return {
|
||||
"дата": dump_date(s.date),
|
||||
"тип": SALE_KIND_LABELS.get(s.kind, s.kind),
|
||||
"кому": doc.counterparty_name(s.counterparty_id),
|
||||
"состав": _lines_text(doc, s.lines, "unit_price"),
|
||||
"сумма": m.fmt_money(s.total, cur),
|
||||
"оплачено": m.fmt_money(s.paid, cur),
|
||||
"заметка": s.note,
|
||||
}
|
||||
|
||||
|
||||
def _batch_label(b: Batch) -> str:
|
||||
return f"Партия №{b.number} от {b.date.strftime('%d.%m.%Y')}"
|
||||
|
||||
|
||||
def _sale_label(doc, s: Sale) -> str:
|
||||
who = doc.counterparty_name(s.counterparty_id)
|
||||
kind = SALE_KIND_LABELS.get(s.kind, s.kind)
|
||||
base = f"{kind} от {s.date.strftime('%d.%m.%Y')}"
|
||||
return f"{base} — {who}" if who else base
|
||||
|
||||
|
||||
# --- Номенклатура ---------------------------------------------------------
|
||||
|
||||
|
||||
def create_product(
|
||||
vault,
|
||||
name: str,
|
||||
unit: str = "шт",
|
||||
cost_price=0,
|
||||
retail_price=0,
|
||||
note: str = "",
|
||||
) -> Product:
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise ValidationError("У товара должно быть название.")
|
||||
|
||||
product = Product(
|
||||
id=new_id("p"),
|
||||
name=name,
|
||||
unit=unit.strip() or "шт",
|
||||
cost_price=m.money(cost_price),
|
||||
retail_price=m.money(retail_price),
|
||||
note=note.strip(),
|
||||
)
|
||||
# Стартовая цена — тоже точка истории, иначе первый период остался бы
|
||||
# без объяснения, откуда взялась цифра.
|
||||
product.price_history.append(
|
||||
PricePoint(
|
||||
date=date.today(),
|
||||
cost_price=product.cost_price,
|
||||
retail_price=product.retail_price,
|
||||
note="создание товара",
|
||||
)
|
||||
)
|
||||
vault.doc.products.append(product)
|
||||
_record(
|
||||
vault,
|
||||
"product.create",
|
||||
"product",
|
||||
product.id,
|
||||
product.name,
|
||||
_as_created(_flat_product(vault.doc, product)),
|
||||
)
|
||||
return product
|
||||
|
||||
|
||||
def update_product(
|
||||
vault,
|
||||
product_id: str,
|
||||
name: str | None = None,
|
||||
unit: str | None = None,
|
||||
note: str | None = None,
|
||||
archived: bool | None = None,
|
||||
) -> Product:
|
||||
"""Правка карточки без цен — для цен есть change_prices с историей."""
|
||||
doc = vault.doc
|
||||
product = doc.product(product_id)
|
||||
if product is None:
|
||||
raise ValidationError("Товар не найден.")
|
||||
|
||||
before = _flat_product(doc, product)
|
||||
if name is not None:
|
||||
if not name.strip():
|
||||
raise ValidationError("У товара должно быть название.")
|
||||
product.name = name.strip()
|
||||
if unit is not None:
|
||||
product.unit = unit.strip() or "шт"
|
||||
if note is not None:
|
||||
product.note = note.strip()
|
||||
if archived is not None:
|
||||
product.archived = bool(archived)
|
||||
|
||||
changes = _diff(before, _flat_product(doc, product))
|
||||
if changes:
|
||||
action = "product.archive" if archived is not None and archived else "product.update"
|
||||
_record(vault, action, "product", product.id, product.name, changes)
|
||||
return product
|
||||
|
||||
|
||||
def change_prices(
|
||||
vault,
|
||||
product_id: str,
|
||||
cost_price,
|
||||
retail_price,
|
||||
on_date: date | None = None,
|
||||
note: str = "",
|
||||
) -> Product:
|
||||
"""Поменять цены товара, оставив след в истории цен."""
|
||||
doc = vault.doc
|
||||
product = doc.product(product_id)
|
||||
if product is None:
|
||||
raise ValidationError("Товар не найден.")
|
||||
|
||||
cost_price = m.money(cost_price)
|
||||
retail_price = m.money(retail_price)
|
||||
if cost_price < 0 or retail_price < 0:
|
||||
raise ValidationError("Цена не может быть отрицательной.")
|
||||
if cost_price == product.cost_price and retail_price == product.retail_price:
|
||||
return product
|
||||
|
||||
before = _flat_product(doc, product)
|
||||
product.cost_price = cost_price
|
||||
product.retail_price = retail_price
|
||||
product.price_history.append(
|
||||
PricePoint(
|
||||
date=on_date or date.today(),
|
||||
cost_price=cost_price,
|
||||
retail_price=retail_price,
|
||||
note=note.strip(),
|
||||
)
|
||||
)
|
||||
_record(
|
||||
vault,
|
||||
"product.price_change",
|
||||
"product",
|
||||
product.id,
|
||||
product.name,
|
||||
_diff(before, _flat_product(doc, product)),
|
||||
)
|
||||
return product
|
||||
|
||||
|
||||
def delete_product(vault, product_id: str) -> None:
|
||||
"""Удалить товар, если он нигде не использован.
|
||||
|
||||
Удалять использованный нельзя: закупки и продажи ссылаются на него, и
|
||||
отчёты по закрытым периодам молча превратились бы в «(удалённый товар)».
|
||||
Для вышедших из оборота булок есть архив.
|
||||
"""
|
||||
doc = vault.doc
|
||||
product = doc.product(product_id)
|
||||
if product is None:
|
||||
raise ValidationError("Товар не найден.")
|
||||
|
||||
used_in_batches = sum(
|
||||
1 for b in doc.batches for line in b.lines if line.product_id == product_id
|
||||
)
|
||||
used_in_sales = sum(
|
||||
1 for s in doc.sales for line in s.lines if line.product_id == product_id
|
||||
)
|
||||
if used_in_batches or used_in_sales:
|
||||
raise ValidationError(
|
||||
f"«{product.name}» используется в закупках ({used_in_batches}) "
|
||||
f"и продажах ({used_in_sales}), удалить нельзя. "
|
||||
"Отправь товар в архив — он исчезнет из списков, а история останется."
|
||||
)
|
||||
|
||||
flat = _flat_product(doc, product)
|
||||
doc.products.remove(product)
|
||||
_record(vault, "product.delete", "product", product_id, product.name, _as_deleted(flat))
|
||||
|
||||
|
||||
# --- Контрагенты ----------------------------------------------------------
|
||||
|
||||
|
||||
def create_counterparty(vault, name: str, kind: str = "friend", note: str = "") -> Counterparty:
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise ValidationError("У контрагента должно быть имя.")
|
||||
|
||||
cp = Counterparty(id=new_id("c"), name=name, kind=kind, note=note.strip())
|
||||
vault.doc.counterparties.append(cp)
|
||||
_record(
|
||||
vault,
|
||||
"counterparty.create",
|
||||
"counterparty",
|
||||
cp.id,
|
||||
cp.name,
|
||||
_as_created(_flat_counterparty(vault.doc, cp)),
|
||||
)
|
||||
return cp
|
||||
|
||||
|
||||
def update_counterparty(
|
||||
vault,
|
||||
cp_id: str,
|
||||
name: str | None = None,
|
||||
kind: str | None = None,
|
||||
note: str | None = None,
|
||||
) -> Counterparty:
|
||||
doc = vault.doc
|
||||
cp = doc.counterparty(cp_id)
|
||||
if cp is None:
|
||||
raise ValidationError("Контрагент не найден.")
|
||||
|
||||
before = _flat_counterparty(doc, cp)
|
||||
if name is not None:
|
||||
if not name.strip():
|
||||
raise ValidationError("У контрагента должно быть имя.")
|
||||
cp.name = name.strip()
|
||||
if kind is not None:
|
||||
cp.kind = kind
|
||||
if note is not None:
|
||||
cp.note = note.strip()
|
||||
|
||||
changes = _diff(before, _flat_counterparty(doc, cp))
|
||||
if changes:
|
||||
_record(vault, "counterparty.update", "counterparty", cp.id, cp.name, changes)
|
||||
return cp
|
||||
|
||||
|
||||
def delete_counterparty(vault, cp_id: str) -> None:
|
||||
doc = vault.doc
|
||||
cp = doc.counterparty(cp_id)
|
||||
if cp is None:
|
||||
raise ValidationError("Контрагент не найден.")
|
||||
|
||||
used = sum(1 for s in doc.sales if s.counterparty_id == cp_id)
|
||||
if used:
|
||||
raise ValidationError(
|
||||
f"На «{cp.name}» ссылаются продажи ({used}), удалить нельзя."
|
||||
)
|
||||
|
||||
flat = _flat_counterparty(doc, cp)
|
||||
doc.counterparties.remove(cp)
|
||||
_record(vault, "counterparty.delete", "counterparty", cp_id, cp.name, _as_deleted(flat))
|
||||
|
||||
|
||||
# --- Закупки --------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_batch_lines(doc, lines: list[dict]) -> list[BatchLine]:
|
||||
result: list[BatchLine] = []
|
||||
for raw in lines:
|
||||
product_id = raw.get("product_id")
|
||||
if not product_id or doc.product(product_id) is None:
|
||||
raise ValidationError("В закупке есть строка без существующего товара.")
|
||||
quantity = m.qty(raw.get("qty"))
|
||||
if quantity <= 0:
|
||||
raise ValidationError(
|
||||
f"Количество для «{doc.product_name(product_id)}» должно быть больше нуля."
|
||||
)
|
||||
cost = m.money(raw.get("unit_cost"))
|
||||
if cost < 0:
|
||||
raise ValidationError("Себестоимость не может быть отрицательной.")
|
||||
result.append(BatchLine(product_id=product_id, qty=quantity, unit_cost=cost))
|
||||
|
||||
if not result:
|
||||
raise ValidationError("В закупке должна быть хотя бы одна строка.")
|
||||
return result
|
||||
|
||||
|
||||
def create_batch(
|
||||
vault,
|
||||
on_date: date,
|
||||
due_date: date,
|
||||
lines: list[dict],
|
||||
note: str = "",
|
||||
) -> Batch:
|
||||
doc = vault.doc
|
||||
on_date = parse_date(on_date)
|
||||
due_date = parse_date(due_date)
|
||||
if due_date < on_date:
|
||||
raise ValidationError("Дата покрытия не может быть раньше даты закупки.")
|
||||
|
||||
batch = Batch(
|
||||
id=new_id("b"),
|
||||
number=doc.next_batch_number(),
|
||||
date=on_date,
|
||||
due_date=due_date,
|
||||
lines=_build_batch_lines(doc, lines),
|
||||
note=note.strip(),
|
||||
)
|
||||
doc.batches.append(batch)
|
||||
_record(
|
||||
vault,
|
||||
"batch.create",
|
||||
"batch",
|
||||
batch.id,
|
||||
_batch_label(batch),
|
||||
_as_created(_flat_batch(doc, batch)),
|
||||
)
|
||||
return batch
|
||||
|
||||
|
||||
def update_batch(
|
||||
vault,
|
||||
batch_id: str,
|
||||
on_date: date | None = None,
|
||||
due_date: date | None = None,
|
||||
lines: list[dict] | None = None,
|
||||
note: str | None = None,
|
||||
) -> Batch:
|
||||
doc = vault.doc
|
||||
batch = doc.batch(batch_id)
|
||||
if batch is None:
|
||||
raise ValidationError("Партия не найдена.")
|
||||
|
||||
before = _flat_batch(doc, batch)
|
||||
new_date = parse_date(on_date) if on_date is not None else batch.date
|
||||
new_due = parse_date(due_date) if due_date is not None else batch.due_date
|
||||
if new_due < new_date:
|
||||
raise ValidationError("Дата покрытия не может быть раньше даты закупки.")
|
||||
|
||||
batch.date = new_date
|
||||
batch.due_date = new_due
|
||||
if lines is not None:
|
||||
batch.lines = _build_batch_lines(doc, lines)
|
||||
if note is not None:
|
||||
batch.note = note.strip()
|
||||
|
||||
changes = _diff(before, _flat_batch(doc, batch))
|
||||
if changes:
|
||||
_record(vault, "batch.update", "batch", batch.id, _batch_label(batch), changes)
|
||||
return batch
|
||||
|
||||
|
||||
def set_batch_closed(vault, batch_id: str, closed: bool) -> Batch:
|
||||
doc = vault.doc
|
||||
batch = doc.batch(batch_id)
|
||||
if batch is None:
|
||||
raise ValidationError("Партия не найдена.")
|
||||
if batch.closed == closed:
|
||||
return batch
|
||||
|
||||
before = _flat_batch(doc, batch)
|
||||
batch.closed = closed
|
||||
_record(
|
||||
vault,
|
||||
"batch.close" if closed else "batch.reopen",
|
||||
"batch",
|
||||
batch.id,
|
||||
_batch_label(batch),
|
||||
_diff(before, _flat_batch(doc, batch)),
|
||||
)
|
||||
return batch
|
||||
|
||||
|
||||
def delete_batch(vault, batch_id: str) -> None:
|
||||
doc = vault.doc
|
||||
batch = doc.batch(batch_id)
|
||||
if batch is None:
|
||||
raise ValidationError("Партия не найдена.")
|
||||
|
||||
label = _batch_label(batch)
|
||||
flat = _flat_batch(doc, batch)
|
||||
doc.batches.remove(batch)
|
||||
# Платежи, привязанные к удалённой партии, отвязываем, а не удаляем:
|
||||
# деньги пекарне реально ушли, и терять их из учёта нельзя.
|
||||
for payment in doc.bakery_payments:
|
||||
if payment.batch_id == batch_id:
|
||||
payment.batch_id = None
|
||||
_record(vault, "batch.delete", "batch", batch_id, label, _as_deleted(flat))
|
||||
|
||||
|
||||
# --- Продажи и выбытие ----------------------------------------------------
|
||||
|
||||
|
||||
def _build_sale_lines(doc, lines: list[dict]) -> list[SaleLine]:
|
||||
result: list[SaleLine] = []
|
||||
for raw in lines:
|
||||
product_id = raw.get("product_id")
|
||||
if not product_id or doc.product(product_id) is None:
|
||||
raise ValidationError("В продаже есть строка без существующего товара.")
|
||||
quantity = m.qty(raw.get("qty"))
|
||||
if quantity <= 0:
|
||||
raise ValidationError(
|
||||
f"Количество для «{doc.product_name(product_id)}» должно быть больше нуля."
|
||||
)
|
||||
price = m.money(raw.get("unit_price"))
|
||||
if price < 0:
|
||||
raise ValidationError("Цена не может быть отрицательной.")
|
||||
result.append(SaleLine(product_id=product_id, qty=quantity, unit_price=price))
|
||||
|
||||
if not result:
|
||||
raise ValidationError("В продаже должна быть хотя бы одна строка.")
|
||||
return result
|
||||
|
||||
|
||||
def create_sale(
|
||||
vault,
|
||||
on_date: date,
|
||||
kind: str,
|
||||
lines: list[dict],
|
||||
counterparty_id: str | None = None,
|
||||
note: str = "",
|
||||
paid_amount=None,
|
||||
) -> Sale:
|
||||
"""Записать выбытие товара.
|
||||
|
||||
paid_amount=None означает «оплачено полностью» — самый частый случай.
|
||||
Явный ноль или частичная сумма создают долг.
|
||||
"""
|
||||
doc = vault.doc
|
||||
sale = Sale(
|
||||
id=new_id("s"),
|
||||
date=parse_date(on_date),
|
||||
kind=kind,
|
||||
counterparty_id=counterparty_id or None,
|
||||
lines=_build_sale_lines(doc, lines),
|
||||
note=note.strip(),
|
||||
)
|
||||
|
||||
if not sale.is_consumption:
|
||||
amount = sale.total if paid_amount is None else m.money(paid_amount)
|
||||
if amount < 0:
|
||||
raise ValidationError("Оплата не может быть отрицательной.")
|
||||
if amount > 0:
|
||||
sale.payments.append(Payment(date=sale.date, amount=amount))
|
||||
|
||||
doc.sales.append(sale)
|
||||
_record(
|
||||
vault,
|
||||
"sale.create",
|
||||
"sale",
|
||||
sale.id,
|
||||
_sale_label(doc, sale),
|
||||
_as_created(_flat_sale(doc, sale)),
|
||||
)
|
||||
return sale
|
||||
|
||||
|
||||
def update_sale(
|
||||
vault,
|
||||
sale_id: str,
|
||||
on_date: date | None = None,
|
||||
kind: str | None = None,
|
||||
lines: list[dict] | None = None,
|
||||
counterparty_id: str | None = None,
|
||||
note: str | None = None,
|
||||
) -> Sale:
|
||||
"""Правка продажи. Платежи не трогает — для них есть отдельные операции."""
|
||||
doc = vault.doc
|
||||
sale = doc.sale(sale_id)
|
||||
if sale is None:
|
||||
raise ValidationError("Продажа не найдена.")
|
||||
|
||||
before = _flat_sale(doc, sale)
|
||||
if on_date is not None:
|
||||
sale.date = parse_date(on_date)
|
||||
if kind is not None:
|
||||
sale.kind = kind
|
||||
if lines is not None:
|
||||
sale.lines = _build_sale_lines(doc, lines)
|
||||
if counterparty_id is not None:
|
||||
sale.counterparty_id = counterparty_id or None
|
||||
if note is not None:
|
||||
sale.note = note.strip()
|
||||
|
||||
changes = _diff(before, _flat_sale(doc, sale))
|
||||
if changes:
|
||||
_record(vault, "sale.update", "sale", sale.id, _sale_label(doc, sale), changes)
|
||||
return sale
|
||||
|
||||
|
||||
def delete_sale(vault, sale_id: str) -> None:
|
||||
doc = vault.doc
|
||||
sale = doc.sale(sale_id)
|
||||
if sale is None:
|
||||
raise ValidationError("Продажа не найдена.")
|
||||
|
||||
label = _sale_label(doc, sale)
|
||||
flat = _flat_sale(doc, sale)
|
||||
doc.sales.remove(sale)
|
||||
_record(vault, "sale.delete", "sale", sale_id, label, _as_deleted(flat))
|
||||
|
||||
|
||||
def add_sale_payment(vault, sale_id: str, amount, on_date: date | None = None, note: str = "") -> Sale:
|
||||
doc = vault.doc
|
||||
sale = doc.sale(sale_id)
|
||||
if sale is None:
|
||||
raise ValidationError("Продажа не найдена.")
|
||||
|
||||
amount = m.money(amount)
|
||||
if amount <= 0:
|
||||
raise ValidationError("Сумма оплаты должна быть больше нуля.")
|
||||
|
||||
before = _flat_sale(doc, sale)
|
||||
sale.payments.append(
|
||||
Payment(date=parse_date(on_date) if on_date else date.today(), amount=amount, note=note.strip())
|
||||
)
|
||||
_record(
|
||||
vault,
|
||||
"sale.payment_add",
|
||||
"sale",
|
||||
sale.id,
|
||||
_sale_label(doc, sale),
|
||||
_diff(before, _flat_sale(doc, sale)),
|
||||
)
|
||||
return sale
|
||||
|
||||
|
||||
def delete_sale_payment(vault, sale_id: str, index: int) -> Sale:
|
||||
doc = vault.doc
|
||||
sale = doc.sale(sale_id)
|
||||
if sale is None:
|
||||
raise ValidationError("Продажа не найдена.")
|
||||
if not 0 <= index < len(sale.payments):
|
||||
raise ValidationError("Такой оплаты нет.")
|
||||
|
||||
before = _flat_sale(doc, sale)
|
||||
sale.payments.pop(index)
|
||||
_record(
|
||||
vault,
|
||||
"sale.payment_delete",
|
||||
"sale",
|
||||
sale.id,
|
||||
_sale_label(doc, sale),
|
||||
_diff(before, _flat_sale(doc, sale)),
|
||||
)
|
||||
return sale
|
||||
|
||||
|
||||
# --- Расчёты с пекарней ---------------------------------------------------
|
||||
|
||||
|
||||
def add_bakery_payment(
|
||||
vault,
|
||||
amount,
|
||||
on_date: date | None = None,
|
||||
batch_id: str | None = None,
|
||||
note: str = "",
|
||||
) -> BakeryPayment:
|
||||
doc = vault.doc
|
||||
amount = m.money(amount)
|
||||
if amount <= 0:
|
||||
raise ValidationError("Сумма платежа должна быть больше нуля.")
|
||||
if batch_id and doc.batch(batch_id) is None:
|
||||
raise ValidationError("Партия не найдена.")
|
||||
|
||||
payment = BakeryPayment(
|
||||
id=new_id("bp"),
|
||||
date=parse_date(on_date) if on_date else date.today(),
|
||||
amount=amount,
|
||||
batch_id=batch_id or None,
|
||||
note=note.strip(),
|
||||
)
|
||||
doc.bakery_payments.append(payment)
|
||||
|
||||
batch = doc.batch(batch_id) if batch_id else None
|
||||
_record(
|
||||
vault,
|
||||
"bakery_payment.add",
|
||||
"bakery_payment",
|
||||
payment.id,
|
||||
f"Платёж пекарне {m.fmt_money(amount, doc.settings.currency)}",
|
||||
[
|
||||
Change("дата", "", dump_date(payment.date)),
|
||||
Change("сумма", "", m.fmt_money(amount, doc.settings.currency)),
|
||||
Change("партия", "", _batch_label(batch) if batch else "без привязки"),
|
||||
Change("заметка", "", payment.note),
|
||||
],
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
def delete_bakery_payment(vault, payment_id: str) -> None:
|
||||
doc = vault.doc
|
||||
payment = next((p for p in doc.bakery_payments if p.id == payment_id), None)
|
||||
if payment is None:
|
||||
raise ValidationError("Платёж не найден.")
|
||||
|
||||
doc.bakery_payments.remove(payment)
|
||||
_record(
|
||||
vault,
|
||||
"bakery_payment.delete",
|
||||
"bakery_payment",
|
||||
payment_id,
|
||||
f"Платёж пекарне {m.fmt_money(payment.amount, doc.settings.currency)}",
|
||||
[
|
||||
Change("дата", dump_date(payment.date), ""),
|
||||
Change("сумма", m.fmt_money(payment.amount, doc.settings.currency), ""),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# --- Настройки ------------------------------------------------------------
|
||||
|
||||
|
||||
def update_settings(vault, currency=None, default_period_days=None, git: dict | None = None):
|
||||
"""Правка настроек.
|
||||
|
||||
Значение токена в журнал не пишется — иначе секрет, ради которого всё
|
||||
и шифруется, лежал бы открытым текстом внутри той же базы и уезжал бы
|
||||
в открытый экспорт.
|
||||
"""
|
||||
doc = vault.doc
|
||||
settings = doc.settings
|
||||
changes: list[Change] = []
|
||||
|
||||
if currency is not None and currency != settings.currency:
|
||||
changes.append(Change("валюта", settings.currency, currency))
|
||||
settings.currency = currency
|
||||
|
||||
if default_period_days is not None and int(default_period_days) != settings.default_period_days:
|
||||
changes.append(
|
||||
Change("период по умолчанию", str(settings.default_period_days), str(default_period_days))
|
||||
)
|
||||
settings.default_period_days = int(default_period_days)
|
||||
|
||||
for key, value in (git or {}).items():
|
||||
if not hasattr(settings.git, key):
|
||||
continue
|
||||
old = getattr(settings.git, key)
|
||||
if isinstance(old, bool):
|
||||
value = bool(value)
|
||||
elif isinstance(old, int) and not isinstance(old, bool):
|
||||
value = int(value)
|
||||
if old == value:
|
||||
continue
|
||||
|
||||
setattr(settings.git, key, value)
|
||||
if key in SECRET_SETTINGS:
|
||||
changes.append(Change(f"git.{key}", "скрыто", "изменён" if value else "удалён"))
|
||||
else:
|
||||
changes.append(Change(f"git.{key}", str(old), str(value)))
|
||||
|
||||
if changes:
|
||||
_record(vault, "settings.update", "settings", "settings", "Настройки", changes)
|
||||
return settings
|
||||
|
||||
|
||||
# --- Обслуживание журнала -------------------------------------------------
|
||||
|
||||
|
||||
def prune_journal(vault, before: date) -> int:
|
||||
"""Выбросить записи журнала старше указанной даты. Возвращает их число."""
|
||||
doc = vault.doc
|
||||
cutoff = dump_date(parse_date(before))
|
||||
kept = [e for e in doc.journal if e.ts[:10] >= cutoff]
|
||||
removed = len(doc.journal) - len(kept)
|
||||
if not removed:
|
||||
return 0
|
||||
|
||||
doc.journal = kept
|
||||
_record(
|
||||
vault,
|
||||
"journal.prune",
|
||||
"journal",
|
||||
"journal",
|
||||
"Журнал",
|
||||
[Change("удалено записей старше " + cutoff, "", str(removed))],
|
||||
)
|
||||
return removed
|
||||
485
app/ledger.py
Normal file
485
app/ledger.py
Normal file
@ -0,0 +1,485 @@
|
||||
"""Расчёты по партиям, долгам и остаткам. Чистые функции, без Qt.
|
||||
|
||||
Главный вопрос, на который отвечает модуль: успеваю ли я собрать себестоимость
|
||||
конкретной партии до её дедлайна. Чтобы на него ответить, недостаточно знать
|
||||
общую выручку — нужно понимать, деньги за какую именно партию уже пришли.
|
||||
Поэтому продажи распределяются по партиям методом FIFO.
|
||||
|
||||
Цены у поставщика фиксированные, так что на саму себестоимость выбор FIFO
|
||||
не влияет. Он нужен ровно для привязки выручки к периоду.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from . import money as m
|
||||
from .models import (
|
||||
Batch,
|
||||
CONSUMPTION_KINDS,
|
||||
Document,
|
||||
Sale,
|
||||
)
|
||||
|
||||
# За сколько дней до дедлайна партия начинает считаться «горящей».
|
||||
DUE_SOON_DAYS = 3
|
||||
|
||||
STATUS_SETTLED = "settled"
|
||||
STATUS_OVERDUE = "overdue"
|
||||
STATUS_DUE_SOON = "due_soon"
|
||||
STATUS_OPEN = "open"
|
||||
|
||||
STATUS_LABELS = {
|
||||
STATUS_SETTLED: "Закрыта",
|
||||
STATUS_OVERDUE: "Просрочена",
|
||||
STATUS_DUE_SOON: "Горит",
|
||||
STATUS_OPEN: "В работе",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Allocation:
|
||||
"""Кусок продажи, списанный с конкретной партии."""
|
||||
|
||||
sale_id: str
|
||||
line_index: int
|
||||
product_id: str
|
||||
batch_id: str
|
||||
qty: Decimal
|
||||
unit_cost: Decimal
|
||||
unit_price: Decimal
|
||||
kind: str
|
||||
paid_fraction: Decimal
|
||||
|
||||
@property
|
||||
def cost(self) -> Decimal:
|
||||
return self.qty * self.unit_cost
|
||||
|
||||
@property
|
||||
def revenue(self) -> Decimal:
|
||||
"""Начислено — включая то, что ещё не оплачено."""
|
||||
return self.qty * self.unit_price
|
||||
|
||||
@property
|
||||
def cash(self) -> Decimal:
|
||||
"""Реально полученные деньги с учётом частичной оплаты."""
|
||||
return self.revenue * self.paid_fraction
|
||||
|
||||
|
||||
@dataclass
|
||||
class Shortfall:
|
||||
"""Продали больше, чем закупали: столько-то штук взялись из ниоткуда."""
|
||||
|
||||
sale_id: str
|
||||
product_id: str
|
||||
qty: Decimal
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchReport:
|
||||
batch: Batch
|
||||
cost_total: Decimal
|
||||
qty_total: Decimal
|
||||
qty_sold: Decimal
|
||||
qty_left: Decimal
|
||||
revenue: Decimal
|
||||
cash_collected: Decimal
|
||||
consumed_cost: Decimal
|
||||
paid_to_bakery: Decimal
|
||||
days_left: int
|
||||
status: str
|
||||
stock: dict[str, Decimal] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def remaining_to_bakery(self) -> Decimal:
|
||||
return max(m.ZERO, m.money(self.cost_total - self.paid_to_bakery))
|
||||
|
||||
@property
|
||||
def coverage_pct(self) -> Decimal:
|
||||
"""Какая доля себестоимости уже собрана деньгами."""
|
||||
if self.cost_total <= 0:
|
||||
return Decimal(100)
|
||||
return m.money(self.cash_collected / self.cost_total * 100)
|
||||
|
||||
@property
|
||||
def still_to_collect(self) -> Decimal:
|
||||
"""Сколько денег ещё надо собрать, чтобы выйти в ноль по этой партии."""
|
||||
return max(m.ZERO, m.money(self.cost_total - self.cash_collected))
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return f"№{self.batch.number} от {self.batch.date.strftime('%d.%m.%Y')}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DebtorReport:
|
||||
counterparty_id: str | None
|
||||
name: str
|
||||
debt: Decimal
|
||||
sales: list[Sale] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StockRow:
|
||||
product_id: str
|
||||
name: str
|
||||
unit: str
|
||||
qty: Decimal
|
||||
cost: Decimal
|
||||
|
||||
|
||||
@dataclass
|
||||
class Summary:
|
||||
owed_to_bakery: Decimal
|
||||
receivable: Decimal
|
||||
revenue: Decimal
|
||||
cash_collected: Decimal
|
||||
gross_margin: Decimal
|
||||
consumed_cost: Decimal
|
||||
stock_qty: Decimal
|
||||
stock_cost: Decimal
|
||||
overdue_count: int
|
||||
next_due: BatchReport | None
|
||||
|
||||
@property
|
||||
def profit(self) -> Decimal:
|
||||
"""Что осталось тебе.
|
||||
|
||||
Съеденное и подаренное вычитается: пекарне за эти булки всё равно
|
||||
платить, и покрывается это из наценки на проданных.
|
||||
"""
|
||||
return m.money(self.gross_margin - self.consumed_cost)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Report:
|
||||
today: date
|
||||
batches: list[BatchReport]
|
||||
allocations: list[Allocation]
|
||||
shortfalls: list[Shortfall]
|
||||
debtors: list[DebtorReport]
|
||||
stock: list[StockRow]
|
||||
summary: Summary
|
||||
unassigned_bakery_payment: Decimal
|
||||
warnings: list[str]
|
||||
|
||||
def batch_report(self, batch_id: str) -> BatchReport | None:
|
||||
return next((r for r in self.batches if r.batch.id == batch_id), None)
|
||||
|
||||
@property
|
||||
def open_batches(self) -> list[BatchReport]:
|
||||
return [r for r in self.batches if r.status != STATUS_SETTLED]
|
||||
|
||||
|
||||
# --- Распределение продаж по партиям --------------------------------------
|
||||
|
||||
|
||||
def _paid_fraction(sale: Sale) -> Decimal:
|
||||
"""Какая доля продажи оплачена. Переплата не даёт больше единицы."""
|
||||
total = sale.total
|
||||
if total <= 0:
|
||||
return m.ZERO
|
||||
return min(Decimal(1), sale.paid / total)
|
||||
|
||||
|
||||
def _ordered_batches(doc: Document) -> list[tuple[int, Batch]]:
|
||||
return sorted(enumerate(doc.batches), key=lambda t: (t[1].date, t[1].number, t[0]))
|
||||
|
||||
|
||||
def _ordered_sales(doc: Document) -> list[tuple[int, Sale]]:
|
||||
# Порядок ввода — вторичный ключ: две продажи одной датой должны
|
||||
# распределяться одинаково при каждом пересчёте.
|
||||
return sorted(enumerate(doc.sales), key=lambda t: (t[1].date, t[0]))
|
||||
|
||||
|
||||
def allocate(doc: Document) -> tuple[list[Allocation], list[Shortfall], dict[str, dict[str, Decimal]]]:
|
||||
"""Разложить продажи по партиям методом FIFO.
|
||||
|
||||
Возвращает аллокации, нехватку товара и остаток по каждой партии.
|
||||
|
||||
Намеренно не проверяем, что дата продажи позже даты партии: люди заводят
|
||||
документы в произвольном порядке, и жаловаться на это было бы шумом.
|
||||
Значение имеет только очередь партий.
|
||||
"""
|
||||
remaining: dict[str, dict[str, Decimal]] = {}
|
||||
for _, batch in _ordered_batches(doc):
|
||||
per_product = remaining.setdefault(batch.id, {})
|
||||
for line in batch.lines:
|
||||
per_product[line.product_id] = per_product.get(line.product_id, m.ZERO) + line.qty
|
||||
|
||||
# Себестоимость берём из строки партии — это слепок цены на момент закупки.
|
||||
unit_costs: dict[tuple[str, str], Decimal] = {
|
||||
(batch.id, line.product_id): line.unit_cost
|
||||
for _, batch in _ordered_batches(doc)
|
||||
for line in batch.lines
|
||||
}
|
||||
order = [batch.id for _, batch in _ordered_batches(doc)]
|
||||
|
||||
allocations: list[Allocation] = []
|
||||
shortfalls: list[Shortfall] = []
|
||||
|
||||
for _, sale in _ordered_sales(doc):
|
||||
fraction = _paid_fraction(sale)
|
||||
for line_index, line in enumerate(sale.lines):
|
||||
need = line.qty
|
||||
for batch_id in order:
|
||||
if need <= 0:
|
||||
break
|
||||
available = remaining.get(batch_id, {}).get(line.product_id, m.ZERO)
|
||||
if available <= 0:
|
||||
continue
|
||||
|
||||
take = min(available, need)
|
||||
remaining[batch_id][line.product_id] = available - take
|
||||
need -= take
|
||||
allocations.append(
|
||||
Allocation(
|
||||
sale_id=sale.id,
|
||||
line_index=line_index,
|
||||
product_id=line.product_id,
|
||||
batch_id=batch_id,
|
||||
qty=take,
|
||||
unit_cost=unit_costs.get((batch_id, line.product_id), m.ZERO),
|
||||
unit_price=line.unit_price,
|
||||
kind=sale.kind,
|
||||
paid_fraction=fraction,
|
||||
)
|
||||
)
|
||||
|
||||
if need > 0:
|
||||
shortfalls.append(
|
||||
Shortfall(sale_id=sale.id, product_id=line.product_id, qty=need)
|
||||
)
|
||||
|
||||
return allocations, shortfalls, remaining
|
||||
|
||||
|
||||
# --- Разнесение платежей пекарне ------------------------------------------
|
||||
|
||||
|
||||
def _spread_bakery_payments(doc: Document, cost_totals: dict[str, Decimal]) -> tuple[dict[str, Decimal], Decimal]:
|
||||
"""Разложить платежи пекарне по партиям.
|
||||
|
||||
Платёж с указанной партией гасит её; излишек не превращается в
|
||||
отрицательный долг, а переливается в общий котёл. Платежи без привязки
|
||||
гасят старейшие непокрытые партии — так же, как это происходит в жизни.
|
||||
"""
|
||||
paid: dict[str, Decimal] = {batch_id: m.ZERO for batch_id in cost_totals}
|
||||
pool = m.ZERO
|
||||
|
||||
for payment in doc.bakery_payments:
|
||||
batch_id = payment.batch_id
|
||||
if batch_id and batch_id in paid:
|
||||
room = max(m.ZERO, cost_totals[batch_id] - paid[batch_id])
|
||||
applied = min(room, payment.amount)
|
||||
paid[batch_id] += applied
|
||||
pool += payment.amount - applied
|
||||
else:
|
||||
pool += payment.amount
|
||||
|
||||
for _, batch in _ordered_batches(doc):
|
||||
if pool <= 0:
|
||||
break
|
||||
room = max(m.ZERO, cost_totals.get(batch.id, m.ZERO) - paid.get(batch.id, m.ZERO))
|
||||
applied = min(room, pool)
|
||||
paid[batch.id] = paid.get(batch.id, m.ZERO) + applied
|
||||
pool -= applied
|
||||
|
||||
return paid, pool
|
||||
|
||||
|
||||
# --- Сборка отчёта --------------------------------------------------------
|
||||
|
||||
|
||||
def _status(batch: Batch, remaining_to_bakery: Decimal, days_left: int) -> str:
|
||||
if batch.closed or remaining_to_bakery <= 0:
|
||||
return STATUS_SETTLED
|
||||
if days_left < 0:
|
||||
return STATUS_OVERDUE
|
||||
if days_left <= DUE_SOON_DAYS:
|
||||
return STATUS_DUE_SOON
|
||||
return STATUS_OPEN
|
||||
|
||||
|
||||
def build(doc: Document, today: date | None = None) -> Report:
|
||||
"""Пересчитать всё. Дешевле, чем поддерживать инкрементальное состояние."""
|
||||
today = today or date.today()
|
||||
allocations, shortfalls, remaining = allocate(doc)
|
||||
|
||||
cost_totals = {batch.id: batch.cost_total for batch in doc.batches}
|
||||
paid_to_bakery, unassigned = _spread_bakery_payments(doc, cost_totals)
|
||||
|
||||
per_batch: dict[str, dict[str, Decimal]] = {
|
||||
batch.id: {"qty_sold": m.ZERO, "revenue": m.ZERO, "cash": m.ZERO, "consumed": m.ZERO}
|
||||
for batch in doc.batches
|
||||
}
|
||||
for a in allocations:
|
||||
bucket = per_batch.get(a.batch_id)
|
||||
if bucket is None:
|
||||
continue
|
||||
bucket["qty_sold"] += a.qty
|
||||
if a.kind in CONSUMPTION_KINDS:
|
||||
bucket["consumed"] += a.cost
|
||||
else:
|
||||
bucket["revenue"] += a.revenue
|
||||
bucket["cash"] += a.cash
|
||||
|
||||
batch_reports: list[BatchReport] = []
|
||||
for _, batch in _ordered_batches(doc):
|
||||
bucket = per_batch[batch.id]
|
||||
cost_total = cost_totals[batch.id]
|
||||
paid = m.money(paid_to_bakery.get(batch.id, m.ZERO))
|
||||
days_left = (batch.due_date - today).days
|
||||
stock = {
|
||||
pid: qty
|
||||
for pid, qty in remaining.get(batch.id, {}).items()
|
||||
if qty > 0
|
||||
}
|
||||
batch_reports.append(
|
||||
BatchReport(
|
||||
batch=batch,
|
||||
cost_total=cost_total,
|
||||
qty_total=batch.qty_total,
|
||||
qty_sold=m.qty(bucket["qty_sold"]),
|
||||
qty_left=m.qty(sum(stock.values(), m.ZERO)),
|
||||
revenue=m.money(bucket["revenue"]),
|
||||
cash_collected=m.money(bucket["cash"]),
|
||||
consumed_cost=m.money(bucket["consumed"]),
|
||||
paid_to_bakery=paid,
|
||||
days_left=days_left,
|
||||
status=_status(batch, max(m.ZERO, cost_total - paid), days_left),
|
||||
stock=stock,
|
||||
)
|
||||
)
|
||||
|
||||
return Report(
|
||||
today=today,
|
||||
batches=batch_reports,
|
||||
allocations=allocations,
|
||||
shortfalls=shortfalls,
|
||||
debtors=_debtors(doc),
|
||||
stock=_stock(doc, batch_reports),
|
||||
summary=_summary(doc, batch_reports, allocations, today),
|
||||
unassigned_bakery_payment=m.money(unassigned),
|
||||
warnings=_warnings(doc, shortfalls, unassigned),
|
||||
)
|
||||
|
||||
|
||||
def _debtors(doc: Document) -> list[DebtorReport]:
|
||||
"""Кто и сколько должен. Продажи без контрагента идут отдельной строкой."""
|
||||
buckets: dict[str | None, DebtorReport] = {}
|
||||
for sale in doc.sales:
|
||||
if sale.debt <= 0:
|
||||
continue
|
||||
key = sale.counterparty_id
|
||||
report = buckets.get(key)
|
||||
if report is None:
|
||||
report = DebtorReport(
|
||||
counterparty_id=key,
|
||||
name=doc.counterparty_name(key) if key else "Без контрагента",
|
||||
debt=m.ZERO,
|
||||
)
|
||||
buckets[key] = report
|
||||
report.debt = m.money(report.debt + sale.debt)
|
||||
report.sales.append(sale)
|
||||
|
||||
return sorted(buckets.values(), key=lambda r: (-r.debt, r.name))
|
||||
|
||||
|
||||
def _stock(doc: Document, batch_reports: list[BatchReport]) -> list[StockRow]:
|
||||
qty_by_product: dict[str, Decimal] = {}
|
||||
cost_by_product: dict[str, Decimal] = {}
|
||||
for report in batch_reports:
|
||||
for product_id, qty in report.stock.items():
|
||||
unit_cost = next(
|
||||
(line.unit_cost for line in report.batch.lines if line.product_id == product_id),
|
||||
m.ZERO,
|
||||
)
|
||||
qty_by_product[product_id] = qty_by_product.get(product_id, m.ZERO) + qty
|
||||
cost_by_product[product_id] = cost_by_product.get(product_id, m.ZERO) + qty * unit_cost
|
||||
|
||||
rows = []
|
||||
for product in doc.products:
|
||||
qty = qty_by_product.get(product.id, m.ZERO)
|
||||
if qty <= 0 and product.archived:
|
||||
continue
|
||||
rows.append(
|
||||
StockRow(
|
||||
product_id=product.id,
|
||||
name=product.name,
|
||||
unit=product.unit,
|
||||
qty=m.qty(qty),
|
||||
cost=m.money(cost_by_product.get(product.id, m.ZERO)),
|
||||
)
|
||||
)
|
||||
return sorted(rows, key=lambda r: r.name)
|
||||
|
||||
|
||||
def _summary(
|
||||
doc: Document,
|
||||
batch_reports: list[BatchReport],
|
||||
allocations: list[Allocation],
|
||||
today: date,
|
||||
) -> Summary:
|
||||
revenue = m.ZERO
|
||||
cash = m.ZERO
|
||||
margin = m.ZERO
|
||||
consumed = m.ZERO
|
||||
for a in allocations:
|
||||
if a.kind in CONSUMPTION_KINDS:
|
||||
consumed += a.cost
|
||||
else:
|
||||
revenue += a.revenue
|
||||
cash += a.cash
|
||||
margin += a.revenue - a.cost
|
||||
|
||||
open_reports = [r for r in batch_reports if r.status != STATUS_SETTLED]
|
||||
next_due = min(open_reports, key=lambda r: r.batch.due_date, default=None)
|
||||
|
||||
return Summary(
|
||||
owed_to_bakery=m.money(sum((r.remaining_to_bakery for r in batch_reports), m.ZERO)),
|
||||
receivable=m.money(sum((s.debt for s in doc.sales), m.ZERO)),
|
||||
revenue=m.money(revenue),
|
||||
cash_collected=m.money(cash),
|
||||
gross_margin=m.money(margin),
|
||||
consumed_cost=m.money(consumed),
|
||||
stock_qty=m.qty(sum((r.qty_left for r in batch_reports), m.ZERO)),
|
||||
stock_cost=m.money(
|
||||
sum(
|
||||
(
|
||||
qty * line.unit_cost
|
||||
for r in batch_reports
|
||||
for pid, qty in r.stock.items()
|
||||
for line in r.batch.lines
|
||||
if line.product_id == pid
|
||||
),
|
||||
m.ZERO,
|
||||
)
|
||||
),
|
||||
overdue_count=sum(1 for r in batch_reports if r.status == STATUS_OVERDUE),
|
||||
next_due=next_due,
|
||||
)
|
||||
|
||||
|
||||
def _warnings(doc: Document, shortfalls: list[Shortfall], unassigned: Decimal) -> list[str]:
|
||||
"""То, что стоит показать пользователю, но что не должно ломать расчёт."""
|
||||
warnings: list[str] = []
|
||||
|
||||
by_product: dict[str, Decimal] = {}
|
||||
for s in shortfalls:
|
||||
by_product[s.product_id] = by_product.get(s.product_id, m.ZERO) + s.qty
|
||||
for product_id, qty in by_product.items():
|
||||
warnings.append(
|
||||
f"«{doc.product_name(product_id)}»: продано на {m.fmt_qty(qty)} больше, "
|
||||
"чем закуплено — проверь закупки."
|
||||
)
|
||||
|
||||
if unassigned > 0:
|
||||
warnings.append(
|
||||
f"Пекарне переплачено {m.fmt_money(unassigned, doc.settings.currency)} — "
|
||||
"это аванс под будущие партии."
|
||||
)
|
||||
|
||||
return warnings
|
||||
33
app/main.py
Normal file
33
app/main.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""Точка входа."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from PySide6.QtWidgets import QApplication, QDialog
|
||||
|
||||
from . import paths
|
||||
from .ui import theme
|
||||
from .ui.login import LoginDialog
|
||||
from .ui.main_window import MainWindow
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Булочная")
|
||||
app.setOrganizationName("food-market")
|
||||
theme.apply(app)
|
||||
|
||||
paths.data_dir().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
login = LoginDialog(paths.vault_path())
|
||||
if login.exec() != QDialog.Accepted or login.vault is None:
|
||||
return 0
|
||||
|
||||
window = MainWindow(login.vault)
|
||||
window.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
574
app/models.py
Normal file
574
app/models.py
Normal file
@ -0,0 +1,574 @@
|
||||
"""Структура документа базы и сериализация в/из JSON.
|
||||
|
||||
Вся база — один словарь, который целиком помещается в память. Датаклассы ниже
|
||||
описывают его форму; на диск и обратно всё ходит через to_dict/from_dict,
|
||||
чтобы Decimal и date не разъезжались с их JSON-представлением.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from . import money as m
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
# --- Типы движения товара -------------------------------------------------
|
||||
#
|
||||
# Тип определяет и подстановку цены в форме, и то, как продажа попадает
|
||||
# в отчёты: приносит ли она деньги или это чистое выбытие себестоимости.
|
||||
|
||||
KIND_RETAIL = "retail"
|
||||
KIND_FRIEND = "friend"
|
||||
KIND_SELF = "self"
|
||||
KIND_GIFT = "gift"
|
||||
KIND_WRITEOFF = "writeoff"
|
||||
|
||||
SALE_KINDS = (KIND_RETAIL, KIND_FRIEND, KIND_SELF, KIND_GIFT, KIND_WRITEOFF)
|
||||
|
||||
SALE_KIND_LABELS = {
|
||||
KIND_RETAIL: "Продажа",
|
||||
KIND_FRIEND: "Другу по себестоимости",
|
||||
KIND_SELF: "Съел сам",
|
||||
KIND_GIFT: "Подарил",
|
||||
KIND_WRITEOFF: "Списание",
|
||||
}
|
||||
|
||||
# Приносят выручку. Остальные типы — выбытие без денег: себестоимость всё равно
|
||||
# придётся вернуть пекарне, поэтому она гасится из маржи.
|
||||
REVENUE_KINDS = frozenset({KIND_RETAIL, KIND_FRIEND})
|
||||
CONSUMPTION_KINDS = frozenset({KIND_SELF, KIND_GIFT, KIND_WRITEOFF})
|
||||
|
||||
|
||||
def new_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def parse_date(value: Any) -> date:
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
return date.fromisoformat(str(value))
|
||||
|
||||
|
||||
def dump_date(value: date) -> str:
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
# --- Номенклатура ---------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class PricePoint:
|
||||
"""Снимок цен товара на момент изменения.
|
||||
|
||||
Пишется при каждой правке цены, поэтому история цен видна прямо в карточке
|
||||
товара, не полагаясь на историю git.
|
||||
"""
|
||||
|
||||
date: date
|
||||
cost_price: Decimal
|
||||
retail_price: Decimal
|
||||
note: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "PricePoint":
|
||||
return cls(
|
||||
date=parse_date(d["date"]),
|
||||
cost_price=m.money(d.get("cost_price")),
|
||||
retail_price=m.money(d.get("retail_price")),
|
||||
note=d.get("note", ""),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"date": dump_date(self.date),
|
||||
"cost_price": m.dumps(self.cost_price),
|
||||
"retail_price": m.dumps(self.retail_price),
|
||||
"note": self.note,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Product:
|
||||
id: str
|
||||
name: str
|
||||
unit: str = "шт"
|
||||
cost_price: Decimal = m.ZERO
|
||||
retail_price: Decimal = m.ZERO
|
||||
archived: bool = False
|
||||
note: str = ""
|
||||
price_history: list[PricePoint] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Product":
|
||||
return cls(
|
||||
id=d["id"],
|
||||
name=d.get("name", ""),
|
||||
unit=d.get("unit", "шт"),
|
||||
cost_price=m.money(d.get("cost_price")),
|
||||
retail_price=m.money(d.get("retail_price")),
|
||||
archived=bool(d.get("archived", False)),
|
||||
note=d.get("note", ""),
|
||||
price_history=[PricePoint.from_dict(p) for p in d.get("price_history", [])],
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"unit": self.unit,
|
||||
"cost_price": m.dumps(self.cost_price),
|
||||
"retail_price": m.dumps(self.retail_price),
|
||||
"archived": self.archived,
|
||||
"note": self.note,
|
||||
"price_history": [p.to_dict() for p in self.price_history],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Counterparty:
|
||||
id: str
|
||||
name: str
|
||||
kind: str = "friend"
|
||||
note: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Counterparty":
|
||||
return cls(
|
||||
id=d["id"],
|
||||
name=d.get("name", ""),
|
||||
kind=d.get("kind", "friend"),
|
||||
note=d.get("note", ""),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"id": self.id, "name": self.name, "kind": self.kind, "note": self.note}
|
||||
|
||||
|
||||
# --- Закупки --------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchLine:
|
||||
product_id: str
|
||||
qty: Decimal
|
||||
unit_cost: Decimal
|
||||
|
||||
@property
|
||||
def total(self) -> Decimal:
|
||||
return m.money(self.qty * self.unit_cost)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "BatchLine":
|
||||
return cls(
|
||||
product_id=d["product_id"],
|
||||
qty=m.qty(d.get("qty")),
|
||||
unit_cost=m.money(d.get("unit_cost")),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"product_id": self.product_id,
|
||||
"qty": m.dumps(self.qty),
|
||||
"unit_cost": m.dumps(self.unit_cost),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Batch:
|
||||
"""Партия булок, взятая в пекарне в долг.
|
||||
|
||||
unit_cost в строках — слепок цены на момент закупки: если цена у поставщика
|
||||
потом изменится, долг по уже взятой партии не должен поехать.
|
||||
"""
|
||||
|
||||
id: str
|
||||
number: int
|
||||
date: date
|
||||
due_date: date
|
||||
lines: list[BatchLine] = field(default_factory=list)
|
||||
closed: bool = False
|
||||
note: str = ""
|
||||
|
||||
@property
|
||||
def cost_total(self) -> Decimal:
|
||||
"""Сколько надо вернуть пекарне за эту партию."""
|
||||
return m.money(sum((line.total for line in self.lines), m.ZERO))
|
||||
|
||||
@property
|
||||
def qty_total(self) -> Decimal:
|
||||
return m.qty(sum((line.qty for line in self.lines), m.ZERO))
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Batch":
|
||||
return cls(
|
||||
id=d["id"],
|
||||
number=int(d.get("number", 0)),
|
||||
date=parse_date(d["date"]),
|
||||
due_date=parse_date(d["due_date"]),
|
||||
lines=[BatchLine.from_dict(x) for x in d.get("lines", [])],
|
||||
closed=bool(d.get("closed", False)),
|
||||
note=d.get("note", ""),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"number": self.number,
|
||||
"date": dump_date(self.date),
|
||||
"due_date": dump_date(self.due_date),
|
||||
"lines": [x.to_dict() for x in self.lines],
|
||||
"closed": self.closed,
|
||||
"note": self.note,
|
||||
}
|
||||
|
||||
|
||||
# --- Продажи и выбытие ----------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class SaleLine:
|
||||
product_id: str
|
||||
qty: Decimal
|
||||
unit_price: Decimal
|
||||
|
||||
@property
|
||||
def total(self) -> Decimal:
|
||||
return m.money(self.qty * self.unit_price)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "SaleLine":
|
||||
return cls(
|
||||
product_id=d["product_id"],
|
||||
qty=m.qty(d.get("qty")),
|
||||
unit_price=m.money(d.get("unit_price")),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"product_id": self.product_id,
|
||||
"qty": m.dumps(self.qty),
|
||||
"unit_price": m.dumps(self.unit_price),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Payment:
|
||||
"""Приход денег по конкретной продаже (может быть частичным)."""
|
||||
|
||||
date: date
|
||||
amount: Decimal
|
||||
note: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Payment":
|
||||
return cls(
|
||||
date=parse_date(d["date"]),
|
||||
amount=m.money(d.get("amount")),
|
||||
note=d.get("note", ""),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"date": dump_date(self.date),
|
||||
"amount": m.dumps(self.amount),
|
||||
"note": self.note,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sale:
|
||||
"""Любое выбытие товара: продажа, отдача другу, съел сам, списание."""
|
||||
|
||||
id: str
|
||||
date: date
|
||||
kind: str = KIND_RETAIL
|
||||
counterparty_id: str | None = None
|
||||
lines: list[SaleLine] = field(default_factory=list)
|
||||
payments: list[Payment] = field(default_factory=list)
|
||||
note: str = ""
|
||||
|
||||
@property
|
||||
def total(self) -> Decimal:
|
||||
return m.money(sum((line.total for line in self.lines), m.ZERO))
|
||||
|
||||
@property
|
||||
def paid(self) -> Decimal:
|
||||
return m.money(sum((p.amount for p in self.payments), m.ZERO))
|
||||
|
||||
@property
|
||||
def debt(self) -> Decimal:
|
||||
"""Сколько ещё должны по этой продаже (не бывает отрицательным)."""
|
||||
return max(m.ZERO, m.money(self.total - self.paid))
|
||||
|
||||
@property
|
||||
def is_consumption(self) -> bool:
|
||||
return self.kind in CONSUMPTION_KINDS
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Sale":
|
||||
return cls(
|
||||
id=d["id"],
|
||||
date=parse_date(d["date"]),
|
||||
kind=d.get("kind", KIND_RETAIL),
|
||||
counterparty_id=d.get("counterparty_id") or None,
|
||||
lines=[SaleLine.from_dict(x) for x in d.get("lines", [])],
|
||||
payments=[Payment.from_dict(x) for x in d.get("payments", [])],
|
||||
note=d.get("note", ""),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"date": dump_date(self.date),
|
||||
"kind": self.kind,
|
||||
"counterparty_id": self.counterparty_id,
|
||||
"lines": [x.to_dict() for x in self.lines],
|
||||
"payments": [x.to_dict() for x in self.payments],
|
||||
"note": self.note,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BakeryPayment:
|
||||
"""Возврат денег пекарне.
|
||||
|
||||
batch_id=None означает «просто отдал денег» — такой платёж гасит долг
|
||||
по старейшим непокрытым партиям (см. ledger).
|
||||
"""
|
||||
|
||||
id: str
|
||||
date: date
|
||||
amount: Decimal
|
||||
batch_id: str | None = None
|
||||
note: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "BakeryPayment":
|
||||
return cls(
|
||||
id=d["id"],
|
||||
date=parse_date(d["date"]),
|
||||
amount=m.money(d.get("amount")),
|
||||
batch_id=d.get("batch_id") or None,
|
||||
note=d.get("note", ""),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"date": dump_date(self.date),
|
||||
"amount": m.dumps(self.amount),
|
||||
"batch_id": self.batch_id,
|
||||
"note": self.note,
|
||||
}
|
||||
|
||||
|
||||
# --- Журнал ---------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Change:
|
||||
field: str
|
||||
old: str
|
||||
new: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Change":
|
||||
return cls(field=d.get("field", ""), old=d.get("old", ""), new=d.get("new", ""))
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"field": self.field, "old": self.old, "new": self.new}
|
||||
|
||||
|
||||
@dataclass
|
||||
class JournalEntry:
|
||||
"""Запись аудит-лога. Пишется только через journal.apply, только append."""
|
||||
|
||||
id: str
|
||||
ts: str
|
||||
host: str
|
||||
action: str
|
||||
entity: str
|
||||
entity_id: str
|
||||
label: str
|
||||
changes: list[Change] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "JournalEntry":
|
||||
return cls(
|
||||
id=d["id"],
|
||||
ts=d.get("ts", ""),
|
||||
host=d.get("host", ""),
|
||||
action=d.get("action", ""),
|
||||
entity=d.get("entity", ""),
|
||||
entity_id=d.get("entity_id", ""),
|
||||
label=d.get("label", ""),
|
||||
changes=[Change.from_dict(c) for c in d.get("changes", [])],
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"ts": self.ts,
|
||||
"host": self.host,
|
||||
"action": self.action,
|
||||
"entity": self.entity,
|
||||
"entity_id": self.entity_id,
|
||||
"label": self.label,
|
||||
"changes": [c.to_dict() for c in self.changes],
|
||||
}
|
||||
|
||||
|
||||
# --- Настройки ------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitSettings:
|
||||
"""Токен лежит здесь, то есть внутри зашифрованной базы.
|
||||
|
||||
В .git/config он не попадает — иначе лежал бы открытым рядом с шифром.
|
||||
"""
|
||||
|
||||
remote_url: str = ""
|
||||
token: str = ""
|
||||
branch: str = "main"
|
||||
author_name: str = "food-market"
|
||||
author_email: str = "food-market@localhost"
|
||||
auto_push_minutes: int = 60
|
||||
enabled: bool = True
|
||||
daily_squash: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "GitSettings":
|
||||
base = cls()
|
||||
return cls(
|
||||
remote_url=d.get("remote_url", base.remote_url),
|
||||
token=d.get("token", base.token),
|
||||
branch=d.get("branch", base.branch) or base.branch,
|
||||
author_name=d.get("author_name") or base.author_name,
|
||||
author_email=d.get("author_email") or base.author_email,
|
||||
auto_push_minutes=int(d.get("auto_push_minutes", base.auto_push_minutes)),
|
||||
enabled=bool(d.get("enabled", base.enabled)),
|
||||
daily_squash=bool(d.get("daily_squash", base.daily_squash)),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"remote_url": self.remote_url,
|
||||
"token": self.token,
|
||||
"branch": self.branch,
|
||||
"author_name": self.author_name,
|
||||
"author_email": self.author_email,
|
||||
"auto_push_minutes": self.auto_push_minutes,
|
||||
"enabled": self.enabled,
|
||||
"daily_squash": self.daily_squash,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
currency: str = "₽"
|
||||
default_period_days: int = 14
|
||||
git: GitSettings = field(default_factory=GitSettings)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Settings":
|
||||
base = cls()
|
||||
return cls(
|
||||
currency=d.get("currency", base.currency),
|
||||
default_period_days=int(
|
||||
d.get("default_period_days", base.default_period_days)
|
||||
),
|
||||
git=GitSettings.from_dict(d.get("git", {})),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"currency": self.currency,
|
||||
"default_period_days": self.default_period_days,
|
||||
"git": self.git.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
# --- Документ целиком -----------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Document:
|
||||
schema_version: int = SCHEMA_VERSION
|
||||
settings: Settings = field(default_factory=Settings)
|
||||
products: list[Product] = field(default_factory=list)
|
||||
counterparties: list[Counterparty] = field(default_factory=list)
|
||||
batches: list[Batch] = field(default_factory=list)
|
||||
sales: list[Sale] = field(default_factory=list)
|
||||
bakery_payments: list[BakeryPayment] = field(default_factory=list)
|
||||
journal: list[JournalEntry] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Document":
|
||||
return cls(
|
||||
schema_version=int(d.get("schema_version", SCHEMA_VERSION)),
|
||||
settings=Settings.from_dict(d.get("settings", {})),
|
||||
products=[Product.from_dict(x) for x in d.get("products", [])],
|
||||
counterparties=[
|
||||
Counterparty.from_dict(x) for x in d.get("counterparties", [])
|
||||
],
|
||||
batches=[Batch.from_dict(x) for x in d.get("batches", [])],
|
||||
sales=[Sale.from_dict(x) for x in d.get("sales", [])],
|
||||
bakery_payments=[
|
||||
BakeryPayment.from_dict(x) for x in d.get("bakery_payments", [])
|
||||
],
|
||||
journal=[JournalEntry.from_dict(x) for x in d.get("journal", [])],
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"settings": self.settings.to_dict(),
|
||||
"products": [x.to_dict() for x in self.products],
|
||||
"counterparties": [x.to_dict() for x in self.counterparties],
|
||||
"batches": [x.to_dict() for x in self.batches],
|
||||
"sales": [x.to_dict() for x in self.sales],
|
||||
"bakery_payments": [x.to_dict() for x in self.bakery_payments],
|
||||
"journal": [x.to_dict() for x in self.journal],
|
||||
}
|
||||
|
||||
# --- поиск по id ---
|
||||
|
||||
def product(self, product_id: str) -> Product | None:
|
||||
return _find(self.products, product_id)
|
||||
|
||||
def counterparty(self, cp_id: str) -> Counterparty | None:
|
||||
return _find(self.counterparties, cp_id)
|
||||
|
||||
def batch(self, batch_id: str) -> Batch | None:
|
||||
return _find(self.batches, batch_id)
|
||||
|
||||
def sale(self, sale_id: str) -> Sale | None:
|
||||
return _find(self.sales, sale_id)
|
||||
|
||||
def product_name(self, product_id: str) -> str:
|
||||
p = self.product(product_id)
|
||||
return p.name if p else "(удалённый товар)"
|
||||
|
||||
def counterparty_name(self, cp_id: str | None) -> str:
|
||||
if not cp_id:
|
||||
return ""
|
||||
c = self.counterparty(cp_id)
|
||||
return c.name if c else "(удалённый контрагент)"
|
||||
|
||||
def next_batch_number(self) -> int:
|
||||
return max((b.number for b in self.batches), default=0) + 1
|
||||
|
||||
|
||||
def _find(items: list, item_id: str):
|
||||
for item in items:
|
||||
if item.id == item_id:
|
||||
return item
|
||||
return None
|
||||
68
app/money.py
Normal file
68
app/money.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""Деньги и количества.
|
||||
|
||||
Всё считаем в Decimal, а не в float: 0.1 + 0.2 в float даёт 0.30000000000004,
|
||||
и такие копейки накапливаются в отчётах по партиям. В JSON кладём строкой,
|
||||
чтобы точность не терялась при сохранении.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
|
||||
CENTS = Decimal("0.01")
|
||||
QTY_STEP = Decimal("0.001")
|
||||
ZERO = Decimal("0")
|
||||
|
||||
|
||||
def money(value) -> Decimal:
|
||||
"""Привести что угодно к сумме денег, округлив до копеек."""
|
||||
return _to_decimal(value).quantize(CENTS, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def qty(value) -> Decimal:
|
||||
"""Привести что угодно к количеству.
|
||||
|
||||
До тысячных — булки штучные, но так же работают вес и объём.
|
||||
"""
|
||||
return _to_decimal(value).quantize(QTY_STEP, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _to_decimal(value) -> Decimal:
|
||||
if isinstance(value, Decimal):
|
||||
return value
|
||||
if value is None or value == "":
|
||||
return ZERO
|
||||
if isinstance(value, float):
|
||||
# Через str, иначе Decimal(0.1) даст 0.1000000000000000055511151231257827.
|
||||
value = repr(value)
|
||||
try:
|
||||
return Decimal(str(value).replace(",", ".").replace(" ", "").replace(" ", ""))
|
||||
except (InvalidOperation, ValueError) as exc:
|
||||
raise ValueError(f"Не похоже на число: {value!r}") from exc
|
||||
|
||||
|
||||
def dumps(value: Decimal) -> str:
|
||||
"""Сериализовать сумму для JSON."""
|
||||
return format(value, "f")
|
||||
|
||||
|
||||
def fmt_money(value, currency: str = "₽") -> str:
|
||||
"""Отформатировать сумму для показа: 12 345,67 ₽."""
|
||||
value = money(value)
|
||||
sign = "-" if value < 0 else ""
|
||||
whole, _, frac = format(abs(value), "f").partition(".")
|
||||
groups = []
|
||||
while len(whole) > 3:
|
||||
groups.insert(0, whole[-3:])
|
||||
whole = whole[:-3]
|
||||
groups.insert(0, whole)
|
||||
text = f"{sign}{' '.join(groups)},{frac}"
|
||||
# Разделители — обычные пробелы, а не неразрывные: то, что видно на экране,
|
||||
# должно находиться поиском по журналу ровно так, как пользователь это набрал.
|
||||
return f"{text} {currency}" if currency else text
|
||||
|
||||
|
||||
def fmt_qty(value) -> str:
|
||||
"""Отформатировать количество, пряча незначащие нули: 5, 2,5."""
|
||||
text = format(qty(value), "f").rstrip("0").rstrip(".")
|
||||
return (text or "0").replace(".", ",")
|
||||
42
app/paths.py
Normal file
42
app/paths.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Где лежат данные.
|
||||
|
||||
Собранный exe кладётся в корень клона репозитория, а база — в data/ рядом
|
||||
с ним. При запуске из исходников корнем считается сама папка проекта, поэтому
|
||||
разработка и собранное приложение работают с одним и тем же файлом.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VAULT_NAME = "vault.fmdb"
|
||||
DATA_DIR_NAME = "data"
|
||||
|
||||
|
||||
def app_root() -> Path:
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys.executable).resolve().parent
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
return app_root() / DATA_DIR_NAME
|
||||
|
||||
|
||||
def vault_path() -> Path:
|
||||
return data_dir() / VAULT_NAME
|
||||
|
||||
|
||||
def vault_rel_posix() -> str:
|
||||
"""Путь базы для git — всегда со слешами и относительно корня репозитория."""
|
||||
return f"{DATA_DIR_NAME}/{VAULT_NAME}"
|
||||
|
||||
|
||||
def backup_path() -> Path:
|
||||
return data_dir() / f"{VAULT_NAME}.bak"
|
||||
|
||||
|
||||
def remote_copy_path() -> Path:
|
||||
"""Куда складывается удалённая версия базы при расхождении веток."""
|
||||
return data_dir() / "vault.remote.fmdb"
|
||||
194
app/storage.py
Normal file
194
app/storage.py
Normal file
@ -0,0 +1,194 @@
|
||||
"""Загрузка, сохранение и версионирование схемы базы.
|
||||
|
||||
Ключевая деталь — определение «изменилось ли что-нибудь». Шифрование
|
||||
недетерминировано, поэтому сравнивать байты файла бессмысленно: одни и те же
|
||||
данные каждый раз дают разный шифртекст. Вместо этого храним sha256 от
|
||||
канонического (отсортированного, без пробелов) открытого JSON. Без этого git
|
||||
получал бы новый коммит на каждое срабатывание автосохранения.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from . import crypto
|
||||
from .models import SCHEMA_VERSION, Document
|
||||
|
||||
|
||||
def canonical_bytes(raw: dict) -> bytes:
|
||||
"""Байты, по которым считается хеш и которые уходят в шифрование.
|
||||
|
||||
sort_keys обязателен: без него порядок ключей мог бы плавать и создавать
|
||||
«изменения» на ровном месте.
|
||||
"""
|
||||
return json.dumps(
|
||||
raw, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def content_hash(raw: dict) -> str:
|
||||
return hashlib.sha256(canonical_bytes(raw)).hexdigest()
|
||||
|
||||
|
||||
# --- Миграции схемы -------------------------------------------------------
|
||||
#
|
||||
# Каждая функция получает сырой словарь версии N и возвращает версию N+1.
|
||||
# Нужны потому, что база уже лежит в git: старый файл должен открываться
|
||||
# новой программой без ручных плясок.
|
||||
|
||||
def _migrate_0_to_1(raw: dict) -> dict:
|
||||
raw["schema_version"] = 1
|
||||
raw.setdefault("journal", [])
|
||||
return raw
|
||||
|
||||
|
||||
_MIGRATIONS = {0: _migrate_0_to_1}
|
||||
|
||||
|
||||
def migrate(raw: dict) -> dict:
|
||||
version = int(raw.get("schema_version", 0))
|
||||
if version > SCHEMA_VERSION:
|
||||
raise crypto.UnsupportedFormat(
|
||||
f"База сохранена более новой версией программы (схема {version}). "
|
||||
"Обнови приложение, иначе данные можно потерять."
|
||||
)
|
||||
while version < SCHEMA_VERSION:
|
||||
migration = _MIGRATIONS.get(version)
|
||||
if migration is None:
|
||||
raise crypto.VaultError(f"Нет миграции со схемы {version}.")
|
||||
raw = migration(raw)
|
||||
version = int(raw["schema_version"])
|
||||
return raw
|
||||
|
||||
|
||||
class Vault:
|
||||
"""Открытая база: документ в памяти плюс всё, что нужно для записи."""
|
||||
|
||||
def __init__(self, path: Path, password: str, doc: Document, saved_hash: str = ""):
|
||||
self.path = Path(path)
|
||||
self._password = password
|
||||
self.doc = doc
|
||||
self._saved_hash = saved_hash
|
||||
# Растёт при каждой мутации. UI смотрит на него, чтобы дёшево понять,
|
||||
# что пора перерисоваться и запустить отложенное автосохранение.
|
||||
self.revision = 0
|
||||
|
||||
# --- открытие и создание ---
|
||||
|
||||
@classmethod
|
||||
def create(cls, path: Path, password: str) -> "Vault":
|
||||
path = Path(path)
|
||||
if path.exists():
|
||||
raise crypto.VaultError(f"Файл {path} уже существует.")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
vault = cls(path, password, Document())
|
||||
vault.save(force=True)
|
||||
return vault
|
||||
|
||||
@classmethod
|
||||
def open(cls, path: Path, password: str) -> "Vault":
|
||||
path = Path(path)
|
||||
blob = path.read_bytes()
|
||||
plaintext = crypto.decrypt(blob, password)
|
||||
try:
|
||||
raw = json.loads(plaintext.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise crypto.VaultError(
|
||||
"База расшифровалась, но её содержимое повреждено."
|
||||
) from exc
|
||||
|
||||
raw = migrate(raw)
|
||||
doc = Document.from_dict(raw)
|
||||
# Хеш считаем от того, что получилось после разбора и миграции, а не от
|
||||
# прочитанного сырья: иначе миграция или нормализация Decimal сразу же
|
||||
# выглядели бы как несохранённое изменение.
|
||||
return cls(path, password, doc, saved_hash=content_hash(doc.to_dict()))
|
||||
|
||||
# --- состояние ---
|
||||
|
||||
def touch(self) -> None:
|
||||
"""Отметить, что документ изменили. Зовётся из journal.apply."""
|
||||
self.revision += 1
|
||||
|
||||
@property
|
||||
def is_dirty(self) -> bool:
|
||||
return content_hash(self.doc.to_dict()) != self._saved_hash
|
||||
|
||||
# --- запись ---
|
||||
|
||||
def save(self, force: bool = False) -> bool:
|
||||
"""Записать базу на диск. Возвращает True, только если реально писали.
|
||||
|
||||
Именно этот возврат решает, будет ли git-коммит: если открытые данные
|
||||
не изменились, файл не трогаем и коммита не будет.
|
||||
"""
|
||||
raw = self.doc.to_dict()
|
||||
digest = content_hash(raw)
|
||||
if not force and digest == self._saved_hash:
|
||||
return False
|
||||
|
||||
blob = crypto.encrypt(canonical_bytes(raw), self._password)
|
||||
_atomic_write(self.path, blob)
|
||||
self._saved_hash = digest
|
||||
return True
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Перечитать файл тем же паролем.
|
||||
|
||||
Нужно после того, как файл на диске подменил git: подтянули изменения
|
||||
с другой машины или откатились на серверную версию.
|
||||
"""
|
||||
fresh = Vault.open(self.path, self._password)
|
||||
self.doc = fresh.doc
|
||||
self._saved_hash = fresh._saved_hash
|
||||
self.revision += 1
|
||||
|
||||
def change_password(self, new_password: str) -> None:
|
||||
self._password = new_password
|
||||
self.save(force=True)
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
return password == self._password
|
||||
|
||||
def export_plain_json(self, path: Path) -> Path:
|
||||
"""Выгрузить базу в открытый JSON.
|
||||
|
||||
Единственная страховка от забытого пароля, поэтому она есть в UI явной
|
||||
кнопкой. Файл содержит и git-токен — его место не в общей папке.
|
||||
"""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(self.doc.to_dict(), ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _atomic_write(path: Path, blob: bytes) -> None:
|
||||
"""Записать файл так, чтобы обрыв питания не оставил его наполовину.
|
||||
|
||||
Пишем во временный файл, сбрасываем на диск, только потом подменяем
|
||||
боевой. Предыдущая версия уезжает в .bak — дешёвая страховка от того, что
|
||||
свежий файл окажется битым.
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
|
||||
with open(tmp, "wb") as fh:
|
||||
fh.write(blob)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
|
||||
if path.exists():
|
||||
try:
|
||||
shutil.copy2(path, path.with_suffix(path.suffix + ".bak"))
|
||||
except OSError:
|
||||
# Бэкап — приятный бонус, но не повод не сохранить данные.
|
||||
pass
|
||||
|
||||
os.replace(tmp, path)
|
||||
1
app/ui/__init__.py
Normal file
1
app/ui/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Интерфейс на PySide6."""
|
||||
159
app/ui/login.py
Normal file
159
app/ui/login.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""Разблокировка существующей базы или создание новой."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from .. import crypto, journal
|
||||
from ..storage import Vault
|
||||
from . import widgets as w
|
||||
|
||||
MIN_PASSWORD_LENGTH = 4
|
||||
|
||||
|
||||
class LoginDialog(QDialog):
|
||||
"""Диалог входа. После accept() в self.vault лежит открытая база."""
|
||||
|
||||
def __init__(self, vault_path: Path, parent=None):
|
||||
super().__init__(parent)
|
||||
self.vault_path = Path(vault_path)
|
||||
self.is_new = not self.vault_path.exists()
|
||||
self.vault: Vault | None = None
|
||||
|
||||
self.setWindowTitle("Булочная")
|
||||
self.setMinimumWidth(440)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(24, 20, 24, 20)
|
||||
layout.setSpacing(12)
|
||||
|
||||
layout.addWidget(w.heading("Новая база" if self.is_new else "Булочная"))
|
||||
layout.addWidget(
|
||||
w.label(
|
||||
"Придумай пароль. Им шифруется весь учёт — восстановить его "
|
||||
"невозможно, так что запиши в надёжном месте."
|
||||
if self.is_new
|
||||
else str(self.vault_path),
|
||||
"dim",
|
||||
)
|
||||
)
|
||||
layout.itemAt(layout.count() - 1).widget().setWordWrap(True)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setSpacing(8)
|
||||
|
||||
self.password = QLineEdit()
|
||||
self.password.setEchoMode(QLineEdit.Password)
|
||||
self.password.returnPressed.connect(self.accept)
|
||||
form.addRow("Пароль", self.password)
|
||||
|
||||
if self.is_new:
|
||||
self.confirm = QLineEdit()
|
||||
self.confirm.setEchoMode(QLineEdit.Password)
|
||||
self.confirm.returnPressed.connect(self.accept)
|
||||
form.addRow("Ещё раз", self.confirm)
|
||||
|
||||
self.remote = QLineEdit()
|
||||
self.remote.setPlaceholderText("https://gt.ser.gay/kizya/food-market.git")
|
||||
form.addRow("Репозиторий", self.remote)
|
||||
|
||||
self.token = QLineEdit()
|
||||
self.token.setEchoMode(QLineEdit.Password)
|
||||
self.token.setPlaceholderText("необязательно, можно задать позже")
|
||||
form.addRow("Токен GitLab", self.token)
|
||||
|
||||
self.demo = QCheckBox("Завести три булки для примера")
|
||||
self.demo.setChecked(True)
|
||||
form.addRow("", self.demo)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
show = QCheckBox("Показать пароль")
|
||||
show.toggled.connect(self._toggle_echo)
|
||||
layout.addWidget(show)
|
||||
|
||||
self.error = QLabel("")
|
||||
self.error.setWordWrap(True)
|
||||
self.error.setStyleSheet("color: #e0605e;")
|
||||
layout.addWidget(self.error)
|
||||
|
||||
layout.addWidget(
|
||||
w.row(
|
||||
None,
|
||||
w.button("Отмена", on_click=self.reject),
|
||||
w.button("Создать" if self.is_new else "Открыть", "primary", self.accept),
|
||||
)
|
||||
)
|
||||
self.password.setFocus()
|
||||
|
||||
def _toggle_echo(self, visible: bool) -> None:
|
||||
mode = QLineEdit.Normal if visible else QLineEdit.Password
|
||||
self.password.setEchoMode(mode)
|
||||
if self.is_new:
|
||||
self.confirm.setEchoMode(mode)
|
||||
|
||||
def accept(self) -> None:
|
||||
self.error.setText("")
|
||||
password = self.password.text()
|
||||
|
||||
if self.is_new:
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
self.error.setText(f"Пароль короче {MIN_PASSWORD_LENGTH} символов.")
|
||||
return
|
||||
if password != self.confirm.text():
|
||||
self.error.setText("Пароли не совпадают.")
|
||||
return
|
||||
self._create(password)
|
||||
else:
|
||||
self._open(password)
|
||||
|
||||
def _create(self, password: str) -> None:
|
||||
try:
|
||||
vault = Vault.create(self.vault_path, password)
|
||||
except OSError as exc:
|
||||
self.error.setText(f"Не удалось создать файл: {exc}")
|
||||
return
|
||||
|
||||
git_settings = {}
|
||||
if self.remote.text().strip():
|
||||
git_settings["remote_url"] = self.remote.text().strip()
|
||||
if self.token.text():
|
||||
git_settings["token"] = self.token.text()
|
||||
if git_settings:
|
||||
journal.update_settings(vault, git=git_settings)
|
||||
|
||||
if self.demo.isChecked():
|
||||
journal.create_product(vault, "Булка с повидлом", cost_price=30, retail_price=50)
|
||||
journal.create_product(vault, "Булка с маком", cost_price=20, retail_price=35)
|
||||
journal.create_product(vault, "Булка с корицей", cost_price=25, retail_price=40)
|
||||
|
||||
vault.save()
|
||||
self.vault = vault
|
||||
super().accept()
|
||||
|
||||
def _open(self, password: str) -> None:
|
||||
try:
|
||||
self.vault = Vault.open(self.vault_path, password)
|
||||
except crypto.WrongPassword:
|
||||
self.error.setText("Неверный пароль.")
|
||||
self.password.selectAll()
|
||||
self.password.setFocus()
|
||||
return
|
||||
except crypto.VaultError as exc:
|
||||
QMessageBox.critical(self, "Не удалось открыть базу", str(exc))
|
||||
return
|
||||
except OSError as exc:
|
||||
QMessageBox.critical(self, "Не удалось прочитать файл", str(exc))
|
||||
return
|
||||
super().accept()
|
||||
332
app/ui/main_window.py
Normal file
332
app/ui/main_window.py
Normal file
@ -0,0 +1,332 @@
|
||||
"""Главное окно: боковое меню, экраны, автосохранение и фоновая синхронизация.
|
||||
|
||||
Окно заодно служит контекстом для страниц: они берут отсюда базу, пересчитанный
|
||||
отчёт и валюту, а после любой правки зовут ctx.changed().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QThread, QTimer, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QStackedWidget,
|
||||
QWidget,
|
||||
QHBoxLayout,
|
||||
)
|
||||
|
||||
from .. import gitsync, ledger, paths
|
||||
from ..gitsync import Diverged, GitError, GitSync
|
||||
from ..storage import Vault
|
||||
from . import theme
|
||||
from . import widgets as w
|
||||
from .page_batches import BatchesPage
|
||||
from .page_dashboard import DashboardPage
|
||||
from .page_debts import DebtsPage
|
||||
from .page_journal import JournalPage
|
||||
from .page_products import ProductsPage
|
||||
from .page_sales import SalesPage
|
||||
from .page_settings import SettingsPage
|
||||
|
||||
AUTOSAVE_MS = 2000
|
||||
CLOSE_SYNC_WAIT_MS = 20_000
|
||||
|
||||
|
||||
class SyncTask(QThread):
|
||||
"""Один прогон синхронизации в фоне.
|
||||
|
||||
Git блокирует надолго (сеть, scrypt тут ни при чём), а замерзший интерфейс
|
||||
на каждом часовом тике — худшее, что может сделать фоновая задача.
|
||||
"""
|
||||
|
||||
done = Signal(object, str, bool) # результат, текст ошибки, был ли конфликт
|
||||
|
||||
def __init__(self, sync: GitSync, remote_copy: Path, parent=None):
|
||||
super().__init__(parent)
|
||||
self.sync = sync
|
||||
self.remote_copy = remote_copy
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.done.emit(self.sync.sync(self.remote_copy), "", False)
|
||||
except Diverged as exc:
|
||||
self.done.emit(None, str(exc), True)
|
||||
except GitError as exc:
|
||||
self.done.emit(None, str(exc), False)
|
||||
except Exception as exc: # noqa: BLE001 — фон не должен ронять приложение
|
||||
self.done.emit(None, f"Непредвиденная ошибка синхронизации: {exc}", False)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self, vault: Vault):
|
||||
super().__init__()
|
||||
self.vault = vault
|
||||
self.report = ledger.build(vault.doc)
|
||||
self.conflict_message: str | None = None
|
||||
|
||||
self._sync_task: SyncTask | None = None
|
||||
self._syncing = False
|
||||
self._sync_text = "не синхронизировалось"
|
||||
self._saved_revision = vault.revision
|
||||
|
||||
self.setWindowTitle("Булочная")
|
||||
self.resize(1180, 760)
|
||||
|
||||
self._build_ui()
|
||||
self._build_status_bar()
|
||||
self._start_timers()
|
||||
self.refresh_all()
|
||||
|
||||
# Первый заход в сеть — сразу: если с другой машины что-то прилетело,
|
||||
# лучше узнать об этом до того, как начнёшь вводить данные.
|
||||
QTimer.singleShot(600, lambda: self.sync_now(quiet=True))
|
||||
|
||||
# --- сборка ---
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
central = QWidget()
|
||||
layout = QHBoxLayout(central)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self.sidebar = QListWidget()
|
||||
self.sidebar.setObjectName("sidebar")
|
||||
self.sidebar.setFixedWidth(196)
|
||||
self.sidebar.setFocusPolicy(Qt.NoFocus)
|
||||
layout.addWidget(self.sidebar)
|
||||
|
||||
self.stack = QStackedWidget()
|
||||
layout.addWidget(self.stack, 1)
|
||||
|
||||
self.pages = [
|
||||
("Сводка", DashboardPage(self)),
|
||||
("Закупки", BatchesPage(self)),
|
||||
("Продажи", SalesPage(self)),
|
||||
("Долги", DebtsPage(self)),
|
||||
("Товары", ProductsPage(self)),
|
||||
("Журнал", JournalPage(self)),
|
||||
("Настройки", SettingsPage(self)),
|
||||
]
|
||||
for title, page in self.pages:
|
||||
self.sidebar.addItem(QListWidgetItem(title))
|
||||
self.stack.addWidget(page)
|
||||
|
||||
self.sidebar.currentRowChanged.connect(self._on_page_changed)
|
||||
self.sidebar.setCurrentRow(0)
|
||||
self.setCentralWidget(central)
|
||||
|
||||
def _build_status_bar(self) -> None:
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setStyleSheet(f"color: {theme.TEXT_DIM};")
|
||||
bar = self.statusBar()
|
||||
bar.addWidget(self.status_label, 1)
|
||||
bar.addPermanentWidget(w.button("Синхронизировать", on_click=self.sync_now))
|
||||
|
||||
def _start_timers(self) -> None:
|
||||
self.autosave_timer = QTimer(self)
|
||||
self.autosave_timer.timeout.connect(self._autosave)
|
||||
self.autosave_timer.start(AUTOSAVE_MS)
|
||||
|
||||
self.sync_timer = QTimer(self)
|
||||
self.sync_timer.timeout.connect(lambda: self.sync_now(quiet=True))
|
||||
self.restart_sync_timer()
|
||||
|
||||
def restart_sync_timer(self) -> None:
|
||||
git = self.vault.doc.settings.git
|
||||
self.sync_timer.stop()
|
||||
if git.enabled and git.remote_url:
|
||||
self.sync_timer.start(max(1, git.auto_push_minutes) * 60_000)
|
||||
|
||||
# --- контекст для страниц ---
|
||||
|
||||
@property
|
||||
def currency(self) -> str:
|
||||
return self.vault.doc.settings.currency
|
||||
|
||||
def changed(self) -> None:
|
||||
"""Позвать после любой правки базы."""
|
||||
self.report = ledger.build(self.vault.doc)
|
||||
self.refresh_all()
|
||||
|
||||
def refresh_all(self) -> None:
|
||||
for _, page in self.pages:
|
||||
page.refresh()
|
||||
self._update_status()
|
||||
|
||||
def _on_page_changed(self, index: int) -> None:
|
||||
self.stack.setCurrentIndex(index)
|
||||
# Настройки и сводка показывают состояние синхронизации, которое могло
|
||||
# измениться в фоне, пока смотрели другой экран.
|
||||
self.pages[index][1].refresh()
|
||||
|
||||
def sync_status_text(self) -> str:
|
||||
return self._sync_text
|
||||
|
||||
# --- сохранение ---
|
||||
|
||||
def _autosave(self) -> None:
|
||||
if self._syncing or self.vault.revision == self._saved_revision:
|
||||
return
|
||||
self.save_now()
|
||||
|
||||
def save_now(self) -> bool:
|
||||
try:
|
||||
written = self.vault.save()
|
||||
except OSError as exc:
|
||||
self.status_label.setText(f"Не удалось сохранить базу: {exc}")
|
||||
return False
|
||||
|
||||
self._saved_revision = self.vault.revision
|
||||
return written
|
||||
|
||||
# --- синхронизация ---
|
||||
|
||||
def sync_now(self, quiet: bool = False) -> None:
|
||||
if self._syncing:
|
||||
return
|
||||
|
||||
git = self.vault.doc.settings.git
|
||||
if not git.remote_url:
|
||||
if not quiet:
|
||||
QMessageBox.information(
|
||||
self, "Не настроено", "Укажи адрес репозитория в настройках."
|
||||
)
|
||||
return
|
||||
if gitsync.git_executable() is None:
|
||||
self._sync_text = "git не установлен — синхронизация недоступна"
|
||||
self._update_status()
|
||||
if not quiet:
|
||||
QMessageBox.warning(self, "Нет git", self._sync_text)
|
||||
return
|
||||
if self.conflict_message and quiet:
|
||||
# Пока конфликт не разрешён, автопуш стоит: иначе мы бы либо
|
||||
# затирали чужое, либо каждый час показывали одну и ту же ошибку.
|
||||
return
|
||||
|
||||
self.save_now()
|
||||
self._syncing = True
|
||||
self._sync_text = "синхронизация…"
|
||||
self._update_status()
|
||||
|
||||
sync = GitSync(paths.app_root(), paths.vault_rel_posix(), git)
|
||||
self._sync_task = SyncTask(sync, paths.remote_copy_path(), self)
|
||||
self._sync_task.done.connect(lambda *args: self._on_sync_done(*args, quiet=quiet))
|
||||
self._sync_task.start()
|
||||
|
||||
def _on_sync_done(self, result, error: str, conflict: bool, quiet: bool = True) -> None:
|
||||
self._syncing = False
|
||||
stamp = datetime.now().strftime("%H:%M")
|
||||
|
||||
if conflict:
|
||||
self.conflict_message = error
|
||||
self._sync_text = f"{stamp} — расхождение версий, автопуш остановлен"
|
||||
self._update_status()
|
||||
self.refresh_all()
|
||||
QMessageBox.warning(self, "Версии разошлись", error)
|
||||
return
|
||||
|
||||
if error:
|
||||
self._sync_text = f"{stamp} — ошибка: {error.splitlines()[0][:120]}"
|
||||
self._update_status()
|
||||
if not quiet:
|
||||
QMessageBox.warning(self, "Синхронизация не удалась", error)
|
||||
return
|
||||
|
||||
self.conflict_message = None
|
||||
if result is not None and result.pulled:
|
||||
try:
|
||||
self.vault.reload()
|
||||
self._saved_revision = self.vault.revision
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"Не удалось прочитать версию с сервера",
|
||||
f"Файл подтянут, но открыть его не вышло: {exc}\n\n"
|
||||
"Скорее всего, база с другой машины шифровалась другим паролем.",
|
||||
)
|
||||
|
||||
self._sync_text = f"{stamp} — {result.message if result else 'готово'}"
|
||||
self.changed()
|
||||
|
||||
def _update_status(self) -> None:
|
||||
git = self.vault.doc.settings.git
|
||||
mode = (
|
||||
f"каждые {git.auto_push_minutes} мин"
|
||||
if git.enabled and git.remote_url
|
||||
else "автопуш выключен"
|
||||
)
|
||||
dirty = " • есть несохранённые изменения" if self.vault.revision != self._saved_revision else ""
|
||||
self.status_label.setText(f"{self._sync_text} • {mode}{dirty}")
|
||||
|
||||
# --- разрешение конфликта ---
|
||||
|
||||
def adopt_remote_version(self, _copy_path: Path) -> None:
|
||||
git = self.vault.doc.settings.git
|
||||
sync = GitSync(paths.app_root(), paths.vault_rel_posix(), git)
|
||||
try:
|
||||
sync.reset_to_remote()
|
||||
self.vault.reload()
|
||||
except (GitError, OSError) as exc:
|
||||
QMessageBox.critical(self, "Не получилось", str(exc))
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"Версия с сервера не открывается",
|
||||
f"{exc}\n\nВероятно, у неё другой пароль.",
|
||||
)
|
||||
return
|
||||
|
||||
self._saved_revision = self.vault.revision
|
||||
self.conflict_message = None
|
||||
self._sync_text = "принята версия с сервера"
|
||||
self.changed()
|
||||
|
||||
def force_push_local(self) -> None:
|
||||
git = self.vault.doc.settings.git
|
||||
self.save_now()
|
||||
sync = GitSync(paths.app_root(), paths.vault_rel_posix(), git)
|
||||
try:
|
||||
sync.force_push()
|
||||
except GitError as exc:
|
||||
QMessageBox.critical(self, "Не получилось", str(exc))
|
||||
return
|
||||
|
||||
self.conflict_message = None
|
||||
self._sync_text = "сервер перезаписан локальной версией"
|
||||
self.changed()
|
||||
|
||||
# --- закрытие ---
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
self.autosave_timer.stop()
|
||||
self.sync_timer.stop()
|
||||
self.save_now()
|
||||
|
||||
git = self.vault.doc.settings.git
|
||||
if not (git.enabled and git.remote_url) or self.conflict_message:
|
||||
event.accept()
|
||||
return
|
||||
if gitsync.git_executable() is None:
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if self._sync_task is not None and self._sync_task.isRunning():
|
||||
self._sync_task.wait(CLOSE_SYNC_WAIT_MS)
|
||||
event.accept()
|
||||
return
|
||||
|
||||
# Последний пуш на выходе, с потолком по времени: висеть на закрытии
|
||||
# из-за пропавшей сети приложение не должно.
|
||||
self.status_label.setText("Отправляю базу на сервер…")
|
||||
sync = GitSync(paths.app_root(), paths.vault_rel_posix(), git)
|
||||
task = SyncTask(sync, paths.remote_copy_path(), self)
|
||||
task.start()
|
||||
task.wait(CLOSE_SYNC_WAIT_MS)
|
||||
event.accept()
|
||||
363
app/ui/page_batches.py
Normal file
363
app/ui/page_batches.py
Normal file
@ -0,0 +1,363 @@
|
||||
"""Закупки: партии булок, взятых в пекарне в долг, и их покрытие."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import journal, ledger
|
||||
from .. import money as m
|
||||
from . import theme
|
||||
from . import widgets as w
|
||||
|
||||
|
||||
class BatchDialog(QDialog):
|
||||
"""Форма партии: что взял, почём и до какого числа надо рассчитаться."""
|
||||
|
||||
def __init__(self, ctx, batch=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self.batch = batch
|
||||
self.setWindowTitle(f"Закупка №{batch.number}" if batch else "Новая закупка")
|
||||
self.setMinimumSize(640, 480)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
layout.setSpacing(10)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setSpacing(8)
|
||||
|
||||
self.date = w.DateInput(batch.date if batch else None)
|
||||
self.date.dateChanged.connect(self._shift_due_date)
|
||||
form.addRow("Дата закупки", self.date)
|
||||
|
||||
self.due = w.DateInput(
|
||||
batch.due_date
|
||||
if batch
|
||||
else self.date.get_date() + timedelta(days=ctx.vault.doc.settings.default_period_days)
|
||||
)
|
||||
form.addRow("Покрыть до", self.due)
|
||||
|
||||
self.note = QLineEdit(batch.note if batch else "")
|
||||
self.note.setPlaceholderText("договорились по телефону")
|
||||
form.addRow("Заметка", self.note)
|
||||
layout.addLayout(form)
|
||||
|
||||
layout.addWidget(w.label("Что взял", "h2"))
|
||||
products = [p for p in ctx.vault.doc.products if not p.archived]
|
||||
self.lines = w.LinesEditor(
|
||||
products, "Себестоимость", lambda p: p.cost_price, ctx.currency
|
||||
)
|
||||
if batch:
|
||||
self.lines.set_lines(batch.lines, "unit_cost")
|
||||
elif products:
|
||||
self.lines.add_line()
|
||||
layout.addWidget(self.lines, 1)
|
||||
|
||||
if not products:
|
||||
layout.addWidget(
|
||||
w.label("Сначала заведи товары на вкладке «Товары».", "dim", theme.WARN)
|
||||
)
|
||||
|
||||
layout.addWidget(
|
||||
w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сохранить", "primary", self.accept))
|
||||
)
|
||||
|
||||
def _shift_due_date(self) -> None:
|
||||
"""При смене даты закупки двигаем дедлайн, сохраняя длину периода."""
|
||||
if self.batch is not None:
|
||||
return
|
||||
days = self.ctx.vault.doc.settings.default_period_days
|
||||
self.due.set_date(self.date.get_date() + timedelta(days=days))
|
||||
|
||||
|
||||
class PaymentDialog(QDialog):
|
||||
"""Возврат денег пекарне."""
|
||||
|
||||
def __init__(self, ctx, batch=None, suggested=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Платёж пекарне")
|
||||
self.setMinimumWidth(380)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
layout.setSpacing(10)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setSpacing(8)
|
||||
|
||||
self.date = w.DateInput()
|
||||
form.addRow("Дата", self.date)
|
||||
|
||||
self.amount = w.MoneySpin(ctx.currency)
|
||||
if suggested is not None:
|
||||
self.amount.set_decimal(suggested)
|
||||
form.addRow("Сумма", self.amount)
|
||||
|
||||
self.note = QLineEdit()
|
||||
form.addRow("Заметка", self.note)
|
||||
layout.addLayout(form)
|
||||
|
||||
if batch is not None:
|
||||
layout.addWidget(w.label(f"Зачтётся в партию №{batch.number}.", "dim"))
|
||||
else:
|
||||
layout.addWidget(
|
||||
w.label("Без выбранной партии платёж закроет самые старые долги.", "dim")
|
||||
)
|
||||
|
||||
layout.addWidget(
|
||||
w.row(None, w.button("Отмена", on_click=self.reject), w.button("Записать", "primary", self.accept))
|
||||
)
|
||||
|
||||
|
||||
class BatchesPage(QWidget):
|
||||
def __init__(self, ctx, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 18, 20, 18)
|
||||
layout.setSpacing(12)
|
||||
|
||||
layout.addWidget(w.heading("Закупки"))
|
||||
layout.addWidget(
|
||||
w.row(
|
||||
w.button("+ Закупка", "primary", self.create),
|
||||
w.button("Изменить", on_click=self.edit),
|
||||
w.button("Платёж пекарне", on_click=self.pay),
|
||||
w.button("Закрыть / открыть", on_click=self.toggle_closed),
|
||||
w.button("Удалить", "danger", self.delete),
|
||||
)
|
||||
)
|
||||
|
||||
splitter = QSplitter(Qt.Vertical)
|
||||
|
||||
self.table = w.table(
|
||||
["№", "Дата", "Покрыть до", "Осталось", "Долг", "Отдано", "Собрано", "Покрытие", "Статус"],
|
||||
stretch_column=8,
|
||||
)
|
||||
self.table.itemSelectionChanged.connect(self._show_details)
|
||||
self.table.doubleClicked.connect(self.edit)
|
||||
splitter.addWidget(self.table)
|
||||
|
||||
self.details = self._build_details()
|
||||
splitter.addWidget(self.details)
|
||||
splitter.setSizes([420, 260])
|
||||
layout.addWidget(splitter, 1)
|
||||
|
||||
def _build_details(self) -> QWidget:
|
||||
holder = QWidget()
|
||||
layout = QVBoxLayout(holder)
|
||||
layout.setContentsMargins(0, 8, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self.details_title = w.label("Выбери партию", "h2")
|
||||
layout.addWidget(self.details_title)
|
||||
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setRange(0, 100)
|
||||
layout.addWidget(self.progress)
|
||||
|
||||
self.details_line = w.label("", "dim")
|
||||
self.details_line.setWordWrap(True)
|
||||
layout.addWidget(self.details_line)
|
||||
|
||||
self.contents = w.table(["Товар", "Взято", "Осталось", "Себестоимость", "Сумма"])
|
||||
layout.addWidget(self.contents, 1)
|
||||
return holder
|
||||
|
||||
# --- отрисовка ---
|
||||
|
||||
def refresh(self) -> None:
|
||||
currency = self.ctx.currency
|
||||
rows, keys = [], []
|
||||
|
||||
# Свежие партии сверху: они и есть текущая работа.
|
||||
for report in sorted(
|
||||
self.ctx.report.batches, key=lambda r: (r.batch.date, r.batch.number), reverse=True
|
||||
):
|
||||
batch = report.batch
|
||||
color = theme.STATUS_COLORS.get(report.status, "")
|
||||
days = (
|
||||
"—"
|
||||
if report.status == ledger.STATUS_SETTLED
|
||||
else f"{report.days_left} дн."
|
||||
if report.days_left >= 0
|
||||
else f"просрочено на {-report.days_left} дн."
|
||||
)
|
||||
rows.append(
|
||||
[
|
||||
w.sortable_num_item(str(batch.number), batch.number),
|
||||
w.text_item(batch.date.strftime("%d.%m.%Y")),
|
||||
w.text_item(batch.due_date.strftime("%d.%m.%Y")),
|
||||
w.text_item(days, color),
|
||||
w.sortable_num_item(m.fmt_money(report.cost_total, currency), report.cost_total),
|
||||
w.sortable_num_item(m.fmt_money(report.paid_to_bakery, currency), report.paid_to_bakery),
|
||||
w.sortable_num_item(m.fmt_money(report.cash_collected, currency), report.cash_collected),
|
||||
w.sortable_num_item(f"{report.coverage_pct}%", report.coverage_pct, color),
|
||||
w.text_item(ledger.STATUS_LABELS.get(report.status, ""), color),
|
||||
]
|
||||
)
|
||||
keys.append(batch.id)
|
||||
|
||||
w.fill(self.table, rows, keys)
|
||||
self._show_details()
|
||||
|
||||
def _show_details(self) -> None:
|
||||
report = self._selected_report(quiet=True)
|
||||
if report is None:
|
||||
self.details_title.setText("Выбери партию")
|
||||
self.details_line.setText("")
|
||||
self.progress.setValue(0)
|
||||
self.contents.setRowCount(0)
|
||||
return
|
||||
|
||||
currency = self.ctx.currency
|
||||
color = theme.STATUS_COLORS.get(report.status, theme.ACCENT)
|
||||
|
||||
self.details_title.setText(f"Партия {report.label}")
|
||||
self.progress.setValue(int(min(100, max(0, report.coverage_pct))))
|
||||
self.progress.setFormat(f"собрано {report.coverage_pct}% себестоимости")
|
||||
self.progress.setStyleSheet(f"QProgressBar::chunk {{ background: {color}; }}")
|
||||
|
||||
parts = [
|
||||
f"Долг пекарне: {m.fmt_money(report.remaining_to_bakery, currency)} "
|
||||
f"из {m.fmt_money(report.cost_total, currency)}",
|
||||
f"собрано деньгами {m.fmt_money(report.cash_collected, currency)}",
|
||||
f"осталось собрать {m.fmt_money(report.still_to_collect, currency)}",
|
||||
]
|
||||
if report.consumed_cost > 0:
|
||||
parts.append(f"съедено и подарено на {m.fmt_money(report.consumed_cost, currency)}")
|
||||
self.details_line.setText(" • ".join(parts))
|
||||
|
||||
doc = self.ctx.vault.doc
|
||||
rows = []
|
||||
for line in report.batch.lines:
|
||||
left = report.stock.get(line.product_id, m.ZERO)
|
||||
rows.append(
|
||||
[
|
||||
w.text_item(doc.product_name(line.product_id)),
|
||||
w.sortable_num_item(m.fmt_qty(line.qty), line.qty),
|
||||
w.sortable_num_item(
|
||||
m.fmt_qty(left), left, theme.MUTED if left <= 0 else ""
|
||||
),
|
||||
w.sortable_num_item(m.fmt_money(line.unit_cost, currency), line.unit_cost),
|
||||
w.sortable_num_item(m.fmt_money(line.total, currency), line.total),
|
||||
]
|
||||
)
|
||||
w.fill(self.contents, rows)
|
||||
|
||||
# --- действия ---
|
||||
|
||||
def _selected_report(self, quiet: bool = False):
|
||||
batch_id = w.selected_key(self.table)
|
||||
report = self.ctx.report.batch_report(batch_id) if batch_id else None
|
||||
if report is None and not quiet:
|
||||
QMessageBox.information(self, "Не выбрано", "Выбери партию в списке.")
|
||||
return report
|
||||
|
||||
def create(self) -> None:
|
||||
if not [p for p in self.ctx.vault.doc.products if not p.archived]:
|
||||
QMessageBox.information(
|
||||
self, "Нет товаров", "Сначала заведи булки на вкладке «Товары»."
|
||||
)
|
||||
return
|
||||
|
||||
dialog = BatchDialog(self.ctx, parent=self)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
try:
|
||||
journal.create_batch(
|
||||
self.ctx.vault,
|
||||
dialog.date.get_date(),
|
||||
dialog.due.get_date(),
|
||||
dialog.lines.lines("unit_cost"),
|
||||
dialog.note.text(),
|
||||
)
|
||||
except journal.ValidationError as exc:
|
||||
QMessageBox.warning(self, "Не получилось", str(exc))
|
||||
return
|
||||
self.ctx.changed()
|
||||
|
||||
def edit(self) -> None:
|
||||
report = self._selected_report()
|
||||
if report is None:
|
||||
return
|
||||
|
||||
dialog = BatchDialog(self.ctx, report.batch, parent=self)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
try:
|
||||
journal.update_batch(
|
||||
self.ctx.vault,
|
||||
report.batch.id,
|
||||
dialog.date.get_date(),
|
||||
dialog.due.get_date(),
|
||||
dialog.lines.lines("unit_cost"),
|
||||
dialog.note.text(),
|
||||
)
|
||||
except journal.ValidationError as exc:
|
||||
QMessageBox.warning(self, "Не получилось", str(exc))
|
||||
return
|
||||
self.ctx.changed()
|
||||
|
||||
def pay(self) -> None:
|
||||
report = self._selected_report(quiet=True)
|
||||
batch = report.batch if report else None
|
||||
dialog = PaymentDialog(
|
||||
self.ctx, batch, report.remaining_to_bakery if report else None, parent=self
|
||||
)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
try:
|
||||
journal.add_bakery_payment(
|
||||
self.ctx.vault,
|
||||
dialog.amount.value_decimal(),
|
||||
dialog.date.get_date(),
|
||||
batch.id if batch else None,
|
||||
dialog.note.text(),
|
||||
)
|
||||
except journal.ValidationError as exc:
|
||||
QMessageBox.warning(self, "Не получилось", str(exc))
|
||||
return
|
||||
self.ctx.changed()
|
||||
|
||||
def toggle_closed(self) -> None:
|
||||
report = self._selected_report()
|
||||
if report is None:
|
||||
return
|
||||
journal.set_batch_closed(self.ctx.vault, report.batch.id, not report.batch.closed)
|
||||
self.ctx.changed()
|
||||
|
||||
def delete(self) -> None:
|
||||
report = self._selected_report()
|
||||
if report is None:
|
||||
return
|
||||
|
||||
confirm = QMessageBox.question(
|
||||
self,
|
||||
"Удалить партию?",
|
||||
f"Удалить партию {report.label}?\n\n"
|
||||
"Платежи пекарне останутся в учёте — они просто потеряют привязку.",
|
||||
)
|
||||
if confirm != QMessageBox.Yes:
|
||||
return
|
||||
try:
|
||||
journal.delete_batch(self.ctx.vault, report.batch.id)
|
||||
except journal.ValidationError as exc:
|
||||
QMessageBox.warning(self, "Не получилось", str(exc))
|
||||
return
|
||||
self.ctx.changed()
|
||||
205
app/ui/page_dashboard.py
Normal file
205
app/ui/page_dashboard.py
Normal file
@ -0,0 +1,205 @@
|
||||
"""Сводка: успеваю ли я рассчитаться с пекарней."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
QProgressBar,
|
||||
QScrollArea,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import ledger
|
||||
from .. import money as m
|
||||
from . import theme
|
||||
from . import widgets as w
|
||||
|
||||
|
||||
class DashboardPage(QWidget):
|
||||
def __init__(self, ctx, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
outer.addWidget(scroll)
|
||||
|
||||
self.body = QWidget()
|
||||
self.layout = QVBoxLayout(self.body)
|
||||
self.layout.setContentsMargins(20, 18, 20, 18)
|
||||
self.layout.setSpacing(14)
|
||||
scroll.setWidget(self.body)
|
||||
|
||||
def refresh(self) -> None:
|
||||
# Сводка целиком собирается заново: цифр немного, а следить за тем,
|
||||
# какая карточка устарела, вышло бы дороже, чем перерисовать всё.
|
||||
while self.layout.count():
|
||||
item = self.layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
# setParent(None) обязателен: deleteLater откладывает удаление
|
||||
# до следующего прохода цикла событий, и до тех пор старые
|
||||
# карточки остаются детьми body.
|
||||
widget.setParent(None)
|
||||
widget.deleteLater()
|
||||
|
||||
report = self.ctx.report
|
||||
summary = report.summary
|
||||
currency = self.ctx.currency
|
||||
|
||||
self.layout.addWidget(w.heading("Сводка"))
|
||||
|
||||
if report.warnings:
|
||||
self.layout.addWidget(w.warning_banner(report.warnings))
|
||||
|
||||
self.layout.addWidget(self._metrics(summary, currency))
|
||||
|
||||
if summary.next_due is not None:
|
||||
self.layout.addWidget(self._deadline_card(summary.next_due, currency))
|
||||
|
||||
self.layout.addWidget(w.label("Открытые партии", "h2"))
|
||||
self.layout.addWidget(self._open_batches(report, currency))
|
||||
|
||||
self.layout.addWidget(w.label("Остатки", "h2"))
|
||||
self.layout.addWidget(self._stock(report, currency))
|
||||
self.layout.addStretch(1)
|
||||
|
||||
def _metrics(self, summary, currency: str) -> QWidget:
|
||||
holder = QWidget()
|
||||
grid = QGridLayout(holder)
|
||||
grid.setContentsMargins(0, 0, 0, 0)
|
||||
grid.setSpacing(10)
|
||||
|
||||
overdue_hint = (
|
||||
f"просрочено партий: {summary.overdue_count}" if summary.overdue_count else "всё в срок"
|
||||
)
|
||||
cards = [
|
||||
(
|
||||
"Долг пекарне",
|
||||
m.fmt_money(summary.owed_to_bakery, currency),
|
||||
overdue_hint,
|
||||
theme.DANGER if summary.overdue_count else theme.TEXT,
|
||||
),
|
||||
(
|
||||
"Мне должны",
|
||||
m.fmt_money(summary.receivable, currency),
|
||||
"по неоплаченным продажам",
|
||||
theme.WARN if summary.receivable > 0 else theme.TEXT,
|
||||
),
|
||||
(
|
||||
"Прибыль",
|
||||
m.fmt_money(summary.profit, currency),
|
||||
f"наценка {m.fmt_money(summary.gross_margin, currency)} "
|
||||
f"− съедено {m.fmt_money(summary.consumed_cost, currency)}",
|
||||
theme.OK if summary.profit > 0 else theme.DANGER if summary.profit < 0 else theme.TEXT,
|
||||
),
|
||||
(
|
||||
"Остаток на складе",
|
||||
m.fmt_qty(summary.stock_qty),
|
||||
f"по себестоимости {m.fmt_money(summary.stock_cost, currency)}",
|
||||
theme.TEXT,
|
||||
),
|
||||
]
|
||||
for index, (title, value, hint, color) in enumerate(cards):
|
||||
grid.addWidget(w.metric_card(title, value, hint, color), 0, index)
|
||||
grid.setColumnStretch(index, 1)
|
||||
return holder
|
||||
|
||||
def _deadline_card(self, report, currency: str) -> QWidget:
|
||||
card = w.card()
|
||||
layout = QVBoxLayout(card)
|
||||
layout.setContentsMargins(16, 14, 16, 14)
|
||||
layout.setSpacing(8)
|
||||
|
||||
color = theme.STATUS_COLORS.get(report.status, theme.ACCENT)
|
||||
when = (
|
||||
f"осталось {report.days_left} дн."
|
||||
if report.days_left >= 0
|
||||
else f"просрочена на {-report.days_left} дн."
|
||||
)
|
||||
layout.addWidget(
|
||||
w.label(
|
||||
f"Ближайший срок — партия {report.label}, до "
|
||||
f"{report.batch.due_date.strftime('%d.%m.%Y')} ({when})",
|
||||
"h2",
|
||||
color,
|
||||
)
|
||||
)
|
||||
|
||||
bar = QProgressBar()
|
||||
bar.setRange(0, 100)
|
||||
bar.setValue(int(min(100, max(0, report.coverage_pct))))
|
||||
bar.setFormat(f"собрано {report.coverage_pct}% себестоимости")
|
||||
bar.setStyleSheet(f"QProgressBar::chunk {{ background: {color}; }}")
|
||||
layout.addWidget(bar)
|
||||
|
||||
layout.addWidget(
|
||||
w.label(
|
||||
f"Надо вернуть {m.fmt_money(report.remaining_to_bakery, currency)} • "
|
||||
f"собрано деньгами {m.fmt_money(report.cash_collected, currency)} • "
|
||||
f"осталось собрать {m.fmt_money(report.still_to_collect, currency)}",
|
||||
"dim",
|
||||
)
|
||||
)
|
||||
return card
|
||||
|
||||
def _open_batches(self, report, currency: str) -> QWidget:
|
||||
table = w.table(
|
||||
["№", "Дата", "Покрыть до", "Осталось", "Долг", "Собрано", "Покрытие", "Статус"],
|
||||
stretch_column=7,
|
||||
)
|
||||
table.setMaximumHeight(220)
|
||||
|
||||
rows = []
|
||||
for item in sorted(report.open_batches, key=lambda r: r.batch.due_date):
|
||||
color = theme.STATUS_COLORS.get(item.status, "")
|
||||
days = (
|
||||
f"{item.days_left} дн."
|
||||
if item.days_left >= 0
|
||||
else f"просрочено на {-item.days_left} дн."
|
||||
)
|
||||
rows.append(
|
||||
[
|
||||
w.sortable_num_item(str(item.batch.number), item.batch.number),
|
||||
w.text_item(item.batch.date.strftime("%d.%m.%Y")),
|
||||
w.text_item(item.batch.due_date.strftime("%d.%m.%Y")),
|
||||
w.text_item(days, color),
|
||||
w.sortable_num_item(
|
||||
m.fmt_money(item.remaining_to_bakery, currency), item.remaining_to_bakery
|
||||
),
|
||||
w.sortable_num_item(m.fmt_money(item.cash_collected, currency), item.cash_collected),
|
||||
w.sortable_num_item(f"{item.coverage_pct}%", item.coverage_pct, color),
|
||||
w.text_item(ledger.STATUS_LABELS.get(item.status, ""), color),
|
||||
]
|
||||
)
|
||||
w.fill(table, rows)
|
||||
|
||||
if not rows:
|
||||
return w.label("Открытых партий нет — пекарня может печь дальше.", "dim")
|
||||
return table
|
||||
|
||||
def _stock(self, report, currency: str) -> QWidget:
|
||||
rows = [row for row in report.stock if row.qty > 0]
|
||||
if not rows:
|
||||
return w.label("Булок не осталось.", "dim")
|
||||
|
||||
table = w.table(["Товар", "Остаток", "Ед.", "По себестоимости"])
|
||||
table.setMaximumHeight(220)
|
||||
w.fill(
|
||||
table,
|
||||
[
|
||||
[
|
||||
w.text_item(row.name),
|
||||
w.sortable_num_item(m.fmt_qty(row.qty), row.qty),
|
||||
w.text_item(row.unit),
|
||||
w.sortable_num_item(m.fmt_money(row.cost, currency), row.cost),
|
||||
]
|
||||
for row in rows
|
||||
],
|
||||
)
|
||||
return table
|
||||
191
app/ui/page_debts.py
Normal file
191
app/ui/page_debts.py
Normal file
@ -0,0 +1,191 @@
|
||||
"""Долги: кто и сколько должен за взятые булки."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QFormLayout,
|
||||
QHeaderView,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import journal
|
||||
from .. import money as m
|
||||
from ..models import SALE_KIND_LABELS
|
||||
from . import theme
|
||||
from . import widgets as w
|
||||
|
||||
ROLE_SALE_ID = Qt.UserRole + 2
|
||||
|
||||
|
||||
class PaymentDialog(QDialog):
|
||||
def __init__(self, suggested: Decimal, currency: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Принять оплату")
|
||||
self.setMinimumWidth(340)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
layout.setSpacing(10)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setSpacing(8)
|
||||
|
||||
self.date = w.DateInput()
|
||||
form.addRow("Дата", self.date)
|
||||
|
||||
self.amount = w.MoneySpin(currency)
|
||||
self.amount.set_decimal(suggested)
|
||||
self.amount.selectAll()
|
||||
form.addRow("Сумма", self.amount)
|
||||
|
||||
self.note = QLineEdit()
|
||||
form.addRow("Заметка", self.note)
|
||||
layout.addLayout(form)
|
||||
|
||||
layout.addWidget(w.label(f"Долг по этой продаже: {m.fmt_money(suggested, currency)}", "dim"))
|
||||
layout.addWidget(
|
||||
w.row(None, w.button("Отмена", on_click=self.reject), w.button("Принять", "primary", self.accept))
|
||||
)
|
||||
self.amount.setFocus()
|
||||
|
||||
|
||||
def ask_payment(parent, suggested: Decimal, currency: str) -> Decimal | None:
|
||||
"""Спросить сумму оплаты. Возвращает None, если отменили."""
|
||||
dialog = PaymentDialog(suggested, currency, parent)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return None
|
||||
return dialog.amount.value_decimal()
|
||||
|
||||
|
||||
class DebtsPage(QWidget):
|
||||
"""Дерево: человек → его неоплаченные продажи."""
|
||||
|
||||
def __init__(self, ctx, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 18, 20, 18)
|
||||
layout.setSpacing(12)
|
||||
|
||||
layout.addWidget(w.heading("Долги"))
|
||||
self.total_label = w.label("", "h2")
|
||||
layout.addWidget(
|
||||
w.row(
|
||||
w.button("Принять оплату", "primary", self.take_payment),
|
||||
w.button("Погасить целиком", on_click=self.settle_full),
|
||||
None,
|
||||
self.total_label,
|
||||
)
|
||||
)
|
||||
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setHeaderLabels(["Кто / что", "Дата", "Сумма", "Оплачено", "Долг"])
|
||||
self.tree.setAlternatingRowColors(True)
|
||||
self.tree.setRootIsDecorated(True)
|
||||
self.tree.header().setStretchLastSection(False)
|
||||
self.tree.header().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
self.tree.itemDoubleClicked.connect(self.take_payment)
|
||||
layout.addWidget(self.tree, 1)
|
||||
|
||||
self.empty = w.label("Никто ничего не должен.", "dim")
|
||||
layout.addWidget(self.empty)
|
||||
|
||||
# --- отрисовка ---
|
||||
|
||||
def refresh(self) -> None:
|
||||
doc = self.ctx.vault.doc
|
||||
currency = self.ctx.currency
|
||||
debtors = self.ctx.report.debtors
|
||||
|
||||
self.tree.clear()
|
||||
for debtor in debtors:
|
||||
parent = QTreeWidgetItem(
|
||||
[debtor.name, "", "", "", m.fmt_money(debtor.debt, currency)]
|
||||
)
|
||||
font = parent.font(0)
|
||||
font.setBold(True)
|
||||
parent.setFont(0, font)
|
||||
parent.setFont(4, font)
|
||||
parent.setForeground(4, QColor(theme.WARN))
|
||||
parent.setTextAlignment(4, Qt.AlignRight | Qt.AlignVCenter)
|
||||
|
||||
for sale in sorted(debtor.sales, key=lambda s: s.date):
|
||||
what = ", ".join(
|
||||
f"{doc.product_name(line.product_id)} × {m.fmt_qty(line.qty)}"
|
||||
for line in sale.lines
|
||||
)
|
||||
child = QTreeWidgetItem(
|
||||
[
|
||||
f"{SALE_KIND_LABELS.get(sale.kind, sale.kind)}: {what}",
|
||||
sale.date.strftime("%d.%m.%Y"),
|
||||
m.fmt_money(sale.total, currency),
|
||||
m.fmt_money(sale.paid, currency),
|
||||
m.fmt_money(sale.debt, currency),
|
||||
]
|
||||
)
|
||||
child.setData(0, ROLE_SALE_ID, sale.id)
|
||||
for column in (2, 3, 4):
|
||||
child.setTextAlignment(column, Qt.AlignRight | Qt.AlignVCenter)
|
||||
parent.addChild(child)
|
||||
|
||||
self.tree.addTopLevelItem(parent)
|
||||
parent.setExpanded(True)
|
||||
|
||||
for column in range(1, 5):
|
||||
self.tree.resizeColumnToContents(column)
|
||||
|
||||
total = self.ctx.report.summary.receivable
|
||||
self.total_label.setText(f"Мне должны: {m.fmt_money(total, currency)}")
|
||||
self.empty.setVisible(not debtors)
|
||||
self.tree.setVisible(bool(debtors))
|
||||
|
||||
# --- действия ---
|
||||
|
||||
def _selected_sale(self):
|
||||
item = self.tree.currentItem()
|
||||
if item is None:
|
||||
QMessageBox.information(self, "Не выбрано", "Выбери конкретную продажу.")
|
||||
return None
|
||||
|
||||
sale_id = item.data(0, ROLE_SALE_ID)
|
||||
if sale_id is None:
|
||||
QMessageBox.information(
|
||||
self, "Выбери продажу", "Раскрой человека и выбери, за что он платит."
|
||||
)
|
||||
return None
|
||||
return self.ctx.vault.doc.sale(sale_id)
|
||||
|
||||
def take_payment(self) -> None:
|
||||
sale = self._selected_sale()
|
||||
if sale is None:
|
||||
return
|
||||
|
||||
amount = ask_payment(self, sale.debt, self.ctx.currency)
|
||||
if amount is None:
|
||||
return
|
||||
try:
|
||||
journal.add_sale_payment(self.ctx.vault, sale.id, amount)
|
||||
except journal.ValidationError as exc:
|
||||
QMessageBox.warning(self, "Не получилось", str(exc))
|
||||
return
|
||||
self.ctx.changed()
|
||||
|
||||
def settle_full(self) -> None:
|
||||
sale = self._selected_sale()
|
||||
if sale is None:
|
||||
return
|
||||
if sale.debt <= 0:
|
||||
return
|
||||
journal.add_sale_payment(self.ctx.vault, sale.id, sale.debt)
|
||||
self.ctx.changed()
|
||||
148
app/ui/page_journal.py
Normal file
148
app/ui/page_journal.py
Normal file
@ -0,0 +1,148 @@
|
||||
"""Журнал изменений.
|
||||
|
||||
История, которую git по зашифрованному файлу дать не может: коммиты хранят
|
||||
непрозрачные снимки, а внутри суток они ещё и схлопываются в один. Здесь видно,
|
||||
что именно менялось, когда и с какой машины.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QComboBox, QLineEdit, QVBoxLayout, QWidget
|
||||
|
||||
from ..models import JournalEntry
|
||||
from . import theme
|
||||
from . import widgets as w
|
||||
|
||||
ENTITY_LABELS = {
|
||||
"product": "Товары",
|
||||
"batch": "Закупки",
|
||||
"sale": "Продажи",
|
||||
"counterparty": "Контрагенты",
|
||||
"bakery_payment": "Платежи пекарне",
|
||||
"settings": "Настройки",
|
||||
"journal": "Журнал",
|
||||
}
|
||||
|
||||
ACTION_LABELS = {
|
||||
"product.create": "товар создан",
|
||||
"product.update": "товар изменён",
|
||||
"product.price_change": "цены изменены",
|
||||
"product.archive": "товар в архив",
|
||||
"product.delete": "товар удалён",
|
||||
"counterparty.create": "контрагент создан",
|
||||
"counterparty.update": "контрагент изменён",
|
||||
"counterparty.delete": "контрагент удалён",
|
||||
"batch.create": "закупка создана",
|
||||
"batch.update": "закупка изменена",
|
||||
"batch.close": "партия закрыта",
|
||||
"batch.reopen": "партия открыта",
|
||||
"batch.delete": "закупка удалена",
|
||||
"sale.create": "продажа создана",
|
||||
"sale.update": "продажа изменена",
|
||||
"sale.delete": "продажа удалена",
|
||||
"sale.payment_add": "принята оплата",
|
||||
"sale.payment_delete": "оплата удалена",
|
||||
"bakery_payment.add": "платёж пекарне",
|
||||
"bakery_payment.delete": "платёж пекарне удалён",
|
||||
"settings.update": "настройки изменены",
|
||||
"journal.prune": "журнал обрезан",
|
||||
}
|
||||
|
||||
ACTION_COLORS = {
|
||||
"delete": theme.DANGER,
|
||||
"create": theme.OK,
|
||||
"add": theme.OK,
|
||||
}
|
||||
|
||||
|
||||
def describe(entry: JournalEntry) -> str:
|
||||
"""Свернуть изменения в одну строку таблицы."""
|
||||
parts = []
|
||||
for change in entry.changes:
|
||||
if change.old and change.new:
|
||||
parts.append(f"{change.field}: {change.old} → {change.new}")
|
||||
elif change.new:
|
||||
parts.append(f"{change.field}: {change.new}")
|
||||
elif change.old:
|
||||
parts.append(f"{change.field}: было {change.old}")
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def _color_for(action: str) -> str:
|
||||
tail = action.rsplit(".", 1)[-1]
|
||||
for needle, color in ACTION_COLORS.items():
|
||||
if needle in tail:
|
||||
return color
|
||||
return ""
|
||||
|
||||
|
||||
class JournalPage(QWidget):
|
||||
def __init__(self, ctx, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 18, 20, 18)
|
||||
layout.setSpacing(12)
|
||||
|
||||
layout.addWidget(w.heading("Журнал"))
|
||||
layout.addWidget(
|
||||
w.label(
|
||||
"Что менялось в базе. Git хранит только зашифрованные снимки — "
|
||||
"подробности есть здесь.",
|
||||
"dim",
|
||||
)
|
||||
)
|
||||
|
||||
self.search = QLineEdit()
|
||||
self.search.setPlaceholderText("Поиск: булка с маком, 30,00, Вася…")
|
||||
self.search.setClearButtonEnabled(True)
|
||||
self.search.textChanged.connect(self.refresh)
|
||||
|
||||
self.entity = QComboBox()
|
||||
self.entity.addItem("Все разделы", None)
|
||||
for key, title in ENTITY_LABELS.items():
|
||||
self.entity.addItem(title, key)
|
||||
self.entity.currentIndexChanged.connect(self.refresh)
|
||||
|
||||
self.count_label = w.label("", "dim")
|
||||
layout.addWidget(w.row(self.search, self.entity, None, self.count_label, stretch_at=0))
|
||||
|
||||
self.table = w.table(
|
||||
["Когда", "Машина", "Что произошло", "Объект", "Изменения"], stretch_column=4
|
||||
)
|
||||
self.table.setSortingEnabled(False) # журнал всегда в хронологии
|
||||
layout.addWidget(self.table, 1)
|
||||
|
||||
def refresh(self) -> None:
|
||||
needle = self.search.text().strip().lower()
|
||||
wanted = self.entity.currentData()
|
||||
|
||||
rows = []
|
||||
total = 0
|
||||
for entry in reversed(self.ctx.vault.doc.journal):
|
||||
total += 1
|
||||
if wanted and entry.entity != wanted:
|
||||
continue
|
||||
|
||||
description = describe(entry)
|
||||
action = ACTION_LABELS.get(entry.action, entry.action)
|
||||
haystack = " ".join([action, entry.label, description, entry.host]).lower()
|
||||
if needle and needle not in haystack:
|
||||
continue
|
||||
|
||||
when = entry.ts.replace("T", " ")[:16]
|
||||
rows.append(
|
||||
[
|
||||
w.text_item(when),
|
||||
w.text_item(entry.host, theme.MUTED),
|
||||
w.text_item(action, _color_for(entry.action)),
|
||||
w.text_item(entry.label),
|
||||
w.text_item(description),
|
||||
]
|
||||
)
|
||||
|
||||
w.fill(self.table, rows)
|
||||
self.count_label.setText(
|
||||
f"показано {len(rows)} из {total}" if len(rows) != total else f"записей: {total}"
|
||||
)
|
||||
277
app/ui/page_products.py
Normal file
277
app/ui/page_products.py
Normal file
@ -0,0 +1,277 @@
|
||||
"""Номенклатура: товары, их цены и история изменения цен."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QFormLayout,
|
||||
QHeaderView,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import journal
|
||||
from .. import money as m
|
||||
from . import theme
|
||||
from . import widgets as w
|
||||
|
||||
|
||||
class ProductDialog(QDialog):
|
||||
"""Карточка товара. Цены здесь только при создании — потом через историю."""
|
||||
|
||||
def __init__(self, currency: str, product=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.product = product
|
||||
self.setWindowTitle("Товар" if product else "Новый товар")
|
||||
self.setMinimumWidth(400)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
layout.setSpacing(10)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setSpacing(8)
|
||||
|
||||
self.name = QLineEdit(product.name if product else "")
|
||||
self.name.setPlaceholderText("Булка с повидлом")
|
||||
form.addRow("Название", self.name)
|
||||
|
||||
self.unit = QLineEdit(product.unit if product else "шт")
|
||||
form.addRow("Единица", self.unit)
|
||||
|
||||
if product is None:
|
||||
self.cost = w.MoneySpin(currency)
|
||||
form.addRow("Себестоимость", self.cost)
|
||||
self.retail = w.MoneySpin(currency)
|
||||
form.addRow("Цена продажи", self.retail)
|
||||
|
||||
self.note = QPlainTextEdit(product.note if product else "")
|
||||
self.note.setMaximumHeight(70)
|
||||
form.addRow("Заметка", self.note)
|
||||
|
||||
layout.addLayout(form)
|
||||
if product is not None:
|
||||
layout.addWidget(
|
||||
w.label("Цены меняются отдельной кнопкой — так сохраняется их история.", "dim")
|
||||
)
|
||||
layout.addWidget(
|
||||
w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сохранить", "primary", self.accept))
|
||||
)
|
||||
|
||||
|
||||
class PriceDialog(QDialog):
|
||||
"""Смена цен с записью в историю."""
|
||||
|
||||
def __init__(self, currency: str, product, parent=None):
|
||||
super().__init__(parent)
|
||||
self.product = product
|
||||
self.setWindowTitle(f"Цены — {product.name}")
|
||||
self.setMinimumWidth(520)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
layout.setSpacing(10)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setSpacing(8)
|
||||
|
||||
self.cost = w.MoneySpin(currency)
|
||||
self.cost.set_decimal(product.cost_price)
|
||||
form.addRow("Себестоимость", self.cost)
|
||||
|
||||
self.retail = w.MoneySpin(currency)
|
||||
self.retail.set_decimal(product.retail_price)
|
||||
form.addRow("Цена продажи", self.retail)
|
||||
|
||||
self.date = w.DateInput()
|
||||
form.addRow("С даты", self.date)
|
||||
|
||||
self.note = QLineEdit()
|
||||
self.note.setPlaceholderText("подорожала мука")
|
||||
form.addRow("Причина", self.note)
|
||||
|
||||
layout.addLayout(form)
|
||||
layout.addWidget(w.label("История цен", "h2"))
|
||||
|
||||
history = w.table(["Дата", "Себестоимость", "Цена продажи", "Причина"], stretch_column=3)
|
||||
history.setSortingEnabled(False)
|
||||
history.setMaximumHeight(220)
|
||||
rows = [
|
||||
[
|
||||
w.text_item(point.date.strftime("%d.%m.%Y")),
|
||||
w.num_item(m.fmt_money(point.cost_price, currency), point.cost_price),
|
||||
w.num_item(m.fmt_money(point.retail_price, currency), point.retail_price),
|
||||
w.text_item(point.note),
|
||||
]
|
||||
for point in reversed(product.price_history)
|
||||
]
|
||||
w.fill(history, rows)
|
||||
layout.addWidget(history)
|
||||
|
||||
layout.addWidget(
|
||||
w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сохранить", "primary", self.accept))
|
||||
)
|
||||
|
||||
|
||||
class ProductsPage(QWidget):
|
||||
def __init__(self, ctx, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 18, 20, 18)
|
||||
layout.setSpacing(12)
|
||||
|
||||
layout.addWidget(w.heading("Номенклатура"))
|
||||
layout.addWidget(
|
||||
w.row(
|
||||
w.button("+ Товар", "primary", self.create),
|
||||
w.button("Изменить", on_click=self.edit),
|
||||
w.button("Цены и история", on_click=self.edit_prices),
|
||||
w.button("В архив / вернуть", on_click=self.toggle_archive),
|
||||
w.button("Удалить", "danger", self.delete),
|
||||
None,
|
||||
self._archive_checkbox(),
|
||||
)
|
||||
)
|
||||
|
||||
self.table = w.table(
|
||||
["Название", "Ед.", "Себестоимость", "Цена продажи", "Наценка", "Остаток", "Статус"]
|
||||
)
|
||||
self.table.doubleClicked.connect(self.edit_prices)
|
||||
layout.addWidget(self.table, 1)
|
||||
|
||||
def _archive_checkbox(self) -> QCheckBox:
|
||||
self.show_archived = QCheckBox("Показывать архив")
|
||||
self.show_archived.toggled.connect(self.refresh)
|
||||
return self.show_archived
|
||||
|
||||
# --- отрисовка ---
|
||||
|
||||
def refresh(self) -> None:
|
||||
currency = self.ctx.currency
|
||||
stock = {row.product_id: row.qty for row in self.ctx.report.stock}
|
||||
|
||||
rows, keys = [], []
|
||||
for product in sorted(self.ctx.vault.doc.products, key=lambda p: p.name.lower()):
|
||||
if product.archived and not self.show_archived.isChecked():
|
||||
continue
|
||||
|
||||
margin = m.money(product.retail_price - product.cost_price)
|
||||
left = stock.get(product.id, m.ZERO)
|
||||
rows.append(
|
||||
[
|
||||
w.text_item(product.name, theme.MUTED if product.archived else ""),
|
||||
w.text_item(product.unit),
|
||||
w.sortable_num_item(m.fmt_money(product.cost_price, currency), product.cost_price),
|
||||
w.sortable_num_item(m.fmt_money(product.retail_price, currency), product.retail_price),
|
||||
w.sortable_num_item(
|
||||
m.fmt_money(margin, currency),
|
||||
margin,
|
||||
theme.OK if margin > 0 else theme.DANGER if margin < 0 else "",
|
||||
),
|
||||
w.sortable_num_item(m.fmt_qty(left), left, theme.MUTED if left <= 0 else ""),
|
||||
w.text_item("в архиве" if product.archived else "", theme.MUTED),
|
||||
]
|
||||
)
|
||||
keys.append(product.id)
|
||||
|
||||
w.fill(self.table, rows, keys)
|
||||
|
||||
# --- действия ---
|
||||
|
||||
def _selected(self):
|
||||
product_id = w.selected_key(self.table)
|
||||
product = self.ctx.vault.doc.product(product_id) if product_id else None
|
||||
if product is None:
|
||||
QMessageBox.information(self, "Не выбрано", "Выбери товар в списке.")
|
||||
return product
|
||||
|
||||
def create(self) -> None:
|
||||
dialog = ProductDialog(self.ctx.currency, parent=self)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
try:
|
||||
journal.create_product(
|
||||
self.ctx.vault,
|
||||
dialog.name.text(),
|
||||
dialog.unit.text(),
|
||||
dialog.cost.value_decimal(),
|
||||
dialog.retail.value_decimal(),
|
||||
dialog.note.toPlainText(),
|
||||
)
|
||||
except journal.ValidationError as exc:
|
||||
QMessageBox.warning(self, "Не получилось", str(exc))
|
||||
return
|
||||
self.ctx.changed()
|
||||
|
||||
def edit(self) -> None:
|
||||
product = self._selected()
|
||||
if product is None:
|
||||
return
|
||||
|
||||
dialog = ProductDialog(self.ctx.currency, product, parent=self)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
try:
|
||||
journal.update_product(
|
||||
self.ctx.vault,
|
||||
product.id,
|
||||
name=dialog.name.text(),
|
||||
unit=dialog.unit.text(),
|
||||
note=dialog.note.toPlainText(),
|
||||
)
|
||||
except journal.ValidationError as exc:
|
||||
QMessageBox.warning(self, "Не получилось", str(exc))
|
||||
return
|
||||
self.ctx.changed()
|
||||
|
||||
def edit_prices(self) -> None:
|
||||
product = self._selected()
|
||||
if product is None:
|
||||
return
|
||||
|
||||
dialog = PriceDialog(self.ctx.currency, product, parent=self)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
try:
|
||||
journal.change_prices(
|
||||
self.ctx.vault,
|
||||
product.id,
|
||||
dialog.cost.value_decimal(),
|
||||
dialog.retail.value_decimal(),
|
||||
dialog.date.get_date(),
|
||||
dialog.note.text(),
|
||||
)
|
||||
except journal.ValidationError as exc:
|
||||
QMessageBox.warning(self, "Не получилось", str(exc))
|
||||
return
|
||||
self.ctx.changed()
|
||||
|
||||
def toggle_archive(self) -> None:
|
||||
product = self._selected()
|
||||
if product is None:
|
||||
return
|
||||
journal.update_product(self.ctx.vault, product.id, archived=not product.archived)
|
||||
self.ctx.changed()
|
||||
|
||||
def delete(self) -> None:
|
||||
product = self._selected()
|
||||
if product is None:
|
||||
return
|
||||
|
||||
confirm = QMessageBox.question(
|
||||
self, "Удалить товар?", f"Удалить «{product.name}» без возможности вернуть?"
|
||||
)
|
||||
if confirm != QMessageBox.Yes:
|
||||
return
|
||||
try:
|
||||
journal.delete_product(self.ctx.vault, product.id)
|
||||
except journal.ValidationError as exc:
|
||||
QMessageBox.warning(self, "Удалить нельзя", str(exc))
|
||||
return
|
||||
self.ctx.changed()
|
||||
322
app/ui/page_sales.py
Normal file
322
app/ui/page_sales.py
Normal file
@ -0,0 +1,322 @@
|
||||
"""Продажи и прочее выбытие: розница, друзья по себестоимости, съел сам."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QFormLayout,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import journal
|
||||
from .. import money as m
|
||||
from ..models import (
|
||||
CONSUMPTION_KINDS,
|
||||
KIND_FRIEND,
|
||||
KIND_RETAIL,
|
||||
SALE_KIND_LABELS,
|
||||
SALE_KINDS,
|
||||
)
|
||||
from . import theme
|
||||
from . import widgets as w
|
||||
|
||||
|
||||
def price_source_for(kind: str):
|
||||
"""Откуда берётся цена по умолчанию для этого типа выбытия.
|
||||
|
||||
Ради этого типы и заведены: друг платит себестоимость, съеденное не стоит
|
||||
ничего, розница идёт по цене продажи.
|
||||
"""
|
||||
if kind == KIND_FRIEND:
|
||||
return lambda product: product.cost_price
|
||||
if kind in CONSUMPTION_KINDS:
|
||||
return lambda product: m.ZERO
|
||||
return lambda product: product.retail_price
|
||||
|
||||
|
||||
class SaleDialog(QDialog):
|
||||
def __init__(self, ctx, sale=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self.sale = sale
|
||||
self.setWindowTitle("Продажа" if sale else "Новая продажа")
|
||||
self.setMinimumSize(640, 520)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
layout.setSpacing(10)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setSpacing(8)
|
||||
|
||||
self.date = w.DateInput(sale.date if sale else None)
|
||||
form.addRow("Дата", self.date)
|
||||
|
||||
self.kind = QComboBox()
|
||||
for kind in SALE_KINDS:
|
||||
self.kind.addItem(SALE_KIND_LABELS[kind], kind)
|
||||
self.kind.setCurrentIndex(SALE_KINDS.index(sale.kind) if sale else 0)
|
||||
self.kind.currentIndexChanged.connect(self._on_kind_changed)
|
||||
form.addRow("Тип", self.kind)
|
||||
|
||||
self.counterparty = QComboBox()
|
||||
self.counterparty.addItem("— не указан —", None)
|
||||
for cp in sorted(ctx.vault.doc.counterparties, key=lambda c: c.name.lower()):
|
||||
self.counterparty.addItem(cp.name, cp.id)
|
||||
if sale and sale.counterparty_id:
|
||||
index = self.counterparty.findData(sale.counterparty_id)
|
||||
if index >= 0:
|
||||
self.counterparty.setCurrentIndex(index)
|
||||
form.addRow("Кому", w.row(self.counterparty, w.button("+ Новый", on_click=self._add_counterparty), stretch_at=0))
|
||||
|
||||
self.note = QLineEdit(sale.note if sale else "")
|
||||
form.addRow("Заметка", self.note)
|
||||
layout.addLayout(form)
|
||||
|
||||
layout.addWidget(w.label("Что ушло", "h2"))
|
||||
products = [p for p in ctx.vault.doc.products if not p.archived]
|
||||
self.lines = w.LinesEditor(
|
||||
products, "Цена", price_source_for(self.current_kind()), ctx.currency
|
||||
)
|
||||
self.lines.changed.connect(self._sync_payment_default)
|
||||
if sale:
|
||||
self.lines.set_lines(sale.lines, "unit_price")
|
||||
elif products:
|
||||
self.lines.add_line()
|
||||
layout.addWidget(self.lines, 1)
|
||||
|
||||
# Оплату задаём только при создании: у существующей продажи платежи
|
||||
# живут своей жизнью и правятся на экране долгов.
|
||||
self.paid = None
|
||||
if sale is None:
|
||||
pay_form = QFormLayout()
|
||||
self.paid = w.MoneySpin(ctx.currency)
|
||||
pay_form.addRow("Оплачено сразу", self.paid)
|
||||
layout.addLayout(pay_form)
|
||||
self.paid_hint = w.label("", "dim")
|
||||
layout.addWidget(self.paid_hint)
|
||||
|
||||
layout.addWidget(
|
||||
w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сохранить", "primary", self.accept))
|
||||
)
|
||||
self._on_kind_changed()
|
||||
|
||||
def current_kind(self) -> str:
|
||||
return self.kind.currentData()
|
||||
|
||||
def _on_kind_changed(self) -> None:
|
||||
kind = self.current_kind()
|
||||
self.lines.price_source = price_source_for(kind)
|
||||
self.lines.refresh_prices()
|
||||
|
||||
consumption = kind in CONSUMPTION_KINDS
|
||||
if self.paid is not None:
|
||||
self.paid.setEnabled(not consumption)
|
||||
if consumption:
|
||||
self.paid.set_decimal(0)
|
||||
self._sync_payment_default()
|
||||
|
||||
def _sync_payment_default(self) -> None:
|
||||
if self.paid is None:
|
||||
return
|
||||
|
||||
kind = self.current_kind()
|
||||
if kind in CONSUMPTION_KINDS:
|
||||
self.paid_hint.setText("Денег нет по определению, но себестоимость пекарне вернуть надо.")
|
||||
return
|
||||
|
||||
total = self.lines.total()
|
||||
# Полная оплата — самый частый случай, поэтому она и подставляется.
|
||||
# Уменьшил сумму — получил долг, который поедет на экран «Долги».
|
||||
if not self.paid.hasFocus():
|
||||
self.paid.set_decimal(total)
|
||||
|
||||
debt = m.money(total - self.paid.value_decimal())
|
||||
if debt > 0:
|
||||
self.paid_hint.setText(f"В долг уйдёт {m.fmt_money(debt, self.ctx.currency)}.")
|
||||
else:
|
||||
self.paid_hint.setText("Оплачено полностью.")
|
||||
|
||||
def _add_counterparty(self) -> None:
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
name, ok = QInputDialog.getText(self, "Новый контрагент", "Имя")
|
||||
if not ok or not name.strip():
|
||||
return
|
||||
try:
|
||||
cp = journal.create_counterparty(self.ctx.vault, name)
|
||||
except journal.ValidationError as exc:
|
||||
QMessageBox.warning(self, "Не получилось", str(exc))
|
||||
return
|
||||
self.counterparty.addItem(cp.name, cp.id)
|
||||
self.counterparty.setCurrentIndex(self.counterparty.count() - 1)
|
||||
|
||||
|
||||
class SalesPage(QWidget):
|
||||
def __init__(self, ctx, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 18, 20, 18)
|
||||
layout.setSpacing(12)
|
||||
|
||||
layout.addWidget(w.heading("Продажи"))
|
||||
|
||||
self.filter = QComboBox()
|
||||
self.filter.addItem("Все типы", None)
|
||||
for kind in SALE_KINDS:
|
||||
self.filter.addItem(SALE_KIND_LABELS[kind], kind)
|
||||
self.filter.currentIndexChanged.connect(self.refresh)
|
||||
|
||||
layout.addWidget(
|
||||
w.row(
|
||||
w.button("+ Продажа", "primary", self.create),
|
||||
w.button("Изменить", on_click=self.edit),
|
||||
w.button("Принять оплату", on_click=self.take_payment),
|
||||
w.button("Удалить", "danger", self.delete),
|
||||
None,
|
||||
self.filter,
|
||||
)
|
||||
)
|
||||
|
||||
self.table = w.table(
|
||||
["Дата", "Тип", "Кому", "Что", "Сумма", "Оплачено", "Долг"], stretch_column=3
|
||||
)
|
||||
self.table.doubleClicked.connect(self.edit)
|
||||
layout.addWidget(self.table, 1)
|
||||
|
||||
# --- отрисовка ---
|
||||
|
||||
def refresh(self) -> None:
|
||||
doc = self.ctx.vault.doc
|
||||
currency = self.ctx.currency
|
||||
wanted = self.filter.currentData()
|
||||
|
||||
rows, keys = [], []
|
||||
for sale in sorted(doc.sales, key=lambda s: s.date, reverse=True):
|
||||
if wanted and sale.kind != wanted:
|
||||
continue
|
||||
|
||||
what = ", ".join(
|
||||
f"{doc.product_name(line.product_id)} × {m.fmt_qty(line.qty)}"
|
||||
for line in sale.lines
|
||||
)
|
||||
debt = sale.debt
|
||||
rows.append(
|
||||
[
|
||||
w.text_item(sale.date.strftime("%d.%m.%Y")),
|
||||
w.text_item(
|
||||
SALE_KIND_LABELS.get(sale.kind, sale.kind),
|
||||
theme.MUTED if sale.is_consumption else "",
|
||||
),
|
||||
w.text_item(doc.counterparty_name(sale.counterparty_id)),
|
||||
w.text_item(what),
|
||||
w.sortable_num_item(m.fmt_money(sale.total, currency), sale.total),
|
||||
w.sortable_num_item(m.fmt_money(sale.paid, currency), sale.paid),
|
||||
w.sortable_num_item(
|
||||
m.fmt_money(debt, currency) if debt > 0 else "",
|
||||
debt,
|
||||
theme.WARN if debt > 0 else "",
|
||||
),
|
||||
]
|
||||
)
|
||||
keys.append(sale.id)
|
||||
|
||||
w.fill(self.table, rows, keys)
|
||||
|
||||
# --- действия ---
|
||||
|
||||
def _selected(self):
|
||||
sale_id = w.selected_key(self.table)
|
||||
sale = self.ctx.vault.doc.sale(sale_id) if sale_id else None
|
||||
if sale is None:
|
||||
QMessageBox.information(self, "Не выбрано", "Выбери продажу в списке.")
|
||||
return sale
|
||||
|
||||
def create(self) -> None:
|
||||
if not [p for p in self.ctx.vault.doc.products if not p.archived]:
|
||||
QMessageBox.information(self, "Нет товаров", "Сначала заведи булки на вкладке «Товары».")
|
||||
return
|
||||
|
||||
dialog = SaleDialog(self.ctx, parent=self)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
try:
|
||||
journal.create_sale(
|
||||
self.ctx.vault,
|
||||
dialog.date.get_date(),
|
||||
dialog.current_kind(),
|
||||
dialog.lines.lines("unit_price"),
|
||||
dialog.counterparty.currentData(),
|
||||
dialog.note.text(),
|
||||
dialog.paid.value_decimal(),
|
||||
)
|
||||
except journal.ValidationError as exc:
|
||||
QMessageBox.warning(self, "Не получилось", str(exc))
|
||||
return
|
||||
self.ctx.changed()
|
||||
|
||||
def edit(self) -> None:
|
||||
sale = self._selected()
|
||||
if sale is None:
|
||||
return
|
||||
|
||||
dialog = SaleDialog(self.ctx, sale, parent=self)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
try:
|
||||
journal.update_sale(
|
||||
self.ctx.vault,
|
||||
sale.id,
|
||||
dialog.date.get_date(),
|
||||
dialog.current_kind(),
|
||||
dialog.lines.lines("unit_price"),
|
||||
dialog.counterparty.currentData() or "",
|
||||
dialog.note.text(),
|
||||
)
|
||||
except journal.ValidationError as exc:
|
||||
QMessageBox.warning(self, "Не получилось", str(exc))
|
||||
return
|
||||
self.ctx.changed()
|
||||
|
||||
def take_payment(self) -> None:
|
||||
sale = self._selected()
|
||||
if sale is None:
|
||||
return
|
||||
if sale.debt <= 0:
|
||||
QMessageBox.information(self, "Долга нет", "Эта продажа уже оплачена.")
|
||||
return
|
||||
|
||||
from .page_debts import ask_payment
|
||||
|
||||
amount = ask_payment(self, sale.debt, self.ctx.currency)
|
||||
if amount is None:
|
||||
return
|
||||
try:
|
||||
journal.add_sale_payment(self.ctx.vault, sale.id, amount)
|
||||
except journal.ValidationError as exc:
|
||||
QMessageBox.warning(self, "Не получилось", str(exc))
|
||||
return
|
||||
self.ctx.changed()
|
||||
|
||||
def delete(self) -> None:
|
||||
sale = self._selected()
|
||||
if sale is None:
|
||||
return
|
||||
|
||||
confirm = QMessageBox.question(
|
||||
self, "Удалить продажу?", "Товар вернётся в остатки, деньги — исчезнут из учёта."
|
||||
)
|
||||
if confirm != QMessageBox.Yes:
|
||||
return
|
||||
try:
|
||||
journal.delete_sale(self.ctx.vault, sale.id)
|
||||
except journal.ValidationError as exc:
|
||||
QMessageBox.warning(self, "Не получилось", str(exc))
|
||||
return
|
||||
self.ctx.changed()
|
||||
362
app/ui/page_settings.py
Normal file
362
app/ui/page_settings.py
Normal file
@ -0,0 +1,362 @@
|
||||
"""Настройки: git, пароль, обслуживание базы и разрешение конфликтов."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QScrollArea,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import gitsync, journal, paths
|
||||
from ..storage import Vault
|
||||
from . import theme
|
||||
from . import widgets as w
|
||||
|
||||
|
||||
class ChangePasswordDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Смена пароля")
|
||||
self.setMinimumWidth(380)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
layout.setSpacing(10)
|
||||
|
||||
form = QFormLayout()
|
||||
self.current = QLineEdit()
|
||||
self.current.setEchoMode(QLineEdit.Password)
|
||||
form.addRow("Текущий", self.current)
|
||||
|
||||
self.new = QLineEdit()
|
||||
self.new.setEchoMode(QLineEdit.Password)
|
||||
form.addRow("Новый", self.new)
|
||||
|
||||
self.confirm = QLineEdit()
|
||||
self.confirm.setEchoMode(QLineEdit.Password)
|
||||
form.addRow("Ещё раз", self.confirm)
|
||||
layout.addLayout(form)
|
||||
|
||||
self.error = QLabel("")
|
||||
self.error.setStyleSheet(f"color: {theme.DANGER};")
|
||||
self.error.setWordWrap(True)
|
||||
layout.addWidget(self.error)
|
||||
|
||||
layout.addWidget(
|
||||
w.label(
|
||||
"Файл будет перешифрован целиком. Старые копии в истории git "
|
||||
"останутся зашифрованными прежним паролем.",
|
||||
"dim",
|
||||
)
|
||||
)
|
||||
layout.itemAt(layout.count() - 1).widget().setWordWrap(True)
|
||||
layout.addWidget(
|
||||
w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сменить", "primary", self.accept))
|
||||
)
|
||||
|
||||
|
||||
class SettingsPage(QWidget):
|
||||
def __init__(self, ctx, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
outer.addWidget(scroll)
|
||||
|
||||
body = QWidget()
|
||||
layout = QVBoxLayout(body)
|
||||
layout.setContentsMargins(20, 18, 20, 18)
|
||||
layout.setSpacing(14)
|
||||
scroll.setWidget(body)
|
||||
|
||||
layout.addWidget(w.heading("Настройки"))
|
||||
layout.addWidget(self._git_group())
|
||||
layout.addWidget(self._conflict_group())
|
||||
layout.addWidget(self._general_group())
|
||||
layout.addWidget(self._maintenance_group())
|
||||
layout.addStretch(1)
|
||||
|
||||
# --- группы ---
|
||||
|
||||
def _git_group(self) -> QGroupBox:
|
||||
box = QGroupBox("Синхронизация с GitLab")
|
||||
layout = QVBoxLayout(box)
|
||||
layout.setSpacing(8)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setSpacing(8)
|
||||
|
||||
self.remote = QLineEdit()
|
||||
self.remote.setPlaceholderText("https://gt.ser.gay/kizya/food-market.git")
|
||||
form.addRow("Репозиторий", self.remote)
|
||||
|
||||
self.token = QLineEdit()
|
||||
self.token.setEchoMode(QLineEdit.Password)
|
||||
self.token.setPlaceholderText("оставь пустым, чтобы не менять")
|
||||
form.addRow("Токен", self.token)
|
||||
|
||||
self.branch = QLineEdit()
|
||||
form.addRow("Ветка", self.branch)
|
||||
|
||||
self.interval = QSpinBox()
|
||||
self.interval.setRange(5, 24 * 60)
|
||||
self.interval.setSuffix(" мин")
|
||||
form.addRow("Пушить каждые", self.interval)
|
||||
|
||||
self.enabled = QCheckBox("Синхронизировать автоматически")
|
||||
form.addRow("", self.enabled)
|
||||
|
||||
self.squash = QCheckBox("Один коммит на день")
|
||||
self.squash.setToolTip(
|
||||
"Дельта-сжатие на зашифрованном файле не работает: каждый коммит — "
|
||||
"это полная копия базы. Схлопывание сбивает рост репозитория "
|
||||
"примерно с 60 до 13 МБ в год. Взамен откат через git возможен "
|
||||
"на границу суток, а не на любой час — внутри дня историю показывает журнал."
|
||||
)
|
||||
form.addRow("", self.squash)
|
||||
layout.addLayout(form)
|
||||
|
||||
self.git_status = w.label("", "dim")
|
||||
self.git_status.setWordWrap(True)
|
||||
layout.addWidget(self.git_status)
|
||||
|
||||
layout.addWidget(
|
||||
w.row(
|
||||
w.button("Сохранить", "primary", self.save_git),
|
||||
w.button("Синхронизировать сейчас", on_click=self.ctx.sync_now),
|
||||
None,
|
||||
)
|
||||
)
|
||||
layout.addWidget(
|
||||
w.label(
|
||||
"Токен хранится внутри зашифрованной базы и не попадает в .git/config. "
|
||||
"В журнал его значение тоже не пишется.",
|
||||
"dim",
|
||||
)
|
||||
)
|
||||
layout.itemAt(layout.count() - 1).widget().setWordWrap(True)
|
||||
return box
|
||||
|
||||
def _conflict_group(self) -> QGroupBox:
|
||||
self.conflict_box = QGroupBox("Расхождение версий")
|
||||
layout = QVBoxLayout(self.conflict_box)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self.conflict_text = w.label("", "dim")
|
||||
self.conflict_text.setWordWrap(True)
|
||||
layout.addWidget(self.conflict_text)
|
||||
layout.addWidget(
|
||||
w.row(
|
||||
w.button("Взять версию с сервера", on_click=self.take_remote),
|
||||
w.button("Оставить мою и перезаписать сервер", "danger", self.force_local),
|
||||
None,
|
||||
)
|
||||
)
|
||||
return self.conflict_box
|
||||
|
||||
def _general_group(self) -> QGroupBox:
|
||||
box = QGroupBox("Общее")
|
||||
form = QFormLayout(box)
|
||||
form.setSpacing(8)
|
||||
|
||||
self.currency = QLineEdit()
|
||||
self.currency.setMaximumWidth(80)
|
||||
form.addRow("Валюта", self.currency)
|
||||
|
||||
self.period_days = QSpinBox()
|
||||
self.period_days.setRange(1, 365)
|
||||
self.period_days.setSuffix(" дн.")
|
||||
form.addRow("Период по умолчанию", self.period_days)
|
||||
|
||||
form.addRow("", w.button("Сохранить", "primary", self.save_general))
|
||||
return box
|
||||
|
||||
def _maintenance_group(self) -> QGroupBox:
|
||||
box = QGroupBox("База")
|
||||
layout = QVBoxLayout(box)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self.db_info = w.label("", "dim")
|
||||
self.db_info.setWordWrap(True)
|
||||
layout.addWidget(self.db_info)
|
||||
|
||||
layout.addWidget(
|
||||
w.row(
|
||||
w.button("Сменить пароль", on_click=self.change_password),
|
||||
w.button("Выгрузить открытый JSON", on_click=self.export_json),
|
||||
w.button("Обрезать журнал", on_click=self.prune_journal),
|
||||
None,
|
||||
)
|
||||
)
|
||||
layout.addWidget(
|
||||
w.label(
|
||||
"Забытый пароль восстановить нельзя — это единственная страховка. "
|
||||
"В выгрузке лежит и токен, так что храни её не на общем диске.",
|
||||
"dim",
|
||||
)
|
||||
)
|
||||
layout.itemAt(layout.count() - 1).widget().setWordWrap(True)
|
||||
return box
|
||||
|
||||
# --- отрисовка ---
|
||||
|
||||
def refresh(self) -> None:
|
||||
settings = self.ctx.vault.doc.settings
|
||||
git = settings.git
|
||||
|
||||
self.remote.setText(git.remote_url)
|
||||
self.branch.setText(git.branch)
|
||||
self.interval.setValue(git.auto_push_minutes)
|
||||
self.enabled.setChecked(git.enabled)
|
||||
self.squash.setChecked(git.daily_squash)
|
||||
self.token.setPlaceholderText(
|
||||
"токен задан, оставь пустым чтобы не менять" if git.token else "токен не задан"
|
||||
)
|
||||
self.currency.setText(settings.currency)
|
||||
self.period_days.setValue(settings.default_period_days)
|
||||
|
||||
if gitsync.git_executable() is None:
|
||||
self.git_status.setText(
|
||||
"git не найден в системе — синхронизация выключена. "
|
||||
"Установи Git для Windows, учёт при этом работает как обычно."
|
||||
)
|
||||
else:
|
||||
self.git_status.setText(self.ctx.sync_status_text())
|
||||
|
||||
self.conflict_box.setVisible(self.ctx.conflict_message is not None)
|
||||
if self.ctx.conflict_message:
|
||||
self.conflict_text.setText(
|
||||
f"{self.ctx.conflict_message}\n\n"
|
||||
f"Версия с сервера сохранена в {paths.remote_copy_path().name} "
|
||||
"рядом с базой. Выбери, какая версия верная."
|
||||
)
|
||||
|
||||
doc = self.ctx.vault.doc
|
||||
size = self.ctx.vault.path.stat().st_size if self.ctx.vault.path.exists() else 0
|
||||
self.db_info.setText(
|
||||
f"{self.ctx.vault.path}\n"
|
||||
f"{size / 1024:.1f} КБ • товаров {len(doc.products)} • "
|
||||
f"закупок {len(doc.batches)} • продаж {len(doc.sales)} • "
|
||||
f"записей в журнале {len(doc.journal)}"
|
||||
)
|
||||
|
||||
# --- действия ---
|
||||
|
||||
def save_git(self) -> None:
|
||||
changes = {
|
||||
"remote_url": self.remote.text().strip(),
|
||||
"branch": self.branch.text().strip() or "main",
|
||||
"auto_push_minutes": self.interval.value(),
|
||||
"enabled": self.enabled.isChecked(),
|
||||
"daily_squash": self.squash.isChecked(),
|
||||
}
|
||||
# Пустое поле означает «не трогать», иначе токен стирался бы при
|
||||
# каждом сохранении настроек.
|
||||
if self.token.text():
|
||||
changes["token"] = self.token.text()
|
||||
|
||||
journal.update_settings(self.ctx.vault, git=changes)
|
||||
self.token.clear()
|
||||
self.ctx.restart_sync_timer()
|
||||
self.ctx.changed()
|
||||
|
||||
def save_general(self) -> None:
|
||||
journal.update_settings(
|
||||
self.ctx.vault,
|
||||
currency=self.currency.text().strip(),
|
||||
default_period_days=self.period_days.value(),
|
||||
)
|
||||
self.ctx.changed()
|
||||
|
||||
def change_password(self) -> None:
|
||||
dialog = ChangePasswordDialog(self)
|
||||
while dialog.exec() == QDialog.Accepted:
|
||||
if not self.ctx.vault.check_password(dialog.current.text()):
|
||||
dialog.error.setText("Текущий пароль неверный.")
|
||||
continue
|
||||
if len(dialog.new.text()) < 4:
|
||||
dialog.error.setText("Новый пароль короче 4 символов.")
|
||||
continue
|
||||
if dialog.new.text() != dialog.confirm.text():
|
||||
dialog.error.setText("Новые пароли не совпадают.")
|
||||
continue
|
||||
|
||||
self.ctx.vault.change_password(dialog.new.text())
|
||||
QMessageBox.information(self, "Готово", "Пароль изменён, база перешифрована.")
|
||||
self.ctx.changed()
|
||||
return
|
||||
|
||||
def export_json(self) -> None:
|
||||
default = paths.data_dir() / f"export-{date.today().isoformat()}.json"
|
||||
target, _ = QFileDialog.getSaveFileName(
|
||||
self, "Выгрузить базу в открытый JSON", str(default), "JSON (*.json)"
|
||||
)
|
||||
if not target:
|
||||
return
|
||||
|
||||
self.ctx.vault.export_plain_json(target)
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Выгружено",
|
||||
f"Открытая копия сохранена:\n{target}\n\n"
|
||||
"Файл не зашифрован и содержит токен GitLab. Держи его отдельно.",
|
||||
)
|
||||
|
||||
def prune_journal(self) -> None:
|
||||
years, ok = QInputDialog.getInt(
|
||||
self, "Обрезать журнал", "Удалить записи старше, лет:", 3, 1, 50
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
|
||||
cutoff = date.today() - timedelta(days=365 * years)
|
||||
removed = journal.prune_journal(self.ctx.vault, cutoff)
|
||||
QMessageBox.information(
|
||||
self, "Готово", f"Удалено записей: {removed}." if removed else "Удалять нечего."
|
||||
)
|
||||
self.ctx.changed()
|
||||
|
||||
# --- разрешение конфликта ---
|
||||
|
||||
def take_remote(self) -> None:
|
||||
copy = paths.remote_copy_path()
|
||||
if not copy.exists():
|
||||
QMessageBox.warning(self, "Нет файла", "Версия с сервера не найдена.")
|
||||
return
|
||||
|
||||
confirm = QMessageBox.question(
|
||||
self,
|
||||
"Взять версию с сервера?",
|
||||
"Твои локальные изменения будут потеряны. Продолжить?",
|
||||
)
|
||||
if confirm != QMessageBox.Yes:
|
||||
return
|
||||
self.ctx.adopt_remote_version(copy)
|
||||
|
||||
def force_local(self) -> None:
|
||||
confirm = QMessageBox.question(
|
||||
self,
|
||||
"Перезаписать сервер?",
|
||||
"Версия на сервере будет заменена твоей. Изменения, сделанные "
|
||||
"на другой машине, пропадут. Продолжить?",
|
||||
)
|
||||
if confirm != QMessageBox.Yes:
|
||||
return
|
||||
self.ctx.force_push_local()
|
||||
181
app/ui/theme.py
Normal file
181
app/ui/theme.py
Normal file
@ -0,0 +1,181 @@
|
||||
"""Тёмная тема.
|
||||
|
||||
Палитра задаётся через QPalette, а не только через QSS: иначе системные
|
||||
диалоги и всплывающие подсказки остались бы светлыми и резали глаза.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor, QPalette
|
||||
|
||||
BG = "#1c1f24"
|
||||
BG_ALT = "#23272e"
|
||||
SURFACE = "#2b3038"
|
||||
BORDER = "#3a4048"
|
||||
TEXT = "#e6e8ea"
|
||||
TEXT_DIM = "#98a0aa"
|
||||
ACCENT = "#4c9aff"
|
||||
|
||||
OK = "#4caf7d"
|
||||
WARN = "#d9a441"
|
||||
DANGER = "#e0605e"
|
||||
MUTED = "#6c757d"
|
||||
|
||||
STATUS_COLORS = {
|
||||
"settled": OK,
|
||||
"open": ACCENT,
|
||||
"due_soon": WARN,
|
||||
"overdue": DANGER,
|
||||
}
|
||||
|
||||
|
||||
def apply(app) -> None:
|
||||
app.setStyle("Fusion")
|
||||
|
||||
palette = QPalette()
|
||||
palette.setColor(QPalette.Window, QColor(BG))
|
||||
palette.setColor(QPalette.WindowText, QColor(TEXT))
|
||||
palette.setColor(QPalette.Base, QColor(BG_ALT))
|
||||
palette.setColor(QPalette.AlternateBase, QColor(SURFACE))
|
||||
palette.setColor(QPalette.Text, QColor(TEXT))
|
||||
palette.setColor(QPalette.Button, QColor(SURFACE))
|
||||
palette.setColor(QPalette.ButtonText, QColor(TEXT))
|
||||
palette.setColor(QPalette.ToolTipBase, QColor(SURFACE))
|
||||
palette.setColor(QPalette.ToolTipText, QColor(TEXT))
|
||||
palette.setColor(QPalette.Highlight, QColor(ACCENT))
|
||||
palette.setColor(QPalette.HighlightedText, QColor("#ffffff"))
|
||||
palette.setColor(QPalette.PlaceholderText, QColor(TEXT_DIM))
|
||||
palette.setColor(QPalette.Link, QColor(ACCENT))
|
||||
palette.setColor(QPalette.Disabled, QPalette.Text, QColor(MUTED))
|
||||
palette.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(MUTED))
|
||||
palette.setColor(QPalette.Disabled, QPalette.WindowText, QColor(MUTED))
|
||||
app.setPalette(palette)
|
||||
|
||||
app.setStyleSheet(QSS)
|
||||
|
||||
|
||||
QSS = f"""
|
||||
QWidget {{
|
||||
font-size: 13px;
|
||||
}}
|
||||
|
||||
QLabel[role="h1"] {{ font-size: 21px; font-weight: 600; }}
|
||||
QLabel[role="h2"] {{ font-size: 15px; font-weight: 600; }}
|
||||
QLabel[role="dim"] {{ color: {TEXT_DIM}; }}
|
||||
QLabel[role="metric"] {{ font-size: 24px; font-weight: 600; }}
|
||||
|
||||
QFrame[role="card"] {{
|
||||
background: {SURFACE};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QFrame[role="banner"] {{
|
||||
background: #3a2f1c;
|
||||
border: 1px solid {WARN};
|
||||
border-radius: 6px;
|
||||
}}
|
||||
|
||||
QListWidget#sidebar {{
|
||||
background: {BG_ALT};
|
||||
border: none;
|
||||
border-right: 1px solid {BORDER};
|
||||
outline: none;
|
||||
padding-top: 8px;
|
||||
}}
|
||||
QListWidget#sidebar::item {{
|
||||
padding: 10px 16px;
|
||||
margin: 1px 6px;
|
||||
border-radius: 6px;
|
||||
color: {TEXT_DIM};
|
||||
}}
|
||||
QListWidget#sidebar::item:selected {{
|
||||
background: {SURFACE};
|
||||
color: {TEXT};
|
||||
font-weight: 600;
|
||||
}}
|
||||
QListWidget#sidebar::item:hover {{ color: {TEXT}; }}
|
||||
|
||||
QPushButton {{
|
||||
background: {SURFACE};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 6px;
|
||||
padding: 6px 14px;
|
||||
}}
|
||||
QPushButton:hover {{ border-color: {ACCENT}; }}
|
||||
QPushButton:disabled {{ color: {MUTED}; border-color: {BORDER}; }}
|
||||
QPushButton[role="primary"] {{
|
||||
background: {ACCENT};
|
||||
border-color: {ACCENT};
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}}
|
||||
QPushButton[role="primary"]:hover {{ background: #5aa6ff; }}
|
||||
QPushButton[role="danger"]:hover {{ border-color: {DANGER}; color: {DANGER}; }}
|
||||
|
||||
QLineEdit, QDateEdit, QComboBox, QSpinBox, QDoubleSpinBox, QPlainTextEdit, QTextEdit {{
|
||||
background: {BG_ALT};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
selection-background-color: {ACCENT};
|
||||
}}
|
||||
QLineEdit:focus, QDateEdit:focus, QComboBox:focus,
|
||||
QSpinBox:focus, QDoubleSpinBox:focus, QPlainTextEdit:focus {{
|
||||
border-color: {ACCENT};
|
||||
}}
|
||||
QComboBox::drop-down {{ border: none; width: 18px; }}
|
||||
QComboBox QAbstractItemView {{
|
||||
background: {BG_ALT};
|
||||
border: 1px solid {BORDER};
|
||||
selection-background-color: {ACCENT};
|
||||
}}
|
||||
|
||||
QTableWidget, QTreeWidget {{
|
||||
background: {BG_ALT};
|
||||
alternate-background-color: {SURFACE};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 6px;
|
||||
gridline-color: {BORDER};
|
||||
selection-background-color: {ACCENT};
|
||||
}}
|
||||
QHeaderView::section {{
|
||||
background: {SURFACE};
|
||||
color: {TEXT_DIM};
|
||||
border: none;
|
||||
border-bottom: 1px solid {BORDER};
|
||||
padding: 7px 8px;
|
||||
font-weight: 600;
|
||||
}}
|
||||
QTableWidget::item, QTreeWidget::item {{ padding: 4px 6px; }}
|
||||
|
||||
QProgressBar {{
|
||||
background: {BG};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 6px;
|
||||
height: 14px;
|
||||
text-align: center;
|
||||
color: {TEXT};
|
||||
}}
|
||||
QProgressBar::chunk {{ border-radius: 5px; }}
|
||||
|
||||
QStatusBar {{ border-top: 1px solid {BORDER}; }}
|
||||
QStatusBar::item {{ border: none; }}
|
||||
|
||||
QScrollBar:vertical {{ background: transparent; width: 10px; margin: 0; }}
|
||||
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 5px; min-height: 30px; }}
|
||||
QScrollBar::handle:vertical:hover {{ background: {MUTED}; }}
|
||||
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; width: 0; }}
|
||||
QScrollBar:horizontal {{ background: transparent; height: 10px; }}
|
||||
QScrollBar::handle:horizontal {{ background: {BORDER}; border-radius: 5px; min-width: 30px; }}
|
||||
|
||||
QTabWidget::pane {{ border: 1px solid {BORDER}; border-radius: 6px; }}
|
||||
QTabBar::tab {{
|
||||
background: transparent;
|
||||
padding: 7px 14px;
|
||||
color: {TEXT_DIM};
|
||||
border-bottom: 2px solid transparent;
|
||||
}}
|
||||
QTabBar::tab:selected {{ color: {TEXT}; border-bottom-color: {ACCENT}; }}
|
||||
"""
|
||||
436
app/ui/widgets.py
Normal file
436
app/ui/widgets.py
Normal file
@ -0,0 +1,436 @@
|
||||
"""Общие элементы интерфейса.
|
||||
|
||||
Здесь всё, что иначе копировалось бы по экранам: поля денег и количеств,
|
||||
настроенные таблицы и редактор строк документа.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from PySide6.QtCore import QDate, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QFont
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QCompleter,
|
||||
QDateEdit,
|
||||
QDoubleSpinBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import money as m
|
||||
from . import theme
|
||||
|
||||
MAX_MONEY = 99_999_999.0
|
||||
|
||||
|
||||
# --- поля ввода -----------------------------------------------------------
|
||||
|
||||
|
||||
class MoneySpin(QDoubleSpinBox):
|
||||
def __init__(self, currency: str = "₽", parent=None):
|
||||
super().__init__(parent)
|
||||
self.setDecimals(2)
|
||||
self.setRange(0, MAX_MONEY)
|
||||
self.setGroupSeparatorShown(True)
|
||||
self.setSuffix(f" {currency}" if currency else "")
|
||||
self.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
|
||||
def value_decimal(self) -> Decimal:
|
||||
return m.money(self.value())
|
||||
|
||||
def set_decimal(self, value) -> None:
|
||||
self.setValue(float(m.money(value)))
|
||||
|
||||
|
||||
class QtySpin(QDoubleSpinBox):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setDecimals(3)
|
||||
self.setRange(0, 1_000_000)
|
||||
self.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
# Дробные количества нужны для веса, но булки штучные — прячем нули.
|
||||
self.setStepType(QDoubleSpinBox.DefaultStepType)
|
||||
|
||||
def textFromValue(self, value: float) -> str:
|
||||
return m.fmt_qty(value)
|
||||
|
||||
def valueFromText(self, text: str) -> float:
|
||||
return float(m.qty(text))
|
||||
|
||||
def value_decimal(self) -> Decimal:
|
||||
return m.qty(self.value())
|
||||
|
||||
def set_decimal(self, value) -> None:
|
||||
self.setValue(float(m.qty(value)))
|
||||
|
||||
|
||||
class DateInput(QDateEdit):
|
||||
def __init__(self, value: date | None = None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setCalendarPopup(True)
|
||||
self.setDisplayFormat("dd.MM.yyyy")
|
||||
self.set_date(value or date.today())
|
||||
|
||||
def set_date(self, value: date) -> None:
|
||||
self.setDate(QDate(value.year, value.month, value.day))
|
||||
|
||||
def get_date(self) -> date:
|
||||
qd = self.date()
|
||||
return date(qd.year(), qd.month(), qd.day())
|
||||
|
||||
|
||||
class ProductCombo(QComboBox):
|
||||
"""Выпадающий список товаров с поиском по набранному тексту."""
|
||||
|
||||
def __init__(self, products, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setEditable(True)
|
||||
self.setInsertPolicy(QComboBox.NoInsert)
|
||||
# Поиск по подстроке, а не только по началу: «мак» должен находить
|
||||
# «Булка с маком».
|
||||
completer = self.completer()
|
||||
if completer is not None:
|
||||
completer.setFilterMode(Qt.MatchContains)
|
||||
completer.setCompletionMode(QCompleter.PopupCompletion)
|
||||
for product in products:
|
||||
self.addItem(product.name, product.id)
|
||||
|
||||
def current_product_id(self) -> str | None:
|
||||
return self.currentData()
|
||||
|
||||
def select_product(self, product_id: str) -> None:
|
||||
index = self.findData(product_id)
|
||||
if index >= 0:
|
||||
self.setCurrentIndex(index)
|
||||
|
||||
|
||||
# --- мелкие сборки --------------------------------------------------------
|
||||
|
||||
|
||||
def label(text: str, role: str = "", color: str = "") -> QLabel:
|
||||
widget = QLabel(text)
|
||||
if role:
|
||||
widget.setProperty("role", role)
|
||||
if color:
|
||||
widget.setStyleSheet(f"color: {color};")
|
||||
return widget
|
||||
|
||||
|
||||
def heading(text: str) -> QLabel:
|
||||
return label(text, "h1")
|
||||
|
||||
|
||||
def card() -> QFrame:
|
||||
frame = QFrame()
|
||||
frame.setProperty("role", "card")
|
||||
return frame
|
||||
|
||||
|
||||
def button(text: str, role: str = "", on_click=None) -> QPushButton:
|
||||
widget = QPushButton(text)
|
||||
if role:
|
||||
widget.setProperty("role", role)
|
||||
if on_click is not None:
|
||||
widget.clicked.connect(on_click)
|
||||
return widget
|
||||
|
||||
|
||||
def row(*widgets, stretch_at: int | None = None) -> QWidget:
|
||||
holder = QWidget()
|
||||
layout = QHBoxLayout(holder)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
for index, widget in enumerate(widgets):
|
||||
if widget is None:
|
||||
layout.addStretch(1)
|
||||
continue
|
||||
layout.addWidget(widget)
|
||||
if index == stretch_at:
|
||||
layout.setStretch(index, 1)
|
||||
return holder
|
||||
|
||||
|
||||
def metric_card(title: str, value: str, hint: str = "", color: str = "") -> QFrame:
|
||||
"""Крупная цифра для сводки."""
|
||||
frame = card()
|
||||
layout = QVBoxLayout(frame)
|
||||
layout.setContentsMargins(14, 12, 14, 12)
|
||||
layout.setSpacing(2)
|
||||
layout.addWidget(label(title, "dim"))
|
||||
layout.addWidget(label(value, "metric", color))
|
||||
if hint:
|
||||
layout.addWidget(label(hint, "dim"))
|
||||
frame.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
return frame
|
||||
|
||||
|
||||
def warning_banner(messages: list[str]) -> QFrame:
|
||||
frame = QFrame()
|
||||
frame.setProperty("role", "banner")
|
||||
layout = QVBoxLayout(frame)
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
layout.setSpacing(3)
|
||||
for text in messages:
|
||||
item = QLabel(f"⚠ {text}")
|
||||
item.setWordWrap(True)
|
||||
layout.addWidget(item)
|
||||
return frame
|
||||
|
||||
|
||||
# --- таблицы --------------------------------------------------------------
|
||||
|
||||
|
||||
def table(headers: list[str], stretch_column: int = 0) -> QTableWidget:
|
||||
widget = QTableWidget(0, len(headers))
|
||||
widget.setHorizontalHeaderLabels(headers)
|
||||
widget.verticalHeader().setVisible(False)
|
||||
widget.setAlternatingRowColors(True)
|
||||
widget.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
widget.setSelectionMode(QTableWidget.SingleSelection)
|
||||
widget.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
widget.setSortingEnabled(True)
|
||||
widget.setWordWrap(False)
|
||||
|
||||
header = widget.horizontalHeader()
|
||||
header.setSectionResizeMode(QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(stretch_column, QHeaderView.Stretch)
|
||||
header.setHighlightSections(False)
|
||||
return widget
|
||||
|
||||
|
||||
def text_item(text: str, color: str = "", bold: bool = False) -> QTableWidgetItem:
|
||||
item = QTableWidgetItem(text)
|
||||
if color:
|
||||
item.setForeground(QColor(color))
|
||||
if bold:
|
||||
font = item.font()
|
||||
font.setBold(True)
|
||||
item.setFont(font)
|
||||
return item
|
||||
|
||||
|
||||
def num_item(text: str, value, color: str = "", bold: bool = False) -> QTableWidgetItem:
|
||||
"""Ячейка с числом.
|
||||
|
||||
Отображаемый текст отформатирован, а сортировка идёт по настоящему
|
||||
значению — иначе «1 200 ₽» сортировалось бы как строка и оказалось бы
|
||||
меньше «900 ₽».
|
||||
"""
|
||||
item = text_item(text, color, bold)
|
||||
item.setData(Qt.UserRole + 1, float(value))
|
||||
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
return item
|
||||
|
||||
|
||||
class SortableItem(QTableWidgetItem):
|
||||
def __lt__(self, other):
|
||||
mine = self.data(Qt.UserRole + 1)
|
||||
theirs = other.data(Qt.UserRole + 1)
|
||||
if mine is not None and theirs is not None:
|
||||
return mine < theirs
|
||||
return super().__lt__(other)
|
||||
|
||||
|
||||
def sortable_num_item(text: str, value, color: str = "", bold: bool = False) -> SortableItem:
|
||||
item = SortableItem(text)
|
||||
if color:
|
||||
item.setForeground(QColor(color))
|
||||
if bold:
|
||||
font = item.font()
|
||||
font.setBold(True)
|
||||
item.setFont(font)
|
||||
item.setData(Qt.UserRole + 1, float(value))
|
||||
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
return item
|
||||
|
||||
|
||||
def fill(widget: QTableWidget, rows: list[list[QTableWidgetItem]], keys: list = None) -> None:
|
||||
"""Заполнить таблицу, сохранив идентификатор строки в UserRole нулевой ячейки."""
|
||||
was_sorting = widget.isSortingEnabled()
|
||||
widget.setSortingEnabled(False)
|
||||
widget.setRowCount(len(rows))
|
||||
for r, cells in enumerate(rows):
|
||||
for c, cell in enumerate(cells):
|
||||
widget.setItem(r, c, cell)
|
||||
if keys is not None and cells:
|
||||
cells[0].setData(Qt.UserRole, keys[r])
|
||||
widget.setSortingEnabled(was_sorting)
|
||||
|
||||
|
||||
def selected_key(widget: QTableWidget):
|
||||
row_index = widget.currentRow()
|
||||
if row_index < 0:
|
||||
return None
|
||||
item = widget.item(row_index, 0)
|
||||
return item.data(Qt.UserRole) if item else None
|
||||
|
||||
|
||||
# --- редактор строк документа ---------------------------------------------
|
||||
|
||||
|
||||
class LinesEditor(QWidget):
|
||||
"""Таблица строк закупки или продажи.
|
||||
|
||||
Один виджет на оба случая: отличается только подпись колонки цены и то,
|
||||
откуда берётся подстановка — себестоимость или цена продажи.
|
||||
"""
|
||||
|
||||
changed = Signal()
|
||||
|
||||
COL_PRODUCT, COL_QTY, COL_PRICE, COL_TOTAL = range(4)
|
||||
|
||||
def __init__(self, products, price_title: str, price_source, currency: str = "₽", parent=None):
|
||||
super().__init__(parent)
|
||||
self.products = list(products)
|
||||
self.price_source = price_source
|
||||
self.currency = currency
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(6)
|
||||
|
||||
self.table = QTableWidget(0, 4)
|
||||
self.table.setHorizontalHeaderLabels(["Товар", "Кол-во", price_title, "Сумма"])
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
header = self.table.horizontalHeader()
|
||||
header.setSectionResizeMode(self.COL_PRODUCT, QHeaderView.Stretch)
|
||||
for column in (self.COL_QTY, self.COL_PRICE, self.COL_TOTAL):
|
||||
header.setSectionResizeMode(column, QHeaderView.ResizeToContents)
|
||||
self.table.setMinimumHeight(180)
|
||||
layout.addWidget(self.table)
|
||||
|
||||
self.total_label = label("", "h2")
|
||||
layout.addWidget(
|
||||
row(
|
||||
button("+ Строка", on_click=self.add_line),
|
||||
button("Удалить строку", role="danger", on_click=self.remove_current),
|
||||
None,
|
||||
self.total_label,
|
||||
)
|
||||
)
|
||||
|
||||
# --- содержимое ---
|
||||
|
||||
def add_line(self, product_id: str | None = None, quantity=1, price=None) -> None:
|
||||
if not self.products:
|
||||
return
|
||||
|
||||
r = self.table.rowCount()
|
||||
self.table.insertRow(r)
|
||||
|
||||
combo = ProductCombo(self.products)
|
||||
if product_id:
|
||||
combo.select_product(product_id)
|
||||
combo.currentIndexChanged.connect(lambda _, rr=r: self._on_product_changed(rr))
|
||||
self.table.setCellWidget(r, self.COL_PRODUCT, combo)
|
||||
|
||||
qty_spin = QtySpin()
|
||||
qty_spin.set_decimal(quantity)
|
||||
qty_spin.valueChanged.connect(self._recalc)
|
||||
self.table.setCellWidget(r, self.COL_QTY, qty_spin)
|
||||
|
||||
price_spin = MoneySpin(self.currency)
|
||||
price_spin.set_decimal(
|
||||
price if price is not None else self._suggested_price(combo.current_product_id())
|
||||
)
|
||||
price_spin.valueChanged.connect(self._recalc)
|
||||
self.table.setCellWidget(r, self.COL_PRICE, price_spin)
|
||||
|
||||
self.table.setItem(r, self.COL_TOTAL, text_item(""))
|
||||
self._recalc()
|
||||
|
||||
def remove_current(self) -> None:
|
||||
r = self.table.currentRow()
|
||||
if r >= 0:
|
||||
self.table.removeRow(r)
|
||||
self._recalc()
|
||||
|
||||
def set_lines(self, lines: list, value_attr: str) -> None:
|
||||
self.table.setRowCount(0)
|
||||
for line in lines:
|
||||
self.add_line(line.product_id, line.qty, getattr(line, value_attr))
|
||||
|
||||
def lines(self, value_key: str) -> list[dict]:
|
||||
result = []
|
||||
for r in range(self.table.rowCount()):
|
||||
combo = self.table.cellWidget(r, self.COL_PRODUCT)
|
||||
product_id = combo.current_product_id() if combo else None
|
||||
if not product_id:
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"product_id": product_id,
|
||||
"qty": self.table.cellWidget(r, self.COL_QTY).value_decimal(),
|
||||
value_key: self.table.cellWidget(r, self.COL_PRICE).value_decimal(),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def total(self) -> Decimal:
|
||||
total = m.ZERO
|
||||
for r in range(self.table.rowCount()):
|
||||
qty_widget = self.table.cellWidget(r, self.COL_QTY)
|
||||
price_widget = self.table.cellWidget(r, self.COL_PRICE)
|
||||
if qty_widget and price_widget:
|
||||
total += qty_widget.value_decimal() * price_widget.value_decimal()
|
||||
return m.money(total)
|
||||
|
||||
def refresh_prices(self) -> None:
|
||||
"""Пересобрать подстановку цен — например, после смены типа продажи."""
|
||||
for r in range(self.table.rowCount()):
|
||||
combo = self.table.cellWidget(r, self.COL_PRODUCT)
|
||||
price_widget = self.table.cellWidget(r, self.COL_PRICE)
|
||||
if combo and price_widget:
|
||||
price_widget.set_decimal(self._suggested_price(combo.current_product_id()))
|
||||
self._recalc()
|
||||
|
||||
# --- внутреннее ---
|
||||
|
||||
def _suggested_price(self, product_id: str | None) -> Decimal:
|
||||
product = next((p for p in self.products if p.id == product_id), None)
|
||||
return self.price_source(product) if product else m.ZERO
|
||||
|
||||
def _on_product_changed(self, r: int) -> None:
|
||||
combo = self.table.cellWidget(r, self.COL_PRODUCT)
|
||||
price_widget = self.table.cellWidget(r, self.COL_PRICE)
|
||||
if combo and price_widget:
|
||||
price_widget.set_decimal(self._suggested_price(combo.current_product_id()))
|
||||
self._recalc()
|
||||
|
||||
def _recalc(self) -> None:
|
||||
for r in range(self.table.rowCount()):
|
||||
qty_widget = self.table.cellWidget(r, self.COL_QTY)
|
||||
price_widget = self.table.cellWidget(r, self.COL_PRICE)
|
||||
if not qty_widget or not price_widget:
|
||||
continue
|
||||
total = m.money(qty_widget.value_decimal() * price_widget.value_decimal())
|
||||
item = self.table.item(r, self.COL_TOTAL)
|
||||
if item is None:
|
||||
item = text_item("")
|
||||
self.table.setItem(r, self.COL_TOTAL, item)
|
||||
item.setText(m.fmt_money(total, self.currency))
|
||||
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
|
||||
self.total_label.setText(f"Итого: {m.fmt_money(self.total(), self.currency)}")
|
||||
self.changed.emit()
|
||||
|
||||
|
||||
def progress_color(status: str) -> str:
|
||||
return theme.STATUS_COLORS.get(status, theme.ACCENT)
|
||||
|
||||
|
||||
def monospace(widget) -> None:
|
||||
font = QFont("Consolas")
|
||||
font.setStyleHint(QFont.Monospace)
|
||||
widget.setFont(font)
|
||||
12
food-market.code-workspace
Normal file
12
food-market.code-workspace
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"workbench.editorAssociations": {
|
||||
"*.sprite": "d2rmodding.spriteViewer"
|
||||
}
|
||||
}
|
||||
}
|
||||
89
food-market.spec
Normal file
89
food-market.spec
Normal file
@ -0,0 +1,89 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""Сборка одного exe: pyinstaller food-market.spec
|
||||
|
||||
Готовый food-market.exe кладётся в корень клона репозитория — рядом с папкой
|
||||
data, где лежит зашифрованная база. Приложение ищет data/vault.fmdb
|
||||
относительно самого exe (см. app/paths.py).
|
||||
"""
|
||||
|
||||
# Qt тянет за собой много того, что этому приложению не нужно. Без чистки
|
||||
# onefile раздувается до ~150 МБ, с ней укладывается примерно вдвое меньше.
|
||||
EXCLUDES = [
|
||||
"PySide6.QtWebEngineCore",
|
||||
"PySide6.QtWebEngineWidgets",
|
||||
"PySide6.QtWebEngineQuick",
|
||||
"PySide6.QtQuick",
|
||||
"PySide6.QtQuick3D",
|
||||
"PySide6.QtQuickWidgets",
|
||||
"PySide6.QtQml",
|
||||
"PySide6.Qt3DCore",
|
||||
"PySide6.Qt3DRender",
|
||||
"PySide6.Qt3DInput",
|
||||
"PySide6.Qt3DLogic",
|
||||
"PySide6.Qt3DAnimation",
|
||||
"PySide6.Qt3DExtras",
|
||||
"PySide6.QtMultimedia",
|
||||
"PySide6.QtMultimediaWidgets",
|
||||
"PySide6.QtCharts",
|
||||
"PySide6.QtDataVisualization",
|
||||
"PySide6.QtBluetooth",
|
||||
"PySide6.QtNfc",
|
||||
"PySide6.QtPositioning",
|
||||
"PySide6.QtSerialPort",
|
||||
"PySide6.QtSensors",
|
||||
"PySide6.QtTest",
|
||||
"PySide6.QtSql",
|
||||
"PySide6.QtDesigner",
|
||||
"PySide6.QtHelp",
|
||||
"PySide6.QtUiTools",
|
||||
"PySide6.QtPdf",
|
||||
"PySide6.QtPdfWidgets",
|
||||
"PySide6.QtSpatialAudio",
|
||||
"PySide6.QtTextToSpeech",
|
||||
"PySide6.QtWebChannel",
|
||||
"PySide6.QtWebSockets",
|
||||
# Не наши зависимости — если утащатся транзитом, только раздуют файл.
|
||||
"tkinter",
|
||||
"unittest",
|
||||
"pydoc",
|
||||
"pytest",
|
||||
"numpy",
|
||||
"matplotlib",
|
||||
"PIL",
|
||||
]
|
||||
|
||||
a = Analysis(
|
||||
["run.py"],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
runtime_hooks=[],
|
||||
excludes=EXCLUDES,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name="food-market",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
# UPX выключен намеренно: сжатые им exe регулярно ловят ложные
|
||||
# срабатывания антивирусов, а выигрыш в размере того не стоит.
|
||||
upx=False,
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
3
pytest.ini
Normal file
3
pytest.ini
Normal file
@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
testpaths = tests
|
||||
3
requirements-dev.txt
Normal file
3
requirements-dev.txt
Normal file
@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.0
|
||||
pyinstaller>=6.0
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@ -0,0 +1,2 @@
|
||||
PySide6>=6.7
|
||||
cryptography>=42.0
|
||||
12
run.py
Normal file
12
run.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""Точка входа для запуска и для сборки exe.
|
||||
|
||||
Отдельный файл нужен потому, что app/main.py пользуется относительными
|
||||
импортами: как скрипт он не запускается, а как модуль пакета — да.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from app.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
31
tests/conftest.py
Normal file
31
tests/conftest.py
Normal file
@ -0,0 +1,31 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app import journal
|
||||
from app.storage import Vault
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vault(tmp_path):
|
||||
return Vault.create(tmp_path / "vault.fmdb", "pw")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def buns(vault):
|
||||
"""Три булки из примера пользователя, с фиксированными ценами поставщика."""
|
||||
return {
|
||||
"повидло": journal.create_product(vault, "Булка с повидлом", cost_price=30, retail_price=50),
|
||||
"мак": journal.create_product(vault, "Булка с маком", cost_price=20, retail_price=35),
|
||||
"корица": journal.create_product(vault, "Булка с корицей", cost_price=25, retail_price=40),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def today():
|
||||
return date.today()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def in_two_weeks(today):
|
||||
return today + timedelta(days=14)
|
||||
78
tests/test_crypto.py
Normal file
78
tests/test_crypto.py
Normal file
@ -0,0 +1,78 @@
|
||||
import pytest
|
||||
|
||||
from app import crypto
|
||||
|
||||
|
||||
def test_round_trip():
|
||||
data = '{"привет": "мир"}'.encode("utf-8")
|
||||
blob = crypto.encrypt(data, "пароль123")
|
||||
assert crypto.decrypt(blob, "пароль123") == data
|
||||
|
||||
|
||||
def test_wrong_password():
|
||||
blob = crypto.encrypt(b"secret", "правильный")
|
||||
with pytest.raises(crypto.WrongPassword):
|
||||
crypto.decrypt(blob, "неправильный")
|
||||
|
||||
|
||||
def test_empty_password_still_works():
|
||||
blob = crypto.encrypt(b"secret", "")
|
||||
assert crypto.decrypt(blob, "") == b"secret"
|
||||
|
||||
|
||||
def test_same_data_gives_different_bytes():
|
||||
"""Соль и nonce случайны, поэтому шифртекст не детерминирован.
|
||||
|
||||
Это причина, по которой storage сравнивает хеш открытого JSON, а не байты
|
||||
файла — иначе git получал бы новый коммит на каждое сохранение.
|
||||
"""
|
||||
a = crypto.encrypt(b"same", "pw")
|
||||
b = crypto.encrypt(b"same", "pw")
|
||||
assert a != b
|
||||
assert crypto.decrypt(a, "pw") == crypto.decrypt(b, "pw")
|
||||
|
||||
|
||||
def test_header_is_authenticated():
|
||||
"""Подмена соли в заголовке ловится как ошибка, а не молча меняет ключ."""
|
||||
blob = bytearray(crypto.encrypt(b"secret", "pw"))
|
||||
blob[9] ^= 0xFF # первый байт соли
|
||||
with pytest.raises(crypto.WrongPassword):
|
||||
crypto.decrypt(bytes(blob), "pw")
|
||||
|
||||
|
||||
def test_tampered_ciphertext_rejected():
|
||||
blob = bytearray(crypto.encrypt(b"secret", "pw"))
|
||||
blob[-1] ^= 0xFF
|
||||
with pytest.raises(crypto.WrongPassword):
|
||||
crypto.decrypt(bytes(blob), "pw")
|
||||
|
||||
|
||||
def test_not_a_vault_file():
|
||||
with pytest.raises(crypto.UnsupportedFormat):
|
||||
crypto.decrypt(b"\x00" * 100, "pw")
|
||||
|
||||
|
||||
def test_truncated_file():
|
||||
with pytest.raises(crypto.UnsupportedFormat):
|
||||
crypto.decrypt(b"FMDB\x01", "pw")
|
||||
|
||||
|
||||
def test_future_format_version_reports_clearly():
|
||||
blob = bytearray(crypto.encrypt(b"secret", "pw"))
|
||||
blob[4] = 99
|
||||
with pytest.raises(crypto.UnsupportedFormat, match="новой версией"):
|
||||
crypto.decrypt(bytes(blob), "pw")
|
||||
|
||||
|
||||
def test_absurd_scrypt_params_rejected_without_allocating():
|
||||
"""Чужой файл не должен заставить нас выделить гигабайты под scrypt."""
|
||||
blob = bytearray(crypto.encrypt(b"secret", "pw"))
|
||||
blob[6] = 40 # log2(n) = 40
|
||||
with pytest.raises(crypto.UnsupportedFormat):
|
||||
crypto.decrypt(bytes(blob), "pw")
|
||||
|
||||
|
||||
def test_compression_actually_helps():
|
||||
data = b'{"bun": "poppy"}' * 500
|
||||
blob = crypto.encrypt(data, "pw")
|
||||
assert len(blob) < len(data) // 4
|
||||
282
tests/test_gitsync.py
Normal file
282
tests/test_gitsync.py
Normal file
@ -0,0 +1,282 @@
|
||||
"""Тесты синхронизации против локального bare-репозитория — без сети."""
|
||||
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from app import gitsync
|
||||
from app.gitsync import Diverged, GitSync, auth_url, commit_message, scrub
|
||||
from app.models import GitSettings
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
gitsync.git_executable() is None, reason="git не установлен"
|
||||
)
|
||||
|
||||
REL = "data/vault.fmdb"
|
||||
|
||||
|
||||
def git(cwd, *args):
|
||||
proc = subprocess.run(
|
||||
["git", *args], cwd=cwd, capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def remote(tmp_path):
|
||||
path = tmp_path / "remote.git"
|
||||
path.mkdir()
|
||||
git(path, "init", "--bare", "-b", "main")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sync(tmp_path, remote):
|
||||
work = tmp_path / "work"
|
||||
(work / "data").mkdir(parents=True)
|
||||
(work / "data" / "vault.fmdb").write_bytes(b"encrypted-v1")
|
||||
settings = GitSettings(remote_url=remote.as_posix(), branch="main")
|
||||
return GitSync(work, REL, settings)
|
||||
|
||||
|
||||
def write_vault(sync, content: bytes):
|
||||
(sync.repo_dir / REL).write_bytes(content)
|
||||
|
||||
|
||||
def remote_log(remote):
|
||||
return git(remote, "log", "--format=%H %s").splitlines()
|
||||
|
||||
|
||||
def remote_vault(remote):
|
||||
proc = subprocess.run(
|
||||
["git", "show", f"main:{REL}"], cwd=remote, capture_output=True
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return proc.stdout
|
||||
|
||||
|
||||
# --- базовый цикл ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_first_sync_creates_repo_and_pushes(sync, remote):
|
||||
result = sync.sync()
|
||||
assert result.committed and result.pushed
|
||||
assert remote_vault(remote) == b"encrypted-v1"
|
||||
assert len(remote_log(remote)) == 1
|
||||
|
||||
|
||||
def test_sync_without_changes_does_nothing(sync, remote):
|
||||
sync.sync()
|
||||
result = sync.sync()
|
||||
assert result.committed is False
|
||||
assert result.pushed is False
|
||||
assert len(remote_log(remote)) == 1
|
||||
|
||||
|
||||
def test_commit_touches_only_the_vault(sync, remote):
|
||||
"""Незакоммиченные правки исходников не должны уезжать вместе с данными."""
|
||||
(sync.repo_dir / "app.py").write_text("print('работа в процессе')", encoding="utf-8")
|
||||
(sync.repo_dir / "notes.txt").write_text("черновик", encoding="utf-8")
|
||||
|
||||
sync.sync()
|
||||
files = git(remote, "show", "--name-only", "--format=", "main").split()
|
||||
assert files == [REL]
|
||||
|
||||
|
||||
def test_commit_message_leaks_no_business_numbers(sync, remote):
|
||||
sync.sync()
|
||||
subject = remote_log(remote)[0].split(" ", 1)[1]
|
||||
assert subject == commit_message()
|
||||
assert subject.startswith("vault ")
|
||||
|
||||
|
||||
# --- схлопывание коммитов по дням -----------------------------------------
|
||||
|
||||
|
||||
def test_same_day_syncs_collapse_into_one_commit(sync, remote):
|
||||
"""Ради этого всё и затевалось: не 24 копии файла в сутки, а одна."""
|
||||
sync.sync()
|
||||
for i in range(2, 5):
|
||||
write_vault(sync, f"encrypted-v{i}".encode())
|
||||
result = sync.sync()
|
||||
assert result.amended is True
|
||||
|
||||
assert len(remote_log(remote)) == 1
|
||||
assert remote_vault(remote) == b"encrypted-v4"
|
||||
|
||||
|
||||
def test_squash_can_be_turned_off(sync, remote):
|
||||
sync.settings.daily_squash = False
|
||||
sync.sync()
|
||||
write_vault(sync, b"encrypted-v2")
|
||||
result = sync.sync()
|
||||
|
||||
assert result.amended is False
|
||||
assert len(remote_log(remote)) == 2
|
||||
|
||||
|
||||
def test_no_amend_when_head_is_not_on_the_server(sync):
|
||||
"""Переписывать можно только ту вершину, которая точно наша и уже запушена."""
|
||||
sync.ensure_repo()
|
||||
sync._run(["add", "--", REL])
|
||||
sync._run(["commit", "-m", commit_message(), "--", REL])
|
||||
|
||||
assert sync._can_amend(remote=None) is False
|
||||
assert sync._can_amend(remote="0" * 40) is False
|
||||
|
||||
|
||||
def test_no_amend_over_a_foreign_commit(sync):
|
||||
"""Чужой коммит не наш формат — трогать его нельзя."""
|
||||
sync.ensure_repo()
|
||||
(sync.repo_dir / "readme.md").write_text("привет", encoding="utf-8")
|
||||
sync._run(["add", "-A"])
|
||||
sync._run(["commit", "-m", "правки руками"])
|
||||
|
||||
assert sync._can_amend(remote=sync.head_sha()) is False
|
||||
|
||||
|
||||
def test_no_amend_over_a_commit_touching_other_files(sync):
|
||||
"""Amend утащил бы чужие изменения из той же вершины."""
|
||||
sync.ensure_repo()
|
||||
(sync.repo_dir / "readme.md").write_text("привет", encoding="utf-8")
|
||||
sync._run(["add", "-A"])
|
||||
sync._run(["commit", "-m", commit_message()])
|
||||
|
||||
assert sync._can_amend(remote=sync.head_sha()) is False
|
||||
|
||||
|
||||
def test_yesterdays_commit_is_not_amended(sync, remote):
|
||||
sync.sync()
|
||||
sync._run(["commit", "--amend", "-m", "vault 2020-01-01"])
|
||||
assert sync._can_amend(remote=sync.head_sha()) is False
|
||||
|
||||
|
||||
# --- защита от затирания чужой работы -------------------------------------
|
||||
|
||||
|
||||
def test_stale_lease_is_refused_and_remote_survives(sync, remote, tmp_path):
|
||||
"""Ключевая проверка безопасности схлопывания.
|
||||
|
||||
Между нашим fetch и push другая машина успела запушить своё. Мы уже
|
||||
переписали локальную вершину — force-with-lease обязан это поймать.
|
||||
"""
|
||||
sync.sync()
|
||||
stale = sync.head_sha()
|
||||
|
||||
other = tmp_path / "other"
|
||||
git(tmp_path, "clone", remote.as_posix(), "other")
|
||||
(other / "data" / "vault.fmdb").write_bytes(b"encrypted-from-other-machine")
|
||||
git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01")
|
||||
git(other, "push", "origin", "main")
|
||||
theirs = git(other, "rev-parse", "HEAD")
|
||||
|
||||
write_vault(sync, b"encrypted-ours")
|
||||
committed, amended, lease = sync._commit(remote=stale)
|
||||
assert (committed, amended, lease) == (True, True, stale)
|
||||
|
||||
with pytest.raises(Diverged):
|
||||
sync._push(amended=True, lease=lease)
|
||||
|
||||
# Чужой коммит на месте, наш форс его не снёс.
|
||||
assert git(remote, "rev-parse", "main") == theirs
|
||||
assert remote_vault(remote) == b"encrypted-from-other-machine"
|
||||
|
||||
|
||||
def test_remote_ahead_and_clean_fast_forwards(sync, remote, tmp_path):
|
||||
sync.sync()
|
||||
|
||||
other = tmp_path / "other"
|
||||
git(tmp_path, "clone", remote.as_posix(), "other")
|
||||
(other / "data" / "vault.fmdb").write_bytes(b"encrypted-from-other-machine")
|
||||
git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01")
|
||||
git(other, "push", "origin", "main")
|
||||
|
||||
result = sync.sync()
|
||||
assert result.pulled is True
|
||||
assert (sync.repo_dir / REL).read_bytes() == b"encrypted-from-other-machine"
|
||||
|
||||
|
||||
def test_edits_on_both_machines_raise_and_keep_both_copies(sync, remote, tmp_path):
|
||||
"""Ничего не сливаем и ничего не теряем — решение за пользователем."""
|
||||
sync.sync()
|
||||
|
||||
other = tmp_path / "other"
|
||||
git(tmp_path, "clone", remote.as_posix(), "other")
|
||||
(other / "data" / "vault.fmdb").write_bytes(b"encrypted-from-other-machine")
|
||||
git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01")
|
||||
git(other, "push", "origin", "main")
|
||||
|
||||
write_vault(sync, b"encrypted-ours")
|
||||
copy = sync.repo_dir / "data" / "vault.remote.fmdb"
|
||||
|
||||
with pytest.raises(Diverged):
|
||||
sync.sync(remote_copy_path=copy)
|
||||
|
||||
assert (sync.repo_dir / REL).read_bytes() == b"encrypted-ours"
|
||||
assert copy.read_bytes() == b"encrypted-from-other-machine"
|
||||
|
||||
|
||||
# --- обращение с токеном --------------------------------------------------
|
||||
|
||||
|
||||
def test_auth_url_injects_token():
|
||||
url = auth_url("https://gt.ser.gay/kizya/food-market.git", "glpat-secret")
|
||||
assert url == "https://oauth2:glpat-secret@gt.ser.gay/kizya/food-market.git"
|
||||
|
||||
|
||||
def test_auth_url_replaces_existing_credentials():
|
||||
url = auth_url("https://old:creds@gt.ser.gay/kizya/food-market.git", "glpat-new")
|
||||
assert url == "https://oauth2:glpat-new@gt.ser.gay/kizya/food-market.git"
|
||||
|
||||
|
||||
def test_auth_url_escapes_special_characters():
|
||||
url = auth_url("https://gt.ser.gay/x.git", "a/b@c:d")
|
||||
assert "a%2Fb%40c%3Ad" in url
|
||||
assert url.count("@") == 1
|
||||
|
||||
|
||||
def test_auth_url_left_alone_for_local_paths():
|
||||
assert auth_url("/srv/repo.git", "glpat-secret") == "/srv/repo.git"
|
||||
|
||||
|
||||
def test_token_is_not_written_into_git_config(sync):
|
||||
sync.settings.token = "glpat-secret"
|
||||
sync.ensure_repo()
|
||||
config = (sync.repo_dir / ".git" / "config").read_text(encoding="utf-8")
|
||||
assert "glpat-secret" not in config
|
||||
|
||||
|
||||
def test_scrub_hides_token_in_messages():
|
||||
assert scrub("fatal: https://oauth2:glpat-x@host", "glpat-x") == "fatal: https://oauth2:***@host"
|
||||
|
||||
|
||||
def test_errors_do_not_leak_the_token(sync):
|
||||
sync.settings.remote_url = "https://127.0.0.1:1/nope.git"
|
||||
sync.settings.token = "glpat-supersecret"
|
||||
with pytest.raises(gitsync.GitError) as exc:
|
||||
sync.sync()
|
||||
assert "glpat-supersecret" not in str(exc.value)
|
||||
|
||||
|
||||
# --- прочее ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sync_without_remote_is_a_clear_error(sync):
|
||||
sync.settings.remote_url = ""
|
||||
with pytest.raises(gitsync.GitError, match="адрес репозитория"):
|
||||
sync.sync()
|
||||
|
||||
|
||||
def test_ensure_repo_is_idempotent(sync):
|
||||
sync.ensure_repo()
|
||||
sync.ensure_repo()
|
||||
assert sync.is_repo()
|
||||
assert git(sync.repo_dir, "remote", "get-url", "origin")
|
||||
|
||||
|
||||
def test_ensure_repo_updates_a_changed_remote(sync, tmp_path):
|
||||
sync.ensure_repo()
|
||||
sync.settings.remote_url = (tmp_path / "elsewhere.git").as_posix()
|
||||
sync.ensure_repo()
|
||||
assert git(sync.repo_dir, "remote", "get-url", "origin").endswith("elsewhere.git")
|
||||
267
tests/test_journal.py
Normal file
267
tests/test_journal.py
Normal file
@ -0,0 +1,267 @@
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app import journal
|
||||
from app.models import KIND_FRIEND, KIND_RETAIL, KIND_SELF
|
||||
|
||||
|
||||
def actions(vault):
|
||||
return [e.action for e in vault.doc.journal]
|
||||
|
||||
|
||||
def last(vault):
|
||||
return vault.doc.journal[-1]
|
||||
|
||||
|
||||
def changed(entry):
|
||||
return {c.field: (c.old, c.new) for c in entry.changes}
|
||||
|
||||
|
||||
# --- запись вообще происходит ---------------------------------------------
|
||||
|
||||
|
||||
def test_create_product_is_logged(vault):
|
||||
journal.create_product(vault, "Булка с маком", cost_price=20, retail_price=35)
|
||||
entry = last(vault)
|
||||
assert entry.action == "product.create"
|
||||
assert entry.label == "Булка с маком"
|
||||
assert changed(entry)["себестоимость"][1] == "20,00 ₽"
|
||||
|
||||
|
||||
def test_price_change_records_old_and_new(vault, buns):
|
||||
journal.change_prices(vault, buns["мак"].id, cost_price=25, retail_price=40)
|
||||
entry = last(vault)
|
||||
assert entry.action == "product.price_change"
|
||||
assert changed(entry)["себестоимость"] == ("20,00 ₽", "25,00 ₽")
|
||||
assert changed(entry)["цена продажи"] == ("35,00 ₽", "40,00 ₽")
|
||||
|
||||
|
||||
def test_price_change_appends_to_history(vault, buns):
|
||||
product = buns["мак"]
|
||||
assert len(product.price_history) == 1 # точка при создании
|
||||
journal.change_prices(vault, product.id, cost_price=25, retail_price=40)
|
||||
assert len(product.price_history) == 2
|
||||
assert product.price_history[-1].cost_price == 25
|
||||
|
||||
|
||||
def test_every_operation_touches_the_vault(vault, buns, today, in_two_weeks):
|
||||
"""revision растёт на каждой мутации — на него опирается автосохранение."""
|
||||
before = vault.revision
|
||||
journal.create_batch(
|
||||
vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}]
|
||||
)
|
||||
assert vault.revision > before
|
||||
|
||||
|
||||
def test_journal_covers_the_full_lifecycle(vault, buns, today, in_two_weeks):
|
||||
batch = journal.create_batch(
|
||||
vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}]
|
||||
)
|
||||
sale = journal.create_sale(
|
||||
vault, today, KIND_RETAIL, [{"product_id": buns["мак"].id, "qty": 2, "unit_price": 35}],
|
||||
paid_amount=0,
|
||||
)
|
||||
journal.add_sale_payment(vault, sale.id, 70)
|
||||
journal.add_bakery_payment(vault, 200, batch_id=batch.id)
|
||||
journal.delete_sale(vault, sale.id)
|
||||
|
||||
assert actions(vault)[-5:] == [
|
||||
"batch.create",
|
||||
"sale.create",
|
||||
"sale.payment_add",
|
||||
"bakery_payment.add",
|
||||
"sale.delete",
|
||||
]
|
||||
|
||||
|
||||
# --- пустые правки не создают шума ----------------------------------------
|
||||
|
||||
|
||||
def test_noop_update_writes_nothing(vault, buns):
|
||||
count = len(vault.doc.journal)
|
||||
journal.update_product(vault, buns["мак"].id, name="Булка с маком")
|
||||
assert len(vault.doc.journal) == count
|
||||
|
||||
|
||||
def test_noop_price_change_writes_nothing(vault, buns):
|
||||
count = len(vault.doc.journal)
|
||||
journal.change_prices(vault, buns["мак"].id, cost_price=20, retail_price=35)
|
||||
assert len(vault.doc.journal) == count
|
||||
assert len(buns["мак"].price_history) == 1
|
||||
|
||||
|
||||
def test_noop_edit_leaves_vault_clean(vault, buns):
|
||||
"""Иначе открыл-закрыл форму — и в git уехал пустой коммит."""
|
||||
vault.save()
|
||||
journal.update_product(vault, buns["мак"].id, note="")
|
||||
assert vault.is_dirty is False
|
||||
|
||||
|
||||
# --- секреты не утекают в журнал ------------------------------------------
|
||||
|
||||
|
||||
def test_token_value_never_reaches_the_journal(vault):
|
||||
secret = "glpat-СУПЕРСЕКРЕТ12345"
|
||||
journal.update_settings(vault, git={"token": secret})
|
||||
|
||||
entry = last(vault)
|
||||
assert entry.action == "settings.update"
|
||||
assert changed(entry)["git.token"] == ("скрыто", "изменён")
|
||||
|
||||
# Токен обязан быть в настройках, но нигде в журнале — включая открытый экспорт.
|
||||
assert vault.doc.settings.git.token == secret
|
||||
dumped = json.dumps([e.to_dict() for e in vault.doc.journal], ensure_ascii=False)
|
||||
assert secret not in dumped
|
||||
|
||||
|
||||
def test_non_secret_git_settings_are_logged_plainly(vault):
|
||||
journal.update_settings(vault, git={"remote_url": "https://gt.ser.gay/kizya/food-market.git"})
|
||||
assert "gt.ser.gay" in changed(last(vault))["git.remote_url"][1]
|
||||
|
||||
|
||||
def test_settings_keep_types(vault):
|
||||
journal.update_settings(vault, git={"auto_push_minutes": "30", "enabled": False})
|
||||
assert vault.doc.settings.git.auto_push_minutes == 30
|
||||
assert vault.doc.settings.git.enabled is False
|
||||
|
||||
|
||||
# --- проверки корректности ------------------------------------------------
|
||||
|
||||
|
||||
def test_product_in_use_cannot_be_deleted(vault, buns, today, in_two_weeks):
|
||||
journal.create_batch(
|
||||
vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}]
|
||||
)
|
||||
with pytest.raises(journal.ValidationError, match="архив"):
|
||||
journal.delete_product(vault, buns["мак"].id)
|
||||
|
||||
|
||||
def test_unused_product_can_be_deleted(vault, buns):
|
||||
journal.delete_product(vault, buns["мак"].id)
|
||||
assert vault.doc.product(buns["мак"].id) is None
|
||||
assert last(vault).action == "product.delete"
|
||||
|
||||
|
||||
def test_batch_due_date_must_not_precede_purchase(vault, buns, today):
|
||||
with pytest.raises(journal.ValidationError, match="раньше"):
|
||||
journal.create_batch(
|
||||
vault,
|
||||
today,
|
||||
today - timedelta(days=1),
|
||||
[{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}],
|
||||
)
|
||||
|
||||
|
||||
def test_empty_batch_rejected(vault, today, in_two_weeks):
|
||||
with pytest.raises(journal.ValidationError, match="хотя бы одна"):
|
||||
journal.create_batch(vault, today, in_two_weeks, [])
|
||||
|
||||
|
||||
def test_zero_quantity_rejected(vault, buns, today, in_two_weeks):
|
||||
with pytest.raises(journal.ValidationError, match="больше нуля"):
|
||||
journal.create_batch(
|
||||
vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 0, "unit_cost": 20}]
|
||||
)
|
||||
|
||||
|
||||
def test_counterparty_with_sales_cannot_be_deleted(vault, buns, today):
|
||||
vasya = journal.create_counterparty(vault, "Вася")
|
||||
journal.create_sale(
|
||||
vault, today, KIND_FRIEND,
|
||||
[{"product_id": buns["мак"].id, "qty": 1, "unit_price": 20}],
|
||||
counterparty_id=vasya.id,
|
||||
)
|
||||
with pytest.raises(journal.ValidationError, match="удалить нельзя"):
|
||||
journal.delete_counterparty(vault, vasya.id)
|
||||
|
||||
|
||||
def test_batch_numbers_increment(vault, buns, today, in_two_weeks):
|
||||
lines = [{"product_id": buns["мак"].id, "qty": 1, "unit_cost": 20}]
|
||||
first = journal.create_batch(vault, today, in_two_weeks, lines)
|
||||
second = journal.create_batch(vault, today, in_two_weeks, lines)
|
||||
assert (first.number, second.number) == (1, 2)
|
||||
|
||||
|
||||
# --- поведение вокруг оплат -----------------------------------------------
|
||||
|
||||
|
||||
def test_sale_is_paid_in_full_by_default(vault, buns, today):
|
||||
sale = journal.create_sale(
|
||||
vault, today, KIND_RETAIL, [{"product_id": buns["мак"].id, "qty": 2, "unit_price": 35}]
|
||||
)
|
||||
assert sale.paid == sale.total == 70
|
||||
assert sale.debt == 0
|
||||
|
||||
|
||||
def test_sale_on_credit_creates_debt(vault, buns, today):
|
||||
sale = journal.create_sale(
|
||||
vault, today, KIND_FRIEND, [{"product_id": buns["мак"].id, "qty": 5, "unit_price": 20}],
|
||||
paid_amount=0,
|
||||
)
|
||||
assert sale.paid == 0
|
||||
assert sale.debt == 100
|
||||
|
||||
journal.add_sale_payment(vault, sale.id, 60)
|
||||
assert sale.debt == 40
|
||||
|
||||
|
||||
def test_consumption_never_records_payment(vault, buns, today):
|
||||
"""Съел сам — денег нет по определению, даже если форма что-то прислала."""
|
||||
sale = journal.create_sale(
|
||||
vault, today, KIND_SELF, [{"product_id": buns["мак"].id, "qty": 2, "unit_price": 0}],
|
||||
paid_amount=999,
|
||||
)
|
||||
assert sale.payments == []
|
||||
assert sale.debt == 0
|
||||
|
||||
|
||||
def test_overpayment_does_not_create_negative_debt(vault, buns, today):
|
||||
sale = journal.create_sale(
|
||||
vault, today, KIND_RETAIL, [{"product_id": buns["мак"].id, "qty": 1, "unit_price": 35}],
|
||||
paid_amount=0,
|
||||
)
|
||||
journal.add_sale_payment(vault, sale.id, 100)
|
||||
assert sale.debt == 0
|
||||
|
||||
|
||||
# --- удаление партии не теряет деньги -------------------------------------
|
||||
|
||||
|
||||
def test_deleting_batch_keeps_bakery_payments(vault, buns, today, in_two_weeks):
|
||||
"""Деньги пекарне реально ушли — они не должны исчезнуть вместе с партией."""
|
||||
batch = journal.create_batch(
|
||||
vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}]
|
||||
)
|
||||
payment = journal.add_bakery_payment(vault, 200, batch_id=batch.id)
|
||||
|
||||
journal.delete_batch(vault, batch.id)
|
||||
assert payment in vault.doc.bakery_payments
|
||||
assert payment.batch_id is None
|
||||
|
||||
|
||||
# --- обслуживание ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_prune_journal(vault, buns):
|
||||
for entry in vault.doc.journal:
|
||||
entry.ts = "2020-01-01T00:00:00+03:00"
|
||||
removed = journal.prune_journal(vault, date(2024, 1, 1))
|
||||
assert removed == 3
|
||||
assert [e.action for e in vault.doc.journal] == ["journal.prune"]
|
||||
|
||||
|
||||
def test_prune_nothing_to_remove_is_silent(vault, buns):
|
||||
count = len(vault.doc.journal)
|
||||
assert journal.prune_journal(vault, date(2000, 1, 1)) == 0
|
||||
assert len(vault.doc.journal) == count
|
||||
|
||||
|
||||
def test_journal_entries_survive_save_and_reload(vault, buns):
|
||||
from app.storage import Vault
|
||||
|
||||
vault.save()
|
||||
reopened = Vault.open(vault.path, "pw")
|
||||
assert [e.action for e in reopened.doc.journal] == ["product.create"] * 3
|
||||
assert reopened.doc.journal[0].host
|
||||
314
tests/test_ledger.py
Normal file
314
tests/test_ledger.py
Normal file
@ -0,0 +1,314 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app import journal, ledger
|
||||
from app.models import KIND_FRIEND, KIND_GIFT, KIND_RETAIL, KIND_SELF
|
||||
|
||||
|
||||
def line(product, qty, price):
|
||||
return {"product_id": product.id, "qty": qty, "unit_price": price}
|
||||
|
||||
|
||||
def buy(product, qty, cost):
|
||||
return {"product_id": product.id, "qty": qty, "unit_cost": cost}
|
||||
|
||||
|
||||
# --- Сценарий из постановки задачи ----------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scenario(vault, buns, today, in_two_weeks):
|
||||
"""Ровно то, что описал пользователь.
|
||||
|
||||
Пришёл в пекарню, взял 5 булок с повидлом, 10 с маком, 20 с корицей.
|
||||
Часть продал, часть отдал другу в долг по себестоимости, пару съел сам.
|
||||
"""
|
||||
batch = journal.create_batch(
|
||||
vault,
|
||||
today,
|
||||
in_two_weeks,
|
||||
[buy(buns["повидло"], 5, 30), buy(buns["мак"], 10, 20), buy(buns["корица"], 20, 25)],
|
||||
)
|
||||
vasya = journal.create_counterparty(vault, "Вася")
|
||||
|
||||
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["повидло"], 3, 50)])
|
||||
journal.create_sale(
|
||||
vault, today, KIND_FRIEND, [line(buns["мак"], 5, 20)],
|
||||
counterparty_id=vasya.id, paid_amount=0,
|
||||
)
|
||||
journal.create_sale(vault, today, KIND_SELF, [line(buns["корица"], 2, 0)])
|
||||
|
||||
return {"vault": vault, "batch": batch, "vasya": vasya, "buns": buns}
|
||||
|
||||
|
||||
def test_batch_debt_is_the_purchase_cost(scenario, today):
|
||||
report = ledger.build(scenario["vault"].doc, today)
|
||||
batch = report.batch_report(scenario["batch"].id)
|
||||
# 5×30 + 10×20 + 20×25
|
||||
assert batch.cost_total == Decimal("850.00")
|
||||
assert batch.remaining_to_bakery == Decimal("850.00")
|
||||
|
||||
|
||||
def test_only_actually_received_money_counts_as_coverage(scenario, today):
|
||||
"""Долг Васи не покрывает партию: пекарне нужны деньги, а не начисления."""
|
||||
report = ledger.build(scenario["vault"].doc, today)
|
||||
batch = report.batch_report(scenario["batch"].id)
|
||||
|
||||
assert batch.revenue == Decimal("250.00") # 150 продажа + 100 долг Васи
|
||||
assert batch.cash_collected == Decimal("150.00")
|
||||
assert batch.still_to_collect == Decimal("700.00")
|
||||
assert batch.coverage_pct == Decimal("17.65")
|
||||
|
||||
|
||||
def test_self_consumed_buns_are_a_loss_against_margin(scenario, today):
|
||||
summary = ledger.build(scenario["vault"].doc, today).summary
|
||||
assert summary.gross_margin == Decimal("60.00") # 3 × (50 − 30)
|
||||
assert summary.consumed_cost == Decimal("50.00") # 2 корицы × 25
|
||||
assert summary.profit == Decimal("10.00")
|
||||
|
||||
|
||||
def test_stock_and_receivables(scenario, today):
|
||||
report = ledger.build(scenario["vault"].doc, today)
|
||||
batch = report.batch_report(scenario["batch"].id)
|
||||
|
||||
assert batch.qty_sold == Decimal("10.000")
|
||||
assert batch.qty_left == Decimal("25.000")
|
||||
# 2×30 + 5×20 + 18×25
|
||||
assert report.summary.stock_cost == Decimal("610.00")
|
||||
assert report.summary.receivable == Decimal("100.00")
|
||||
assert [(d.name, d.debt) for d in report.debtors] == [("Вася", Decimal("100.00"))]
|
||||
|
||||
|
||||
def test_paying_the_bakery_closes_the_batch(scenario, today):
|
||||
vault = scenario["vault"]
|
||||
journal.add_bakery_payment(vault, 850, batch_id=scenario["batch"].id)
|
||||
|
||||
batch = ledger.build(vault.doc, today).batch_report(scenario["batch"].id)
|
||||
assert batch.remaining_to_bakery == 0
|
||||
assert batch.status == ledger.STATUS_SETTLED
|
||||
assert ledger.build(vault.doc, today).summary.owed_to_bakery == 0
|
||||
|
||||
|
||||
# --- FIFO по партиям ------------------------------------------------------
|
||||
|
||||
|
||||
def test_sale_spills_from_the_older_batch_into_the_newer(vault, buns, today):
|
||||
"""Привязка выручки к партии — единственная причина считать FIFO."""
|
||||
old = journal.create_batch(
|
||||
vault, today - timedelta(days=10), today + timedelta(days=4),
|
||||
[buy(buns["мак"], 10, 20)],
|
||||
)
|
||||
new = journal.create_batch(
|
||||
vault, today - timedelta(days=1), today + timedelta(days=13),
|
||||
[buy(buns["мак"], 10, 20)],
|
||||
)
|
||||
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 14, 35)])
|
||||
|
||||
report = ledger.build(vault.doc, today)
|
||||
assert report.batch_report(old.id).qty_sold == Decimal("10.000")
|
||||
assert report.batch_report(new.id).qty_sold == Decimal("4.000")
|
||||
assert report.batch_report(old.id).cash_collected == Decimal("350.00") # 10 × 35
|
||||
assert report.batch_report(new.id).cash_collected == Decimal("140.00") # 4 × 35
|
||||
|
||||
|
||||
def test_batch_order_follows_date_not_entry_order(vault, buns, today):
|
||||
later = journal.create_batch(
|
||||
vault, today, today + timedelta(days=14), [buy(buns["мак"], 5, 20)]
|
||||
)
|
||||
earlier = journal.create_batch(
|
||||
vault, today - timedelta(days=5), today + timedelta(days=9), [buy(buns["мак"], 5, 20)]
|
||||
)
|
||||
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 5, 35)])
|
||||
|
||||
report = ledger.build(vault.doc, today)
|
||||
assert report.batch_report(earlier.id).qty_sold == Decimal("5.000")
|
||||
assert report.batch_report(later.id).qty_sold == 0
|
||||
|
||||
|
||||
def test_allocation_is_deterministic(vault, buns, today, in_two_weeks):
|
||||
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
|
||||
for _ in range(3):
|
||||
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)])
|
||||
|
||||
first = ledger.build(vault.doc, today).allocations
|
||||
second = ledger.build(vault.doc, today).allocations
|
||||
assert [(a.sale_id, a.batch_id, a.qty) for a in first] == [
|
||||
(a.sale_id, a.batch_id, a.qty) for a in second
|
||||
]
|
||||
|
||||
|
||||
def test_selling_more_than_bought_warns_instead_of_crashing(vault, buns, today, in_two_weeks):
|
||||
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 5, 20)])
|
||||
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 8, 35)])
|
||||
|
||||
report = ledger.build(vault.doc, today)
|
||||
assert [(s.product_id, s.qty) for s in report.shortfalls] == [
|
||||
(buns["мак"].id, Decimal("3.000"))
|
||||
]
|
||||
assert any("больше" in w for w in report.warnings)
|
||||
|
||||
|
||||
# --- Частичная оплата -----------------------------------------------------
|
||||
|
||||
|
||||
def test_partial_payment_counts_proportionally(vault, buns, today, in_two_weeks):
|
||||
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
|
||||
sale = journal.create_sale(
|
||||
vault, today, KIND_RETAIL, [line(buns["мак"], 10, 35)], paid_amount=0
|
||||
)
|
||||
journal.add_sale_payment(vault, sale.id, 140) # 40% от 350
|
||||
|
||||
report = ledger.build(vault.doc, today)
|
||||
assert report.batches[0].revenue == Decimal("350.00")
|
||||
assert report.batches[0].cash_collected == Decimal("140.00")
|
||||
assert report.summary.receivable == Decimal("210.00")
|
||||
|
||||
|
||||
def test_paying_off_the_debt_completes_coverage(vault, buns, today, in_two_weeks):
|
||||
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
|
||||
sale = journal.create_sale(
|
||||
vault, today, KIND_RETAIL, [line(buns["мак"], 10, 35)], paid_amount=0
|
||||
)
|
||||
journal.add_sale_payment(vault, sale.id, 350)
|
||||
|
||||
report = ledger.build(vault.doc, today)
|
||||
assert report.batches[0].cash_collected == Decimal("350.00")
|
||||
assert report.summary.receivable == 0
|
||||
assert report.debtors == []
|
||||
|
||||
|
||||
# --- Платежи пекарне ------------------------------------------------------
|
||||
|
||||
|
||||
def test_unassigned_payment_pays_the_oldest_batch_first(vault, buns, today):
|
||||
old = journal.create_batch(
|
||||
vault, today - timedelta(days=10), today + timedelta(days=4), [buy(buns["мак"], 10, 20)]
|
||||
)
|
||||
new = journal.create_batch(
|
||||
vault, today, today + timedelta(days=14), [buy(buns["мак"], 10, 20)]
|
||||
)
|
||||
journal.add_bakery_payment(vault, 250) # 200 закрывают старую, 50 идут в новую
|
||||
|
||||
report = ledger.build(vault.doc, today)
|
||||
assert report.batch_report(old.id).remaining_to_bakery == 0
|
||||
assert report.batch_report(new.id).remaining_to_bakery == Decimal("150.00")
|
||||
|
||||
|
||||
def test_overpaying_one_batch_spills_over_instead_of_going_negative(vault, buns, today):
|
||||
first = journal.create_batch(
|
||||
vault, today - timedelta(days=5), today + timedelta(days=9), [buy(buns["мак"], 5, 20)]
|
||||
)
|
||||
second = journal.create_batch(
|
||||
vault, today, today + timedelta(days=14), [buy(buns["мак"], 5, 20)]
|
||||
)
|
||||
journal.add_bakery_payment(vault, 180, batch_id=first.id) # партия стоит всего 100
|
||||
|
||||
report = ledger.build(vault.doc, today)
|
||||
assert report.batch_report(first.id).remaining_to_bakery == 0
|
||||
assert report.batch_report(second.id).remaining_to_bakery == Decimal("20.00")
|
||||
|
||||
|
||||
def test_paying_more_than_everything_is_reported_as_advance(vault, buns, today, in_two_weeks):
|
||||
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 5, 20)])
|
||||
journal.add_bakery_payment(vault, 300)
|
||||
|
||||
report = ledger.build(vault.doc, today)
|
||||
assert report.unassigned_bakery_payment == Decimal("200.00")
|
||||
assert report.summary.owed_to_bakery == 0
|
||||
assert any("аванс" in w for w in report.warnings)
|
||||
|
||||
|
||||
# --- Статусы и дедлайны ---------------------------------------------------
|
||||
|
||||
|
||||
def test_status_open(vault, buns, today):
|
||||
journal.create_batch(vault, today, today + timedelta(days=14), [buy(buns["мак"], 5, 20)])
|
||||
assert ledger.build(vault.doc, today).batches[0].status == ledger.STATUS_OPEN
|
||||
|
||||
|
||||
def test_status_due_soon(vault, buns, today):
|
||||
journal.create_batch(vault, today, today + timedelta(days=2), [buy(buns["мак"], 5, 20)])
|
||||
report = ledger.build(vault.doc, today).batches[0]
|
||||
assert report.status == ledger.STATUS_DUE_SOON
|
||||
assert report.days_left == 2
|
||||
|
||||
|
||||
def test_status_overdue(vault, buns, today):
|
||||
journal.create_batch(
|
||||
vault, today - timedelta(days=20), today - timedelta(days=1), [buy(buns["мак"], 5, 20)]
|
||||
)
|
||||
report = ledger.build(vault.doc, today)
|
||||
assert report.batches[0].status == ledger.STATUS_OVERDUE
|
||||
assert report.summary.overdue_count == 1
|
||||
|
||||
|
||||
def test_manually_closed_batch_is_settled_even_if_unpaid(vault, buns, today, in_two_weeks):
|
||||
"""Иногда с пекарней договариваются на словах — учёт не должен спорить."""
|
||||
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 5, 20)])
|
||||
journal.set_batch_closed(vault, batch.id, True)
|
||||
assert ledger.build(vault.doc, today).batches[0].status == ledger.STATUS_SETTLED
|
||||
|
||||
|
||||
def test_next_due_picks_the_nearest_open_deadline(vault, buns, today):
|
||||
journal.create_batch(vault, today, today + timedelta(days=14), [buy(buns["мак"], 5, 20)])
|
||||
urgent = journal.create_batch(vault, today, today + timedelta(days=2), [buy(buns["мак"], 5, 20)])
|
||||
|
||||
summary = ledger.build(vault.doc, today).summary
|
||||
assert summary.next_due.batch.id == urgent.id
|
||||
|
||||
|
||||
def test_settled_batches_are_not_offered_as_next_due(vault, buns, today):
|
||||
urgent = journal.create_batch(vault, today, today + timedelta(days=1), [buy(buns["мак"], 5, 20)])
|
||||
later = journal.create_batch(vault, today, today + timedelta(days=20), [buy(buns["мак"], 5, 20)])
|
||||
journal.add_bakery_payment(vault, 100, batch_id=urgent.id)
|
||||
|
||||
summary = ledger.build(vault.doc, today).summary
|
||||
assert summary.next_due.batch.id == later.id
|
||||
|
||||
|
||||
# --- Прочее ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gifts_count_as_consumption_not_revenue(vault, buns, today, in_two_weeks):
|
||||
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
|
||||
journal.create_sale(vault, today, KIND_GIFT, [line(buns["мак"], 3, 0)])
|
||||
|
||||
summary = ledger.build(vault.doc, today).summary
|
||||
assert summary.revenue == 0
|
||||
assert summary.consumed_cost == Decimal("60.00")
|
||||
assert summary.profit == Decimal("-60.00")
|
||||
|
||||
|
||||
def test_friend_price_yields_no_margin(vault, buns, today, in_two_weeks):
|
||||
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
|
||||
journal.create_sale(vault, today, KIND_FRIEND, [line(buns["мак"], 10, 20)])
|
||||
|
||||
summary = ledger.build(vault.doc, today).summary
|
||||
assert summary.cash_collected == Decimal("200.00")
|
||||
assert summary.gross_margin == 0
|
||||
assert summary.profit == 0
|
||||
|
||||
|
||||
def test_debts_without_counterparty_are_grouped_separately(vault, buns, today, in_two_weeks):
|
||||
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
|
||||
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)], paid_amount=0)
|
||||
|
||||
debtors = ledger.build(vault.doc, today).debtors
|
||||
assert [(d.name, d.debt) for d in debtors] == [("Без контрагента", Decimal("70.00"))]
|
||||
|
||||
|
||||
def test_empty_database_reports_zeroes(vault, today):
|
||||
report = ledger.build(vault.doc, today)
|
||||
assert report.summary.owed_to_bakery == 0
|
||||
assert report.summary.profit == 0
|
||||
assert report.summary.next_due is None
|
||||
assert report.warnings == []
|
||||
|
||||
|
||||
def test_stock_lists_products_with_remaining_quantity(scenario, today):
|
||||
rows = {r.name: r.qty for r in ledger.build(scenario["vault"].doc, today).stock}
|
||||
assert rows["Булка с повидлом"] == Decimal("2.000")
|
||||
assert rows["Булка с маком"] == Decimal("5.000")
|
||||
assert rows["Булка с корицей"] == Decimal("18.000")
|
||||
134
tests/test_storage.py
Normal file
134
tests/test_storage.py
Normal file
@ -0,0 +1,134 @@
|
||||
import json
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app import crypto, journal
|
||||
from app.storage import Vault, content_hash, migrate
|
||||
|
||||
|
||||
def test_create_and_reopen(tmp_path):
|
||||
path = tmp_path / "vault.fmdb"
|
||||
vault = Vault.create(path, "pw")
|
||||
journal.create_product(vault, "Булка с маком", cost_price=20, retail_price=35)
|
||||
vault.save()
|
||||
|
||||
reopened = Vault.open(path, "pw")
|
||||
assert [p.name for p in reopened.doc.products] == ["Булка с маком"]
|
||||
assert reopened.doc.products[0].cost_price == Decimal("20.00")
|
||||
|
||||
|
||||
def test_open_with_wrong_password(tmp_path):
|
||||
path = tmp_path / "vault.fmdb"
|
||||
Vault.create(path, "pw")
|
||||
with pytest.raises(crypto.WrongPassword):
|
||||
Vault.open(path, "nope")
|
||||
|
||||
|
||||
def test_create_refuses_to_clobber(tmp_path):
|
||||
path = tmp_path / "vault.fmdb"
|
||||
Vault.create(path, "pw")
|
||||
with pytest.raises(crypto.VaultError):
|
||||
Vault.create(path, "other")
|
||||
|
||||
|
||||
def test_save_skips_write_when_nothing_changed(tmp_path):
|
||||
"""Главная защита от мусорных коммитов в git.
|
||||
|
||||
Шифртекст каждый раз новый, поэтому решение «писать или нет» принимается
|
||||
по хешу открытых данных, а не по байтам файла.
|
||||
"""
|
||||
path = tmp_path / "vault.fmdb"
|
||||
vault = Vault.create(path, "pw")
|
||||
journal.create_product(vault, "Булка", cost_price=10, retail_price=20)
|
||||
|
||||
assert vault.save() is True
|
||||
assert vault.save() is False
|
||||
assert vault.save() is False
|
||||
|
||||
|
||||
def test_save_detects_real_change(tmp_path):
|
||||
vault = Vault.create(tmp_path / "vault.fmdb", "pw")
|
||||
vault.save()
|
||||
journal.create_product(vault, "Булка", cost_price=10, retail_price=20)
|
||||
assert vault.is_dirty is True
|
||||
assert vault.save() is True
|
||||
assert vault.is_dirty is False
|
||||
|
||||
|
||||
def test_freshly_opened_vault_is_not_dirty(tmp_path):
|
||||
"""Иначе каждое открытие программы порождало бы коммит на пустом месте."""
|
||||
path = tmp_path / "vault.fmdb"
|
||||
vault = Vault.create(path, "pw")
|
||||
journal.create_product(vault, "Булка", cost_price=10, retail_price=20)
|
||||
vault.save()
|
||||
|
||||
reopened = Vault.open(path, "pw")
|
||||
assert reopened.is_dirty is False
|
||||
assert reopened.save() is False
|
||||
|
||||
|
||||
def test_content_hash_ignores_key_order():
|
||||
assert content_hash({"a": 1, "b": 2}) == content_hash({"b": 2, "a": 1})
|
||||
|
||||
|
||||
def test_change_password(tmp_path):
|
||||
path = tmp_path / "vault.fmdb"
|
||||
vault = Vault.create(path, "old")
|
||||
journal.create_product(vault, "Булка", cost_price=10, retail_price=20)
|
||||
vault.save()
|
||||
|
||||
vault.change_password("new")
|
||||
with pytest.raises(crypto.WrongPassword):
|
||||
Vault.open(path, "old")
|
||||
assert Vault.open(path, "new").doc.products[0].name == "Булка"
|
||||
|
||||
|
||||
def test_atomic_write_leaves_backup(tmp_path):
|
||||
path = tmp_path / "vault.fmdb"
|
||||
vault = Vault.create(path, "pw")
|
||||
journal.create_product(vault, "Булка", cost_price=10, retail_price=20)
|
||||
vault.save()
|
||||
|
||||
assert path.with_suffix(".fmdb.bak").exists()
|
||||
assert not path.with_suffix(".fmdb.tmp").exists()
|
||||
|
||||
|
||||
def test_decimals_survive_round_trip(tmp_path):
|
||||
"""Деньги ходят через строки, поэтому копейки не должны разъезжаться."""
|
||||
path = tmp_path / "vault.fmdb"
|
||||
vault = Vault.create(path, "pw")
|
||||
journal.create_product(vault, "Булка", cost_price="33.33", retail_price="66.67")
|
||||
vault.save()
|
||||
|
||||
product = Vault.open(path, "pw").doc.products[0]
|
||||
assert product.cost_price == Decimal("33.33")
|
||||
assert product.retail_price == Decimal("66.67")
|
||||
|
||||
|
||||
def test_export_plain_json(tmp_path):
|
||||
vault = Vault.create(tmp_path / "vault.fmdb", "pw")
|
||||
journal.create_product(vault, "Булка с маком", cost_price=20, retail_price=35)
|
||||
|
||||
out = vault.export_plain_json(tmp_path / "export.json")
|
||||
data = json.loads(out.read_text(encoding="utf-8"))
|
||||
assert data["products"][0]["name"] == "Булка с маком"
|
||||
|
||||
|
||||
def test_migration_adds_journal():
|
||||
raw = migrate({"schema_version": 0, "products": []})
|
||||
assert raw["schema_version"] == 1
|
||||
assert raw["journal"] == []
|
||||
|
||||
|
||||
def test_future_schema_refuses_to_open():
|
||||
with pytest.raises(crypto.UnsupportedFormat, match="новой версией"):
|
||||
migrate({"schema_version": 999})
|
||||
|
||||
|
||||
def test_corrupted_json_inside_valid_encryption(tmp_path):
|
||||
"""GCM подтвердил целостность, но внутри не JSON — сообщаем внятно."""
|
||||
path = tmp_path / "vault.fmdb"
|
||||
path.write_bytes(crypto.encrypt(b"not json at all", "pw"))
|
||||
with pytest.raises(crypto.VaultError, match="повреждено"):
|
||||
Vault.open(path, "pw")
|
||||
391
tests/test_ui_smoke.py
Normal file
391
tests/test_ui_smoke.py
Normal file
@ -0,0 +1,391 @@
|
||||
"""Дымовые тесты интерфейса: окно собирается, экраны рисуются, формы отдают данные.
|
||||
|
||||
Гоняются в offscreen-режиме, поэтому идут и в консоли, и без монитора.
|
||||
Диалоги не показываются через exec() — вместо этого проверяется их содержимое
|
||||
и то, что из него получается.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from app import journal # noqa: E402
|
||||
from app.models import KIND_FRIEND, KIND_RETAIL, KIND_SELF # noqa: E402
|
||||
from app.ui import theme # noqa: E402
|
||||
from app.ui.main_window import MainWindow # noqa: E402
|
||||
from app.ui.page_batches import BatchDialog # noqa: E402
|
||||
from app.ui.page_journal import JournalPage, describe # noqa: E402
|
||||
from app.ui.page_products import PriceDialog, ProductDialog # noqa: E402
|
||||
from app.ui.page_sales import SaleDialog, price_source_for # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
theme.apply(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def window(qapp, vault, buns, today, in_two_weeks):
|
||||
"""Окно на живых данных: партия, розница, друг в долг, съеденное."""
|
||||
batch = journal.create_batch(
|
||||
vault,
|
||||
today,
|
||||
in_two_weeks,
|
||||
[
|
||||
{"product_id": buns["повидло"].id, "qty": 5, "unit_cost": 30},
|
||||
{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20},
|
||||
{"product_id": buns["корица"].id, "qty": 20, "unit_cost": 25},
|
||||
],
|
||||
)
|
||||
vasya = journal.create_counterparty(vault, "Вася")
|
||||
journal.create_sale(
|
||||
vault, today, KIND_RETAIL, [{"product_id": buns["повидло"].id, "qty": 3, "unit_price": 50}]
|
||||
)
|
||||
journal.create_sale(
|
||||
vault, today, KIND_FRIEND, [{"product_id": buns["мак"].id, "qty": 5, "unit_price": 20}],
|
||||
counterparty_id=vasya.id, paid_amount=0,
|
||||
)
|
||||
journal.create_sale(
|
||||
vault, today, KIND_SELF, [{"product_id": buns["корица"].id, "qty": 2, "unit_price": 0}]
|
||||
)
|
||||
|
||||
win = MainWindow(vault)
|
||||
win.batch = batch
|
||||
win.vasya = vasya
|
||||
yield win
|
||||
win.autosave_timer.stop()
|
||||
win.sync_timer.stop()
|
||||
win.close()
|
||||
win.deleteLater()
|
||||
|
||||
|
||||
# --- окно целиком ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_window_builds_with_all_pages(window):
|
||||
titles = [title for title, _ in window.pages]
|
||||
assert titles == ["Сводка", "Закупки", "Продажи", "Долги", "Товары", "Журнал", "Настройки"]
|
||||
assert window.stack.count() == 7
|
||||
|
||||
|
||||
def test_every_page_refreshes_without_errors(window):
|
||||
for index in range(len(window.pages)):
|
||||
window.sidebar.setCurrentRow(index)
|
||||
window.pages[index][1].refresh()
|
||||
|
||||
|
||||
def test_changed_rebuilds_the_report(window, buns):
|
||||
before = window.report.summary.owed_to_bakery
|
||||
journal.add_bakery_payment(window.vault, 100, batch_id=window.batch.id)
|
||||
window.changed()
|
||||
assert window.report.summary.owed_to_bakery == before - 100
|
||||
|
||||
|
||||
def test_autosave_writes_only_after_a_change(window):
|
||||
window.save_now()
|
||||
stamp = window.vault.path.stat().st_mtime_ns
|
||||
window._autosave()
|
||||
assert window.vault.path.stat().st_mtime_ns == stamp # файл не трогали
|
||||
|
||||
journal.create_product(window.vault, "Булка с изюмом", cost_price=22, retail_price=38)
|
||||
assert window.vault.revision != window._saved_revision
|
||||
window._autosave()
|
||||
assert window.vault.revision == window._saved_revision
|
||||
|
||||
|
||||
def test_sync_is_skipped_without_a_remote(window):
|
||||
"""Без адреса репозитория фоновый тик не должен ничего делать."""
|
||||
window.sync_now(quiet=True)
|
||||
assert window._syncing is False
|
||||
|
||||
|
||||
# --- таблицы наполняются --------------------------------------------------
|
||||
|
||||
|
||||
def test_batches_table_shows_the_batch(window):
|
||||
page = window.pages[1][1]
|
||||
page.refresh()
|
||||
assert page.table.rowCount() == 1
|
||||
assert page.table.item(0, 0).text() == "1"
|
||||
assert "850" in page.table.item(0, 4).text()
|
||||
|
||||
|
||||
def test_batch_details_show_coverage(window):
|
||||
page = window.pages[1][1]
|
||||
page.refresh()
|
||||
page.table.selectRow(0)
|
||||
page._show_details()
|
||||
|
||||
assert "Партия" in page.details_title.text()
|
||||
assert page.progress.value() == 17 # 150 из 850
|
||||
assert page.contents.rowCount() == 3
|
||||
|
||||
|
||||
def test_sales_table_lists_every_kind(window):
|
||||
page = window.pages[2][1]
|
||||
page.refresh()
|
||||
assert page.table.rowCount() == 3
|
||||
|
||||
|
||||
def test_sales_filter_narrows_the_list(window):
|
||||
page = window.pages[2][1]
|
||||
page.filter.setCurrentIndex(page.filter.findData(KIND_SELF))
|
||||
page.refresh()
|
||||
assert page.table.rowCount() == 1
|
||||
|
||||
|
||||
def test_debts_tree_groups_by_person(window):
|
||||
page = window.pages[3][1]
|
||||
page.refresh()
|
||||
assert page.tree.topLevelItemCount() == 1
|
||||
|
||||
person = page.tree.topLevelItem(0)
|
||||
assert person.text(0) == "Вася"
|
||||
assert "100" in person.text(4)
|
||||
assert person.childCount() == 1
|
||||
|
||||
|
||||
def test_products_table_shows_stock_and_margin(window):
|
||||
page = window.pages[4][1]
|
||||
page.refresh()
|
||||
names = [page.table.item(r, 0).text() for r in range(page.table.rowCount())]
|
||||
assert "Булка с маком" in names
|
||||
|
||||
row = names.index("Булка с маком")
|
||||
assert "15" in page.table.item(row, 4).text() # 35 − 20
|
||||
assert page.table.item(row, 5).text() == "5" # 10 закуплено − 5 отдано
|
||||
|
||||
|
||||
def test_archived_products_are_hidden_by_default(window, buns):
|
||||
page = window.pages[4][1]
|
||||
journal.update_product(window.vault, buns["мак"].id, archived=True)
|
||||
window.changed()
|
||||
|
||||
visible = [page.table.item(r, 0).text() for r in range(page.table.rowCount())]
|
||||
assert "Булка с маком" not in visible
|
||||
|
||||
page.show_archived.setChecked(True)
|
||||
visible = [page.table.item(r, 0).text() for r in range(page.table.rowCount())]
|
||||
assert "Булка с маком" in visible
|
||||
|
||||
|
||||
# --- сводка ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dashboard_renders_metrics_and_deadline(window):
|
||||
from PySide6.QtWidgets import QLabel, QProgressBar
|
||||
|
||||
page = window.pages[0][1]
|
||||
page.refresh()
|
||||
|
||||
texts = " ".join(label.text() for label in page.body.findChildren(QLabel))
|
||||
assert "Долг пекарне" in texts
|
||||
assert "850,00 ₽" in texts # вся себестоимость партии ещё не возвращена
|
||||
assert "Мне должны" in texts
|
||||
assert "100,00 ₽" in texts # долг Васи
|
||||
|
||||
bars = page.body.findChildren(QProgressBar)
|
||||
assert len(bars) == 1
|
||||
assert bars[0].value() == 17 # собрано 150 из 850
|
||||
|
||||
|
||||
def test_dashboard_shows_a_warning_when_oversold(window, buns):
|
||||
page = window.pages[0][1]
|
||||
journal.create_sale(
|
||||
window.vault, window.report.today, KIND_RETAIL,
|
||||
[{"product_id": buns["повидло"].id, "qty": 99, "unit_price": 50}],
|
||||
)
|
||||
window.changed()
|
||||
assert any("больше" in warning for warning in window.report.warnings)
|
||||
|
||||
|
||||
# --- журнал ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_journal_page_lists_entries(window):
|
||||
page = window.pages[5][1]
|
||||
page.refresh()
|
||||
assert page.table.rowCount() == len(window.vault.doc.journal)
|
||||
|
||||
|
||||
def test_journal_search_filters(window, buns):
|
||||
page = window.pages[5][1]
|
||||
journal.change_prices(window.vault, buns["мак"].id, cost_price=25, retail_price=40)
|
||||
window.changed()
|
||||
|
||||
page.search.setText("цены изменены")
|
||||
page.refresh()
|
||||
assert page.table.rowCount() == 1
|
||||
assert "20,00 ₽ → 25,00 ₽" in page.table.item(0, 4).text()
|
||||
|
||||
|
||||
def test_journal_search_matches_what_is_displayed(window, buns):
|
||||
"""Формат на экране и то, что ищется, должны совпадать до пробела."""
|
||||
page = window.pages[5][1]
|
||||
journal.change_prices(window.vault, buns["мак"].id, cost_price=1500, retail_price=2000)
|
||||
window.changed()
|
||||
|
||||
page.search.setText("1 500,00")
|
||||
page.refresh()
|
||||
assert page.table.rowCount() == 1
|
||||
|
||||
|
||||
def test_journal_entity_filter(window):
|
||||
page = window.pages[5][1]
|
||||
page.entity.setCurrentIndex(page.entity.findData("batch"))
|
||||
page.refresh()
|
||||
assert page.table.rowCount() == 1
|
||||
|
||||
|
||||
def test_describe_renders_a_transition():
|
||||
from app.models import Change, JournalEntry
|
||||
|
||||
entry = JournalEntry(
|
||||
id="j_1", ts="", host="", action="product.price_change",
|
||||
entity="product", entity_id="p_1", label="Булка",
|
||||
changes=[Change("себестоимость", "20,00 ₽", "25,00 ₽")],
|
||||
)
|
||||
assert describe(entry) == "себестоимость: 20,00 ₽ → 25,00 ₽"
|
||||
|
||||
|
||||
# --- формы ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_batch_dialog_round_trips_lines(window):
|
||||
dialog = BatchDialog(window, window.batch)
|
||||
lines = dialog.lines.lines("unit_cost")
|
||||
|
||||
assert len(lines) == 3
|
||||
assert dialog.lines.total() == Decimal("850.00")
|
||||
assert lines[0]["unit_cost"] == Decimal("30.00")
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_batch_dialog_defaults_the_deadline_from_settings(window):
|
||||
window.vault.doc.settings.default_period_days = 21
|
||||
dialog = BatchDialog(window)
|
||||
assert (dialog.due.get_date() - dialog.date.get_date()).days == 21
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_batch_dialog_deadline_follows_the_purchase_date(window):
|
||||
dialog = BatchDialog(window)
|
||||
dialog.date.set_date(dialog.date.get_date() + timedelta(days=5))
|
||||
days = window.vault.doc.settings.default_period_days
|
||||
assert (dialog.due.get_date() - dialog.date.get_date()).days == days
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_sale_dialog_substitutes_price_by_kind(window, buns):
|
||||
"""Ради этого и заведены типы: другу — себестоимость, себе — ноль."""
|
||||
dialog = SaleDialog(window)
|
||||
dialog.lines.table.cellWidget(0, 0).select_product(buns["мак"].id)
|
||||
|
||||
dialog.kind.setCurrentIndex(0) # розница
|
||||
assert dialog.lines.lines("unit_price")[0]["unit_price"] == Decimal("35.00")
|
||||
|
||||
dialog.kind.setCurrentIndex(1) # другу по себестоимости
|
||||
assert dialog.lines.lines("unit_price")[0]["unit_price"] == Decimal("20.00")
|
||||
|
||||
dialog.kind.setCurrentIndex(2) # съел сам
|
||||
assert dialog.lines.lines("unit_price")[0]["unit_price"] == Decimal("0.00")
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_sale_dialog_disables_payment_for_consumption(window):
|
||||
dialog = SaleDialog(window)
|
||||
dialog.kind.setCurrentIndex(2) # съел сам
|
||||
assert dialog.paid.isEnabled() is False
|
||||
assert dialog.paid.value_decimal() == 0
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_sale_dialog_prefills_full_payment(window, buns):
|
||||
dialog = SaleDialog(window)
|
||||
dialog.lines.table.cellWidget(0, 0).select_product(buns["мак"].id)
|
||||
dialog.lines.table.cellWidget(0, 1).set_decimal(4)
|
||||
|
||||
assert dialog.paid.value_decimal() == Decimal("140.00") # 4 × 35
|
||||
assert "полностью" in dialog.paid_hint.text()
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_price_source_for_each_kind(buns):
|
||||
assert price_source_for(KIND_RETAIL)(buns["мак"]) == Decimal("35.00")
|
||||
assert price_source_for(KIND_FRIEND)(buns["мак"]) == Decimal("20.00")
|
||||
assert price_source_for(KIND_SELF)(buns["мак"]) == 0
|
||||
|
||||
|
||||
def test_price_dialog_shows_history(window, buns):
|
||||
journal.change_prices(window.vault, buns["мак"].id, cost_price=25, retail_price=40)
|
||||
dialog = PriceDialog("₽", window.vault.doc.product(buns["мак"].id))
|
||||
|
||||
assert dialog.cost.value_decimal() == Decimal("25.00")
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_product_dialog_hides_prices_when_editing(window, buns):
|
||||
"""Цены правятся только через историю — иначе она бы врала."""
|
||||
editing = ProductDialog("₽", window.vault.doc.product(buns["мак"].id))
|
||||
assert not hasattr(editing, "cost")
|
||||
editing.deleteLater()
|
||||
|
||||
creating = ProductDialog("₽")
|
||||
assert hasattr(creating, "cost")
|
||||
creating.deleteLater()
|
||||
|
||||
|
||||
def test_lines_editor_add_and_remove(window, buns):
|
||||
dialog = BatchDialog(window)
|
||||
editor = dialog.lines
|
||||
start = editor.table.rowCount()
|
||||
|
||||
editor.add_line(buns["мак"].id, 7, 20)
|
||||
assert len(editor.lines("unit_cost")) == start + 1
|
||||
|
||||
editor.table.setCurrentCell(editor.table.rowCount() - 1, 0)
|
||||
editor.remove_current()
|
||||
assert len(editor.lines("unit_cost")) == start
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
# --- настройки ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_settings_shows_database_facts(window):
|
||||
page = window.pages[6][1]
|
||||
page.refresh()
|
||||
assert "товаров 3" in page.db_info.text()
|
||||
assert "закупок 1" in page.db_info.text()
|
||||
|
||||
|
||||
def test_settings_saves_git_without_wiping_the_token(window):
|
||||
"""Пустое поле токена означает «не трогать», а не «стереть»."""
|
||||
page = window.pages[6][1]
|
||||
journal.update_settings(window.vault, git={"token": "glpat-keepme"})
|
||||
page.refresh()
|
||||
|
||||
page.remote.setText("https://gt.ser.gay/kizya/food-market.git")
|
||||
page.token.clear()
|
||||
page.save_git()
|
||||
|
||||
assert window.vault.doc.settings.git.token == "glpat-keepme"
|
||||
assert window.vault.doc.settings.git.remote_url == "https://gt.ser.gay/kizya/food-market.git"
|
||||
|
||||
|
||||
def test_conflict_block_is_hidden_until_there_is_one(window):
|
||||
page = window.pages[6][1]
|
||||
page.refresh()
|
||||
assert page.conflict_box.isVisibleTo(page) is False
|
||||
|
||||
window.conflict_message = "версии разошлись"
|
||||
page.refresh()
|
||||
assert page.conflict_box.isVisibleTo(page) is True
|
||||
Loading…
Reference in New Issue
Block a user