Отдельный репозиторий для базы, фасовки товара, ввод истории

Три правки по замечаниям.

1. База и код разъехались по разным репозиториям. Автокоммит и раньше
   трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
   поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
   клон food-records со своим .git, а исходники его игнорируют целиком.
   При первом запуске приложение встаёт на уже существующую историю
   сервера, а не заводит параллельную.

2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
   за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
   считаются в базовых единицах, поэтому поштучные продажи вычитаются из
   купленных пачек. Размер фасовки хранится в документе слепком.
   Миграция схемы 1→2 не меняет поведение уже заведённых данных.

3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
   отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
   заводится как контрагент, повтор периода подсвечивается.

Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-08-22 21:25:39 +03:00
parent c8b9f5048b
commit efce552e2b
21 changed files with 1803 additions and 193 deletions

10
.gitignore vendored
View File

@ -12,9 +12,9 @@ dist/
# бинарник в нём не нужен, он собирается из исходников одной командой. # бинарник в нём не нужен, он собирается из исходников одной командой.
/food-market.exe /food-market.exe
# Рабочие артефакты базы. Сама база data/vault.fmdb НЕ игнорируется —
# она и есть предмет синхронизации.
*.tmp *.tmp
data/*.bak
data/vault.remote.fmdb # Папка data — самостоятельный клон репозитория с данными
data/export-*.json # (gt.ser.gay/kizya/food-records) со своим .git. Код и база живут врозь,
# поэтому здесь её не должно быть ни в каком виде.
/data/

View File

@ -1,7 +1,19 @@
# Булочная # Булочная
Десктопный учёт закупки и перепродажи булок. Вся база — один файл, Десктопный учёт закупки и перепродажи булок. Вся база — один файл,
зашифрованный паролем, который раз в час уезжает в этот репозиторий. зашифрованный паролем, который раз в час уезжает в отдельный репозиторий.
**Два репозитория, и это принципиально:**
| репозиторий | что внутри |
|---|---|
| `gt.ser.gay/kizya/food-market` | исходники программы (этот) |
| `gt.ser.gay/kizya/food-records` | только `vault.fmdb` — зашифрованная база |
Смешивать их нельзя: `git push` отправляет ветку целиком, поэтому в общем
репозитории история данных неизбежно тащила бы за собой историю программы.
На диске это папка `data/` рядом с exe — самостоятельный клон репозитория
с данными, со своим `.git`. Исходники её полностью игнорируют.
## Что считает ## Что считает
@ -14,12 +26,33 @@
- **Закупки** — партии с составом, ценами и датой, до которой надо рассчитаться. - **Закупки** — партии с составом, ценами и датой, до которой надо рассчитаться.
- **Продажи** — розница, «другу по себестоимости», «съел сам», подарок, списание. - **Продажи** — розница, «другу по себестоимости», «съел сам», подарок, списание.
Цена подставляется по типу. Цена подставляется по типу. Есть быстрый ввод пачки продаж за прошедший период.
- **Долги** — кто сколько должен, с частичными оплатами. - **Долги** — кто сколько должен, с частичными оплатами.
- **Товары** — номенклатура с историей изменения цен. - **Товары** — номенклатура с фасовками и историей изменения цен.
- **Журнал** — что менялось в базе, когда и с какой машины. - **Журнал** — что менялось в базе, когда и с какой машины.
- **Сводка** — долг пекарне, дебиторка, прибыль, остатки, ближайший дедлайн. - **Сводка** — долг пекарне, дебиторка, прибыль, остатки, ближайший дедлайн.
### Фасовки
Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно за 30 ₽ — это **один
товар с двумя фасовками**, а не два разных товара. У товара есть базовая
единица (штука) и любое число фасовок со своим размером и своей ценой.
Остатки, себестоимость и FIFO всегда считаются в базовых единицах, поэтому
поштучные продажи корректно вычитаются из купленных пачек. Количество и цена
при этом вводятся в той фасовке, которую выбрал ты: «2 пачки по 200 ₽» так
и остаётся в документе. Размер фасовки сохраняется слепком — переопределишь
пачку с 10 на 12 штук, и уже записанные документы не поедут.
### Ввод истории
Кнопка «Быстрый ввод за период» на экране продаж: задаёшь диапазон дат,
каждая строка таблицы — отдельная продажа. Дата новой строки наследуется от
предыдущей, Enter добавляет строку, Ctrl+D дублирует, Ctrl+Enter записывает
всё разом. Незнакомое имя в колонке «Кому» заводится как новый контрагент.
Программа подсказывает, сколько продаж за выбранный период уже записано —
чтобы не внести один и тот же месяц дважды.
Продажи разносятся по партиям методом FIFO. Цены у поставщика фиксированные, Продажи разносятся по партиям методом FIFO. Цены у поставщика фиксированные,
поэтому на себестоимость это не влияет — FIFO нужен только чтобы понимать, поэтому на себестоимость это не влияет — FIFO нужен только чтобы понимать,
деньги за какую партию уже пришли. деньги за какую партию уже пришли.
@ -41,9 +74,15 @@ pip install -r requirements-dev.txt
pyinstaller food-market.spec pyinstaller food-market.spec
``` ```
Готовый `dist/food-market.exe` (~50 МБ) положи в корень клона этого Готовый `dist/food-market.exe` (~50 МБ) можно положить куда угодно — базу он
репозитория. Базу он ищет в `data/vault.fmdb` рядом с собой, поэтому достаточно ищет в папке `data/` рядом с собой и при первом запуске сам заведёт там клон
склонировать репозиторий, положить exe в корень и запустить. репозитория с данными.
На новой машине быстрее склонировать данные сразу:
```bash
git clone https://gt.ser.gay/kizya/food-records.git data
```
## Хранение и шифрование ## Хранение и шифрование
@ -62,8 +101,9 @@ JSON → gzip → AES-256-GCM (ключ: scrypt от пароля) → файл
## Синхронизация ## Синхронизация
Раз в час (интервал настраивается) приложение сохраняет базу, коммитит Раз в час (интервал настраивается) приложение сохраняет базу, коммитит
**только** `data/vault.fmdb` и пушит. Незакоммиченные правки исходников в **только** `vault.fmdb` и пушит — в репозиторий данных, не в этот. Что бы ни
автокоммит не попадают. оказалось в папке рядом с базой, в коммит оно не попадёт: все команды идут
с явным списком путей.
Токен GitLab хранится внутри зашифрованной базы и подставляется в URL только на Токен GitLab хранится внутри зашифрованной базы и подставляется в URL только на
время вызова — в `.git/config` он не пишется и в журнал не попадает. время вызова — в `.git/config` он не пишется и в журнал не попадает.
@ -101,12 +141,14 @@ pytest -q
| файл | зачем | | файл | зачем |
|---|---| |---|---|
| `app/crypto.py` | формат файла, scrypt + AES-GCM | | `app/crypto.py` | формат файла, scrypt + AES-GCM |
| `app/models.py` | структура документа, деньги в Decimal | | `app/models.py` | структура документа, фасовки, деньги в Decimal |
| `app/storage.py` | загрузка, атомарная запись, миграции схемы, хеш «грязности» | | `app/storage.py` | загрузка, атомарная запись, миграции схемы, хеш «грязности» |
| `app/journal.py` | **единственный путь записи** в базу + аудит-лог | | `app/journal.py` | **единственный путь записи** в базу + аудит-лог |
| `app/ledger.py` | FIFO, покрытие партий, долги, остатки, сводка | | `app/ledger.py` | FIFO в базовых единицах, покрытие партий, долги, остатки |
| `app/gitsync.py` | git, схлопывание коммитов, разрешение расхождений | | `app/gitsync.py` | git, схлопывание коммитов, разрешение расхождений |
| `app/ui/` | экраны на PySide6 | | `app/paths.py` | где лежат данные и почему отдельно от кода |
| `app/ui/quick_sales.py` | быстрый ввод продаж за период |
| `app/ui/` | остальные экраны на PySide6 |
Главное архитектурное правило: **ни один экран не меняет документ напрямую**. Главное архитектурное правило: **ни один экран не меняет документ напрямую**.
Всё идёт через функции `app/journal.py`, которые сами считают разницу и Всё идёт через функции `app/journal.py`, которые сами считают разницу и

View File

@ -34,6 +34,24 @@ from .models import GitSettings
COMMIT_PREFIX = "vault" COMMIT_PREFIX = "vault"
COMMIT_RE = re.compile(r"^vault \d{4}-\d{2}-\d{2}$") COMMIT_RE = re.compile(r"^vault \d{4}-\d{2}-\d{2}$")
# Служебные файлы репозитория данных. Создаются один раз и уезжают вместе
# с первым коммитом базы.
SETUP_FILES = {
".gitattributes": (
"# База зашифрована. Если git примет её за текст и подставит CRLF,\n"
"# файл перестанет расшифровываться. Помечаем явно.\n"
"*.fmdb binary\n"
"vault.fmdb binary\n"
),
".gitignore": (
"# Рабочие копии базы. Синхронизируется только vault.fmdb.\n"
"*.bak\n"
"*.tmp\n"
"vault.remote.fmdb\n"
"export-*.json\n"
),
}
LOCAL_TIMEOUT = 30 LOCAL_TIMEOUT = 30
NETWORK_TIMEOUT = 180 NETWORK_TIMEOUT = 180
@ -263,22 +281,39 @@ class GitSync:
touched = self._out(["show", "--name-only", "--format=", "HEAD"]).split("\n") touched = self._out(["show", "--name-only", "--format=", "HEAD"]).split("\n")
return [t for t in touched if t.strip()] == [self.rel_path] return [t for t in touched if t.strip()] == [self.rel_path]
def _pending_setup_files(self) -> list[str]:
"""Создать служебные файлы и вернуть те, которых git ещё не знает."""
pending = []
for name, content in SETUP_FILES.items():
path = self.repo_dir / name
if not path.exists():
path.write_text(content, encoding="utf-8")
tracked = self._run(["ls-files", "--error-unmatch", name], check=False).returncode == 0
if not tracked:
pending.append(name)
return pending
def _commit(self, remote: str | None) -> tuple[bool, bool, str | None]: def _commit(self, remote: str | None) -> tuple[bool, bool, str | None]:
"""Закоммитить базу. Возвращает (закоммитили, через amend, sha до amend).""" """Закоммитить базу. Возвращает (закоммитили, через amend, sha до amend)."""
self._run(["add", "--", self.rel_path]) # Явный список путей — гарантия, что в автокоммит не утащит ничего
# постороннего, что окажется в папке.
targets = [self.rel_path, *self._pending_setup_files()]
self._run(["add", "--", *targets])
staged = self._run( staged = self._run(
["diff", "--cached", "--quiet", "--", self.rel_path], check=False ["diff", "--cached", "--quiet", "--", *targets], check=False
).returncode != 0 ).returncode != 0
if not staged: if not staged:
return False, False, None return False, False, None
if self._can_amend(remote): # Amend возможен, только когда коммитим ровно базу: иначе переписали бы
# вершину, в которой лежит что-то ещё.
if targets == [self.rel_path] and self._can_amend(remote):
pre_amend = self.head_sha() pre_amend = self.head_sha()
self._run(["commit", "--amend", "--no-edit", "--", self.rel_path]) self._run(["commit", "--amend", "--no-edit", "--", self.rel_path])
return True, True, pre_amend return True, True, pre_amend
self._run(["commit", "-m", commit_message(), "--", self.rel_path]) self._run(["commit", "-m", commit_message(), "--", *targets])
return True, False, None return True, False, None
def _push(self, amended: bool, lease: str | None) -> None: def _push(self, amended: bool, lease: str | None) -> None:
@ -324,6 +359,10 @@ class GitSync:
result = SyncResult() result = SyncResult()
head = self.head_sha() head = self.head_sha()
if head is None and remote:
self._adopt_remote_history(remote, remote_copy_path)
head = self.head_sha()
if remote and head and head != remote: if remote and head and head != remote:
behind = self._is_ancestor(head, remote) behind = self._is_ancestor(head, remote)
ahead = self._is_ancestor(remote, head) ahead = self._is_ancestor(remote, head)
@ -389,6 +428,26 @@ class GitSync:
if head: if head:
self._run(["update-ref", self._remote_ref, head], check=False) self._run(["update-ref", self._remote_ref, head], check=False)
def _adopt_remote_history(self, remote: str, remote_copy_path: Path | None) -> None:
"""Встать на историю сервера, когда локальных коммитов ещё нет.
Так бывает при первом запуске: репозиторий данных уже заведён и, как
правило, содержит README. Если начать свою историю параллельно, первый
же пуш упрётся в non-fast-forward.
"""
remote_has_vault = (
self._run(["cat-file", "-e", f"{remote}:{self.rel_path}"], check=False).returncode == 0
)
if remote_has_vault and (self.repo_dir / self.rel_path).exists():
# На сервере уже лежит база, и локально создали ещё одну. Обе
# настоящие — какая нужна, решать пользователю.
self._report_divergence(remote_copy_path)
self._run(["update-ref", f"refs/heads/{self.branch}", remote])
# База не отслеживается, поэтому reset её не тронет — заберём только
# то, что уже есть на сервере.
self._run(["reset", "--hard", remote])
def _report_divergence(self, remote_copy_path: Path | None) -> None: def _report_divergence(self, remote_copy_path: Path | None) -> None:
if remote_copy_path is not None: if remote_copy_path is not None:
self.save_remote_copy(remote_copy_path) self.save_remote_copy(remote_copy_path)

View File

@ -22,6 +22,7 @@ from datetime import date, datetime
from . import money as m from . import money as m
from .models import ( from .models import (
ONE,
SALE_KIND_LABELS, SALE_KIND_LABELS,
BakeryPayment, BakeryPayment,
Batch, Batch,
@ -29,6 +30,7 @@ from .models import (
Change, Change,
Counterparty, Counterparty,
JournalEntry, JournalEntry,
Pack,
Payment, Payment,
PricePoint, PricePoint,
Product, Product,
@ -110,6 +112,7 @@ def _flat_product(doc, p: Product) -> dict[str, str]:
"единица": p.unit, "единица": p.unit,
"себестоимость": m.fmt_money(p.cost_price, doc.settings.currency), "себестоимость": m.fmt_money(p.cost_price, doc.settings.currency),
"цена продажи": m.fmt_money(p.retail_price, doc.settings.currency), "цена продажи": m.fmt_money(p.retail_price, doc.settings.currency),
"фасовки": _packs_text(doc, p),
"в архиве": "да" if p.archived else "", "в архиве": "да" if p.archived else "",
"заметка": p.note, "заметка": p.note,
} }
@ -121,10 +124,21 @@ def _flat_counterparty(doc, c: Counterparty) -> dict[str, str]:
def _lines_text(doc, lines, price_attr: str) -> str: def _lines_text(doc, lines, price_attr: str) -> str:
cur = doc.settings.currency cur = doc.settings.currency
return "; ".join( parts = []
f"{doc.product_name(x.product_id)} × {m.fmt_qty(x.qty)} " for x in lines:
unit = x.unit_name(doc.product(x.product_id))
parts.append(
f"{doc.product_name(x.product_id)} × {m.fmt_qty(x.qty)} {unit} "
f"по {m.fmt_money(getattr(x, price_attr), cur)}" f"по {m.fmt_money(getattr(x, price_attr), cur)}"
for x in lines )
return "; ".join(parts)
def _packs_text(doc, product: Product) -> str:
cur = doc.settings.currency
return "; ".join(
f"{p.name} = {m.fmt_qty(p.size)} {product.unit} по {m.fmt_money(p.retail_price, cur)}"
for p in product.packs
) )
@ -284,6 +298,45 @@ def change_prices(
return product return product
def set_packs(vault, product_id: str, packs: list[dict]) -> Product:
"""Задать фасовки товара.
Уже записанные документы помнят размер фасовки слепком, поэтому правка
здесь не пересчитывает историю меняется только то, что подставится
в новые строки.
"""
doc = vault.doc
product = doc.product(product_id)
if product is None:
raise ValidationError("Товар не найден.")
built: list[Pack] = []
seen = {product.unit}
for raw in packs:
name = (raw.get("name") or "").strip()
if not name:
raise ValidationError("У фасовки должно быть название.")
if name in seen:
raise ValidationError(f"Фасовка «{name}» повторяется или совпадает с единицей товара.")
seen.add(name)
size = m.qty(raw.get("size"))
if size <= 0:
raise ValidationError(f"Размер фасовки «{name}» должен быть больше нуля.")
price = m.money(raw.get("retail_price"))
if price < 0:
raise ValidationError("Цена фасовки не может быть отрицательной.")
built.append(Pack(name=name, size=size, retail_price=price))
before = _flat_product(doc, product)
product.packs = built
changes = _diff(before, _flat_product(doc, product))
if changes:
_record(vault, "product.packs", "product", product.id, product.name, changes)
return product
def delete_product(vault, product_id: str) -> None: def delete_product(vault, product_id: str) -> None:
"""Удалить товар, если он нигде не использован. """Удалить товар, если он нигде не использован.
@ -383,21 +436,45 @@ def delete_counterparty(vault, cp_id: str) -> None:
# --- Закупки -------------------------------------------------------------- # --- Закупки --------------------------------------------------------------
def _resolve_line(doc, raw: dict, what: str) -> tuple[Product, Decimal, str, Decimal]:
"""Проверить строку документа и разложить её на товар, количество и фасовку."""
product_id = raw.get("product_id")
product = doc.product(product_id) if product_id else None
if product is None:
raise ValidationError(f"В {what} есть строка без существующего товара.")
quantity = m.qty(raw.get("qty"))
if quantity <= 0:
raise ValidationError(f"Количество для «{product.name}» должно быть больше нуля.")
uom = (raw.get("uom") or "").strip()
if not uom or uom == product.unit:
return product, quantity, product.unit, ONE
pack = product.pack(uom)
if pack is None:
raise ValidationError(f"У товара «{product.name}» нет фасовки «{uom}».")
# Размер запоминается слепком: переопределят пачку — старые документы
# не должны поехать.
return product, quantity, pack.name, pack.size
def _build_batch_lines(doc, lines: list[dict]) -> list[BatchLine]: def _build_batch_lines(doc, lines: list[dict]) -> list[BatchLine]:
result: list[BatchLine] = [] result: list[BatchLine] = []
for raw in lines: for raw in lines:
product_id = raw.get("product_id") _, quantity, uom, size = _resolve_line(doc, raw, "закупке")
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")) cost = m.money(raw.get("unit_cost"))
if cost < 0: if cost < 0:
raise ValidationError("Себестоимость не может быть отрицательной.") raise ValidationError("Себестоимость не может быть отрицательной.")
result.append(BatchLine(product_id=product_id, qty=quantity, unit_cost=cost)) result.append(
BatchLine(
product_id=raw["product_id"],
qty=quantity,
uom=uom,
uom_size=size,
unit_cost=cost,
)
)
if not result: if not result:
raise ValidationError("В закупке должна быть хотя бы одна строка.") raise ValidationError("В закупке должна быть хотя бы одна строка.")
@ -513,18 +590,19 @@ def delete_batch(vault, batch_id: str) -> None:
def _build_sale_lines(doc, lines: list[dict]) -> list[SaleLine]: def _build_sale_lines(doc, lines: list[dict]) -> list[SaleLine]:
result: list[SaleLine] = [] result: list[SaleLine] = []
for raw in lines: for raw in lines:
product_id = raw.get("product_id") _, quantity, uom, size = _resolve_line(doc, raw, "продаже")
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")) price = m.money(raw.get("unit_price"))
if price < 0: if price < 0:
raise ValidationError("Цена не может быть отрицательной.") raise ValidationError("Цена не может быть отрицательной.")
result.append(SaleLine(product_id=product_id, qty=quantity, unit_price=price)) result.append(
SaleLine(
product_id=raw["product_id"],
qty=quantity,
uom=uom,
uom_size=size,
unit_price=price,
)
)
if not result: if not result:
raise ValidationError("В продаже должна быть хотя бы одна строка.") raise ValidationError("В продаже должна быть хотя бы одна строка.")

View File

@ -91,6 +91,7 @@ class BatchReport:
days_left: int days_left: int
status: str status: str
stock: dict[str, Decimal] = field(default_factory=dict) stock: dict[str, Decimal] = field(default_factory=dict)
stock_cost: Decimal = m.ZERO
@property @property
def remaining_to_bakery(self) -> Decimal: def remaining_to_bakery(self) -> Decimal:
@ -194,27 +195,47 @@ def _ordered_sales(doc: Document) -> list[tuple[int, Sale]]:
return sorted(enumerate(doc.sales), key=lambda t: (t[1].date, t[0])) 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]]]: def batch_unit_costs(doc: Document) -> dict[tuple[str, str], Decimal]:
"""Себестоимость базовой единицы по каждой паре (партия, товар).
Себестоимость слепок цены на момент закупки. Если один товар попал
в партию несколькими строками (например, пачками и поштучно), берём
средневзвешенную: остаток по товару всё равно единый.
"""
totals: dict[tuple[str, str], list[Decimal]] = {}
for batch in doc.batches:
for line in batch.lines:
key = (batch.id, line.product_id)
acc = totals.setdefault(key, [m.ZERO, m.ZERO])
acc[0] += line.base_qty * line.base_unit_cost
acc[1] += line.base_qty
return {
key: (cost / qty if qty > 0 else m.ZERO) for key, (cost, qty) in totals.items()
}
def allocate(
doc: Document,
) -> tuple[list[Allocation], list[Shortfall], dict[str, dict[str, Decimal]]]:
"""Разложить продажи по партиям методом FIFO. """Разложить продажи по партиям методом FIFO.
Возвращает аллокации, нехватку товара и остаток по каждой партии. Возвращает аллокации, нехватку товара и остаток по каждой партии.
Намеренно не проверяем, что дата продажи позже даты партии: люди заводят Намеренно не проверяем, что дата продажи позже даты партии: люди заводят
документы в произвольном порядке, и жаловаться на это было бы шумом. документы в произвольном порядке особенно когда вносят историю задним
Значение имеет только очередь партий. числом, и жаловаться на это было бы шумом. Значение имеет только очередь
партий.
""" """
# Всё внутри — в базовых единицах. Иначе поштучная продажа не вычиталась бы
# из купленной пачки: это один товар, а не два разных.
remaining: dict[str, dict[str, Decimal]] = {} remaining: dict[str, dict[str, Decimal]] = {}
for _, batch in _ordered_batches(doc): for _, batch in _ordered_batches(doc):
per_product = remaining.setdefault(batch.id, {}) per_product = remaining.setdefault(batch.id, {})
for line in batch.lines: for line in batch.lines:
per_product[line.product_id] = per_product.get(line.product_id, m.ZERO) + line.qty per_product[line.product_id] = per_product.get(line.product_id, m.ZERO) + line.base_qty
# Себестоимость берём из строки партии — это слепок цены на момент закупки. unit_costs = batch_unit_costs(doc)
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)] order = [batch.id for _, batch in _ordered_batches(doc)]
allocations: list[Allocation] = [] allocations: list[Allocation] = []
@ -223,7 +244,7 @@ def allocate(doc: Document) -> tuple[list[Allocation], list[Shortfall], dict[str
for _, sale in _ordered_sales(doc): for _, sale in _ordered_sales(doc):
fraction = _paid_fraction(sale) fraction = _paid_fraction(sale)
for line_index, line in enumerate(sale.lines): for line_index, line in enumerate(sale.lines):
need = line.qty need = line.base_qty
for batch_id in order: for batch_id in order:
if need <= 0: if need <= 0:
break break
@ -242,7 +263,7 @@ def allocate(doc: Document) -> tuple[list[Allocation], list[Shortfall], dict[str
batch_id=batch_id, batch_id=batch_id,
qty=take, qty=take,
unit_cost=unit_costs.get((batch_id, line.product_id), m.ZERO), unit_cost=unit_costs.get((batch_id, line.product_id), m.ZERO),
unit_price=line.unit_price, unit_price=line.base_unit_price,
kind=sale.kind, kind=sale.kind,
paid_fraction=fraction, paid_fraction=fraction,
) )
@ -307,6 +328,7 @@ def build(doc: Document, today: date | None = None) -> Report:
"""Пересчитать всё. Дешевле, чем поддерживать инкрементальное состояние.""" """Пересчитать всё. Дешевле, чем поддерживать инкрементальное состояние."""
today = today or date.today() today = today or date.today()
allocations, shortfalls, remaining = allocate(doc) allocations, shortfalls, remaining = allocate(doc)
unit_costs = batch_unit_costs(doc)
cost_totals = {batch.id: batch.cost_total for batch in doc.batches} cost_totals = {batch.id: batch.cost_total for batch in doc.batches}
paid_to_bakery, unassigned = _spread_bakery_payments(doc, cost_totals) paid_to_bakery, unassigned = _spread_bakery_payments(doc, cost_totals)
@ -351,6 +373,15 @@ def build(doc: Document, today: date | None = None) -> Report:
days_left=days_left, days_left=days_left,
status=_status(batch, max(m.ZERO, cost_total - paid), days_left), status=_status(batch, max(m.ZERO, cost_total - paid), days_left),
stock=stock, stock=stock,
stock_cost=m.money(
sum(
(
qty * unit_costs.get((batch.id, pid), m.ZERO)
for pid, qty in stock.items()
),
m.ZERO,
)
),
) )
) )
@ -389,14 +420,12 @@ def _debtors(doc: Document) -> list[DebtorReport]:
def _stock(doc: Document, batch_reports: list[BatchReport]) -> list[StockRow]: def _stock(doc: Document, batch_reports: list[BatchReport]) -> list[StockRow]:
unit_costs = batch_unit_costs(doc)
qty_by_product: dict[str, Decimal] = {} qty_by_product: dict[str, Decimal] = {}
cost_by_product: dict[str, Decimal] = {} cost_by_product: dict[str, Decimal] = {}
for report in batch_reports: for report in batch_reports:
for product_id, qty in report.stock.items(): for product_id, qty in report.stock.items():
unit_cost = next( unit_cost = unit_costs.get((report.batch.id, product_id), m.ZERO)
(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 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 cost_by_product[product_id] = cost_by_product.get(product_id, m.ZERO) + qty * unit_cost
@ -446,18 +475,7 @@ def _summary(
gross_margin=m.money(margin), gross_margin=m.money(margin),
consumed_cost=m.money(consumed), consumed_cost=m.money(consumed),
stock_qty=m.qty(sum((r.qty_left for r in batch_reports), m.ZERO)), stock_qty=m.qty(sum((r.qty_left for r in batch_reports), m.ZERO)),
stock_cost=m.money( stock_cost=m.money(sum((r.stock_cost for r in batch_reports), m.ZERO)),
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), overdue_count=sum(1 for r in batch_reports if r.status == STATUS_OVERDUE),
next_due=next_due, next_due=next_due,
) )

View File

@ -15,7 +15,9 @@ from typing import Any
from . import money as m from . import money as m
SCHEMA_VERSION = 1 SCHEMA_VERSION = 2
ONE = Decimal(1)
# --- Типы движения товара ------------------------------------------------- # --- Типы движения товара -------------------------------------------------
# #
@ -94,8 +96,40 @@ class PricePoint:
} }
@dataclass
class Pack:
"""Фасовка: сколько базовых единиц внутри и почём продаётся целиком.
Пачка печенья по 10 штук за 200 и та же печенька поштучно по 30
это один товар с двумя фасовками, а не два разных товара. Остатки и
себестоимость всегда считаются в базовых единицах, поэтому поштучные
продажи корректно вычитаются из купленных пачек.
"""
name: str
size: Decimal
retail_price: Decimal = m.ZERO
@classmethod
def from_dict(cls, d: dict) -> "Pack":
return cls(
name=d.get("name", ""),
size=m.qty(d.get("size", 1)),
retail_price=m.money(d.get("retail_price")),
)
def to_dict(self) -> dict:
return {
"name": self.name,
"size": m.dumps(self.size),
"retail_price": m.dumps(self.retail_price),
}
@dataclass @dataclass
class Product: class Product:
"""Товар. Цены хранятся за базовую единицу, фасовки — надстройка над ней."""
id: str id: str
name: str name: str
unit: str = "шт" unit: str = "шт"
@ -103,8 +137,40 @@ class Product:
retail_price: Decimal = m.ZERO retail_price: Decimal = m.ZERO
archived: bool = False archived: bool = False
note: str = "" note: str = ""
packs: list[Pack] = field(default_factory=list)
price_history: list[PricePoint] = field(default_factory=list) price_history: list[PricePoint] = field(default_factory=list)
# --- фасовки ---
def uom_names(self) -> list[str]:
"""Базовая единица плюс все фасовки, в порядке для выпадающего списка."""
return [self.unit] + [p.name for p in self.packs]
def pack(self, uom: str) -> Pack | None:
return next((p for p in self.packs if p.name == uom), None)
def size_of(self, uom: str) -> Decimal:
"""Сколько базовых единиц в одной штуке выбранной фасовки."""
pack = self.pack(uom)
return pack.size if pack else ONE
def cost_for(self, uom: str) -> Decimal:
"""Себестоимость одной штуки выбранной фасовки."""
return m.money(self.cost_price * self.size_of(uom))
def retail_for(self, uom: str) -> Decimal:
"""Цена продажи одной штуки выбранной фасовки.
У фасовки цена своя: пачка обычно дешевле, чем те же штуки поодиночке.
Если цена не задана, считаем её от базовой.
"""
pack = self.pack(uom)
if pack is None:
return self.retail_price
if pack.retail_price > 0:
return pack.retail_price
return m.money(self.retail_price * pack.size)
@classmethod @classmethod
def from_dict(cls, d: dict) -> "Product": def from_dict(cls, d: dict) -> "Product":
return cls( return cls(
@ -115,6 +181,7 @@ class Product:
retail_price=m.money(d.get("retail_price")), retail_price=m.money(d.get("retail_price")),
archived=bool(d.get("archived", False)), archived=bool(d.get("archived", False)),
note=d.get("note", ""), note=d.get("note", ""),
packs=[Pack.from_dict(p) for p in d.get("packs", [])],
price_history=[PricePoint.from_dict(p) for p in d.get("price_history", [])], price_history=[PricePoint.from_dict(p) for p in d.get("price_history", [])],
) )
@ -127,6 +194,7 @@ class Product:
"retail_price": m.dumps(self.retail_price), "retail_price": m.dumps(self.retail_price),
"archived": self.archived, "archived": self.archived,
"note": self.note, "note": self.note,
"packs": [p.to_dict() for p in self.packs],
"price_history": [p.to_dict() for p in self.price_history], "price_history": [p.to_dict() for p in self.price_history],
} }
@ -155,20 +223,60 @@ class Counterparty:
@dataclass @dataclass
class BatchLine: class DocumentLine:
"""Общее для строк закупки и продажи.
Количество и цена хранятся в той фасовке, которую выбрал пользователь
«2 пачки по 200 » так и остаётся двумя пачками по 200. Размер фасовки
сохраняется слепком: если пачку потом переопределят с 10 штук на 12,
уже записанные документы не должны поехать.
"""
product_id: str product_id: str
qty: Decimal qty: Decimal
unit_cost: Decimal uom: str = ""
uom_size: Decimal = ONE
def unit_name(self, product: "Product | None") -> str:
return self.uom or (product.unit if product else "")
@property
def base_qty(self) -> Decimal:
"""Количество в базовых единицах — в них считаются остатки и FIFO."""
return m.qty(self.qty * self.uom_size)
def _base_price(self, price: Decimal) -> Decimal:
"""Цена за базовую единицу. Без округления: делим 200 на 3 без потерь."""
if self.uom_size <= 0:
return m.ZERO
return price / self.uom_size
@staticmethod
def _uom_from_dict(d: dict) -> tuple[str, Decimal]:
size = m.qty(d.get("uom_size", 1))
return d.get("uom", ""), size if size > 0 else ONE
@dataclass
class BatchLine(DocumentLine):
unit_cost: Decimal = m.ZERO
@property @property
def total(self) -> Decimal: def total(self) -> Decimal:
return m.money(self.qty * self.unit_cost) return m.money(self.qty * self.unit_cost)
@property
def base_unit_cost(self) -> Decimal:
return self._base_price(self.unit_cost)
@classmethod @classmethod
def from_dict(cls, d: dict) -> "BatchLine": def from_dict(cls, d: dict) -> "BatchLine":
uom, size = cls._uom_from_dict(d)
return cls( return cls(
product_id=d["product_id"], product_id=d["product_id"],
qty=m.qty(d.get("qty")), qty=m.qty(d.get("qty")),
uom=uom,
uom_size=size,
unit_cost=m.money(d.get("unit_cost")), unit_cost=m.money(d.get("unit_cost")),
) )
@ -176,6 +284,8 @@ class BatchLine:
return { return {
"product_id": self.product_id, "product_id": self.product_id,
"qty": m.dumps(self.qty), "qty": m.dumps(self.qty),
"uom": self.uom,
"uom_size": m.dumps(self.uom_size),
"unit_cost": m.dumps(self.unit_cost), "unit_cost": m.dumps(self.unit_cost),
} }
@ -203,7 +313,8 @@ class Batch:
@property @property
def qty_total(self) -> Decimal: def qty_total(self) -> Decimal:
return m.qty(sum((line.qty for line in self.lines), m.ZERO)) """Всего базовых единиц в партии: 2 пачки по 10 — это 20 штук."""
return m.qty(sum((line.base_qty for line in self.lines), m.ZERO))
@classmethod @classmethod
def from_dict(cls, d: dict) -> "Batch": def from_dict(cls, d: dict) -> "Batch":
@ -233,20 +344,25 @@ class Batch:
@dataclass @dataclass
class SaleLine: class SaleLine(DocumentLine):
product_id: str unit_price: Decimal = m.ZERO
qty: Decimal
unit_price: Decimal
@property @property
def total(self) -> Decimal: def total(self) -> Decimal:
return m.money(self.qty * self.unit_price) return m.money(self.qty * self.unit_price)
@property
def base_unit_price(self) -> Decimal:
return self._base_price(self.unit_price)
@classmethod @classmethod
def from_dict(cls, d: dict) -> "SaleLine": def from_dict(cls, d: dict) -> "SaleLine":
uom, size = cls._uom_from_dict(d)
return cls( return cls(
product_id=d["product_id"], product_id=d["product_id"],
qty=m.qty(d.get("qty")), qty=m.qty(d.get("qty")),
uom=uom,
uom_size=size,
unit_price=m.money(d.get("unit_price")), unit_price=m.money(d.get("unit_price")),
) )
@ -254,6 +370,8 @@ class SaleLine:
return { return {
"product_id": self.product_id, "product_id": self.product_id,
"qty": m.dumps(self.qty), "qty": m.dumps(self.qty),
"uom": self.uom,
"uom_size": m.dumps(self.uom_size),
"unit_price": m.dumps(self.unit_price), "unit_price": m.dumps(self.unit_price),
} }
@ -435,7 +553,9 @@ class GitSettings:
В .git/config он не попадает иначе лежал бы открытым рядом с шифром. В .git/config он не попадает иначе лежал бы открытым рядом с шифром.
""" """
remote_url: str = "" # По умолчанию — репозиторий данных, а не исходников. Код и база
# намеренно живут врозь: пуш отправляет ветку целиком.
remote_url: str = "https://gt.ser.gay/kizya/food-records.git"
token: str = "" token: str = ""
branch: str = "main" branch: str = "main"
author_name: str = "food-market" author_name: str = "food-market"

View File

@ -1,8 +1,13 @@
"""Где лежат данные. """Где лежат данные.
Собранный exe кладётся в корень клона репозитория, а база в data/ рядом Код и база живут в **разных** репозиториях. Исходники в food-market,
с ним. При запуске из исходников корнем считается сама папка проекта, поэтому зашифрованная база в food-records. Смешивать их нельзя: пуш отправляет всю
разработка и собранное приложение работают с одним и тем же файлом. ветку целиком, поэтому в общем репозитории история данных неизбежно тащила бы
за собой историю программы.
На диске это выглядит так: exe лежит в корне, а рядом с ним папка data
самостоятельный клон репозитория с данными, со своим .git. Исходники папку
data игнорируют полностью.
""" """
from __future__ import annotations from __future__ import annotations
@ -13,6 +18,8 @@ from pathlib import Path
VAULT_NAME = "vault.fmdb" VAULT_NAME = "vault.fmdb"
DATA_DIR_NAME = "data" DATA_DIR_NAME = "data"
DEFAULT_DATA_REMOTE = "https://gt.ser.gay/kizya/food-records.git"
def app_root() -> Path: def app_root() -> Path:
if getattr(sys, "frozen", False): if getattr(sys, "frozen", False):
@ -21,6 +28,7 @@ def app_root() -> Path:
def data_dir() -> Path: def data_dir() -> Path:
"""Корень репозитория с данными. Внутри — только база и её служебные файлы."""
return app_root() / DATA_DIR_NAME return app_root() / DATA_DIR_NAME
@ -29,8 +37,8 @@ def vault_path() -> Path:
def vault_rel_posix() -> str: def vault_rel_posix() -> str:
"""Путь базы для git — всегда со слешами и относительно корня репозитория.""" """Путь базы внутри репозитория данных. База лежит в его корне."""
return f"{DATA_DIR_NAME}/{VAULT_NAME}" return VAULT_NAME
def backup_path() -> Path: def backup_path() -> Path:

View File

@ -46,7 +46,29 @@ def _migrate_0_to_1(raw: dict) -> dict:
return raw return raw
_MIGRATIONS = {0: _migrate_0_to_1} def _migrate_1_to_2(raw: dict) -> dict:
"""Появились фасовки товара.
Всё, что заведено раньше, считается записанным в базовых единицах:
фасовка пустая, множитель единица. Поведение старых документов при этом
не меняется ни на копейку.
"""
for product in raw.get("products", []):
product.setdefault("packs", [])
for document, key in (
*((b, "lines") for b in raw.get("batches", [])),
*((s, "lines") for s in raw.get("sales", [])),
):
for line in document.get(key, []):
line.setdefault("uom", "")
line.setdefault("uom_size", "1")
raw["schema_version"] = 2
return raw
_MIGRATIONS = {0: _migrate_0_to_1, 1: _migrate_1_to_2}
def migrate(raw: dict) -> dict: def migrate(raw: dict) -> dict:

View File

@ -59,7 +59,7 @@ class BatchDialog(QDialog):
layout.addWidget(w.label("Что взял", "h2")) layout.addWidget(w.label("Что взял", "h2"))
products = [p for p in ctx.vault.doc.products if not p.archived] products = [p for p in ctx.vault.doc.products if not p.archived]
self.lines = w.LinesEditor( self.lines = w.LinesEditor(
products, "Себестоимость", lambda p: p.cost_price, ctx.currency products, "Себестоимость", lambda p, uom: p.cost_for(uom), ctx.currency
) )
if batch: if batch:
self.lines.set_lines(batch.lines, "unit_cost") self.lines.set_lines(batch.lines, "unit_cost")

View File

@ -123,6 +123,7 @@ class DebtsPage(QWidget):
for sale in sorted(debtor.sales, key=lambda s: s.date): for sale in sorted(debtor.sales, key=lambda s: s.date):
what = ", ".join( what = ", ".join(
f"{doc.product_name(line.product_id)} × {m.fmt_qty(line.qty)} " f"{doc.product_name(line.product_id)} × {m.fmt_qty(line.qty)} "
f"{line.unit_name(doc.product(line.product_id))}"
for line in sale.lines for line in sale.lines
) )
child = QTreeWidgetItem( child = QTreeWidgetItem(

View File

@ -10,6 +10,8 @@ from PySide6.QtWidgets import (
QLineEdit, QLineEdit,
QMessageBox, QMessageBox,
QPlainTextEdit, QPlainTextEdit,
QTableWidget,
QTableWidgetItem,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
@ -20,6 +22,90 @@ from . import theme
from . import widgets as w from . import widgets as w
class PacksEditor(QWidget):
"""Фасовки товара: как называется, сколько базовых единиц внутри и почём.
Пачка печенья 10 шт за 200 и та же печенька поштучно за 30 это один
товар с двумя фасовками. Остатки при этом считаются в штуках, поэтому
поштучные продажи вычитаются из купленных пачек.
"""
COL_NAME, COL_SIZE, COL_PRICE = range(3)
def __init__(self, currency: str, base_unit: str, packs=(), parent=None):
super().__init__(parent)
self.currency = currency
self.base_unit = base_unit
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
self.table = QTableWidget(0, 3)
self.table.setHorizontalHeaderLabels(["Название", f"Сколько {base_unit}", "Цена за фасовку"])
self.table.verticalHeader().setVisible(False)
self.table.verticalHeader().setDefaultSectionSize(36)
header = self.table.horizontalHeader()
header.setSectionResizeMode(self.COL_NAME, QHeaderView.Stretch)
for column, width in ((self.COL_SIZE, 130), (self.COL_PRICE, 145)):
header.setSectionResizeMode(column, QHeaderView.Interactive)
self.table.setColumnWidth(column, width)
self.table.setMaximumHeight(170)
layout.addWidget(self.table)
layout.addWidget(
w.row(
w.button("+ Фасовка", on_click=lambda: self.add_pack()),
w.button("Удалить", role="danger", on_click=self.remove_current),
None,
)
)
for pack in packs:
self.add_pack(pack.name, pack.size, pack.retail_price)
def set_base_unit(self, unit: str) -> None:
self.base_unit = unit or "шт"
self.table.setHorizontalHeaderItem(
self.COL_SIZE, QTableWidgetItem(f"Сколько {self.base_unit}")
)
def add_pack(self, name: str = "", size=1, price=0) -> None:
r = self.table.rowCount()
self.table.insertRow(r)
name_edit = QLineEdit(name)
name_edit.setPlaceholderText("пачка")
self.table.setCellWidget(r, self.COL_NAME, name_edit)
size_spin = w.QtySpin()
size_spin.set_decimal(size)
self.table.setCellWidget(r, self.COL_SIZE, size_spin)
price_spin = w.MoneySpin(self.currency)
price_spin.set_decimal(price)
self.table.setCellWidget(r, self.COL_PRICE, price_spin)
def remove_current(self) -> None:
r = self.table.currentRow()
if r >= 0:
self.table.removeRow(r)
def packs(self) -> list[dict]:
result = []
for r in range(self.table.rowCount()):
name = self.table.cellWidget(r, self.COL_NAME).text().strip()
if not name:
continue
result.append(
{
"name": name,
"size": self.table.cellWidget(r, self.COL_SIZE).value_decimal(),
"retail_price": self.table.cellWidget(r, self.COL_PRICE).value_decimal(),
}
)
return result
class ProductDialog(QDialog): class ProductDialog(QDialog):
"""Карточка товара. Цены здесь только при создании — потом через историю.""" """Карточка товара. Цены здесь только при создании — потом через историю."""
@ -27,7 +113,7 @@ class ProductDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.product = product self.product = product
self.setWindowTitle("Товар" if product else "Новый товар") self.setWindowTitle("Товар" if product else "Новый товар")
self.setMinimumWidth(400) self.setMinimumWidth(520)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(20, 16, 20, 16) layout.setContentsMargins(20, 16, 20, 16)
@ -41,6 +127,7 @@ class ProductDialog(QDialog):
form.addRow("Название", self.name) form.addRow("Название", self.name)
self.unit = QLineEdit(product.unit if product else "шт") self.unit = QLineEdit(product.unit if product else "шт")
self.unit.setToolTip("Базовая единица: в ней считаются остатки и себестоимость.")
form.addRow("Единица", self.unit) form.addRow("Единица", self.unit)
if product is None: if product is None:
@ -52,8 +139,24 @@ class ProductDialog(QDialog):
self.note = QPlainTextEdit(product.note if product else "") self.note = QPlainTextEdit(product.note if product else "")
self.note.setMaximumHeight(70) self.note.setMaximumHeight(70)
form.addRow("Заметка", self.note) form.addRow("Заметка", self.note)
layout.addLayout(form) layout.addLayout(form)
layout.addWidget(w.label("Фасовки", "h2"))
layout.addWidget(
w.label(
"Если товар берётся упаковками, а продаётся и поштучно — заведи фасовку. "
"Цены и себестоимость выше указываются за одну базовую единицу.",
"dim",
)
)
layout.itemAt(layout.count() - 1).widget().setWordWrap(True)
self.packs = PacksEditor(
currency, self.unit.text() or "шт", product.packs if product else ()
)
self.unit.textChanged.connect(self.packs.set_base_unit)
layout.addWidget(self.packs)
if product is not None: if product is not None:
layout.addWidget( layout.addWidget(
w.label("Цены меняются отдельной кнопкой — так сохраняется их история.", "dim") w.label("Цены меняются отдельной кнопкой — так сохраняется их история.", "dim")
@ -140,8 +243,15 @@ class ProductsPage(QWidget):
) )
self.table = w.table( self.table = w.table(
["Название", "Ед.", "Себестоимость", "Цена продажи", "Наценка", "Остаток", "Статус"] [
"Название", "Ед.", "Фасовки", "Себестоимость",
"Цена продажи", "Наценка", "Остаток", "Статус",
]
) )
# По содержимому список фасовок разрастается и отжимает название
# в многоточие. Ограничиваем его и отдаём остаток названию.
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Interactive)
self.table.setColumnWidth(2, 330)
self.table.doubleClicked.connect(self.edit_prices) self.table.doubleClicked.connect(self.edit_prices)
layout.addWidget(self.table, 1) layout.addWidget(self.table, 1)
@ -163,10 +273,16 @@ class ProductsPage(QWidget):
margin = m.money(product.retail_price - product.cost_price) margin = m.money(product.retail_price - product.cost_price)
left = stock.get(product.id, m.ZERO) left = stock.get(product.id, m.ZERO)
packs = ", ".join(
f"{p.name} = {m.fmt_qty(p.size)} {product.unit} "
f"по {m.fmt_money(p.retail_price, currency)}"
for p in product.packs
)
rows.append( rows.append(
[ [
w.text_item(product.name, theme.MUTED if product.archived else ""), w.text_item(product.name, theme.MUTED if product.archived else ""),
w.text_item(product.unit), w.text_item(product.unit),
w.text_item(packs, theme.TEXT_DIM),
w.sortable_num_item(m.fmt_money(product.cost_price, currency), product.cost_price), 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(product.retail_price, currency), product.retail_price),
w.sortable_num_item( w.sortable_num_item(
@ -196,7 +312,7 @@ class ProductsPage(QWidget):
if dialog.exec() != QDialog.Accepted: if dialog.exec() != QDialog.Accepted:
return return
try: try:
journal.create_product( product = journal.create_product(
self.ctx.vault, self.ctx.vault,
dialog.name.text(), dialog.name.text(),
dialog.unit.text(), dialog.unit.text(),
@ -204,6 +320,7 @@ class ProductsPage(QWidget):
dialog.retail.value_decimal(), dialog.retail.value_decimal(),
dialog.note.toPlainText(), dialog.note.toPlainText(),
) )
journal.set_packs(self.ctx.vault, product.id, dialog.packs.packs())
except journal.ValidationError as exc: except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc)) QMessageBox.warning(self, "Не получилось", str(exc))
return return
@ -225,6 +342,7 @@ class ProductsPage(QWidget):
unit=dialog.unit.text(), unit=dialog.unit.text(),
note=dialog.note.toPlainText(), note=dialog.note.toPlainText(),
) )
journal.set_packs(self.ctx.vault, product.id, dialog.packs.packs())
except journal.ValidationError as exc: except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc)) QMessageBox.warning(self, "Не получилось", str(exc))
return return

View File

@ -26,16 +26,17 @@ from . import widgets as w
def price_source_for(kind: str): def price_source_for(kind: str):
"""Откуда берётся цена по умолчанию для этого типа выбытия. """Откуда берётся цена по умолчанию для этого типа выбытия и фасовки.
Ради этого типы и заведены: друг платит себестоимость, съеденное не стоит Ради этого типы и заведены: друг платит себестоимость, съеденное не стоит
ничего, розница идёт по цене продажи. ничего, розница идёт по цене продажи. Цена всегда за одну штуку выбранной
фасовки пачка стоит своих денег, а не десяти поштучных цен.
""" """
if kind == KIND_FRIEND: if kind == KIND_FRIEND:
return lambda product: product.cost_price return lambda product, uom: product.cost_for(uom)
if kind in CONSUMPTION_KINDS: if kind in CONSUMPTION_KINDS:
return lambda product: m.ZERO return lambda product, uom: m.ZERO
return lambda product: product.retail_price return lambda product, uom: product.retail_for(uom)
class SaleDialog(QDialog): class SaleDialog(QDialog):
@ -176,6 +177,7 @@ class SalesPage(QWidget):
layout.addWidget( layout.addWidget(
w.row( w.row(
w.button("+ Продажа", "primary", self.create), w.button("+ Продажа", "primary", self.create),
w.button("Быстрый ввод за период", on_click=self.quick_entry),
w.button("Изменить", on_click=self.edit), w.button("Изменить", on_click=self.edit),
w.button("Принять оплату", on_click=self.take_payment), w.button("Принять оплату", on_click=self.take_payment),
w.button("Удалить", "danger", self.delete), w.button("Удалить", "danger", self.delete),
@ -204,6 +206,7 @@ class SalesPage(QWidget):
what = ", ".join( what = ", ".join(
f"{doc.product_name(line.product_id)} × {m.fmt_qty(line.qty)} " f"{doc.product_name(line.product_id)} × {m.fmt_qty(line.qty)} "
f"{line.unit_name(doc.product(line.product_id))}"
for line in sale.lines for line in sale.lines
) )
debt = sale.debt debt = sale.debt
@ -261,6 +264,21 @@ class SalesPage(QWidget):
return return
self.ctx.changed() self.ctx.changed()
def quick_entry(self) -> None:
"""Внести пачку продаж за прошедший период."""
if not [p for p in self.ctx.vault.doc.products if not p.archived]:
QMessageBox.information(self, "Нет товаров", "Сначала заведи булки на вкладке «Товары».")
return
from .quick_sales import QuickSalesDialog
dialog = QuickSalesDialog(self.ctx, parent=self)
if dialog.exec() != QDialog.Accepted:
return
self.ctx.changed()
QMessageBox.information(self, "Готово", f"Записано продаж: {dialog.created}.")
def edit(self) -> None: def edit(self) -> None:
sale = self._selected() sale = self._selected()
if sale is None: if sale is None:

443
app/ui/quick_sales.py Normal file
View File

@ -0,0 +1,443 @@
"""Быстрый ввод продаж за период.
Обычная форма продажи хороша, когда записываешь одну сделку. Когда надо внести
историю за прошедший период, она превращается в пытку: на каждую продажу
открыть, заполнить, закрыть.
Здесь каждая строка таблицы отдельная продажа. Период задаётся сверху, дата
новой строки наследуется от предыдущей, всё вводится с клавиатуры, а запись
происходит одним действием в конце.
"""
from __future__ import annotations
from datetime import date, timedelta
from decimal import Decimal
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QKeySequence, QShortcut
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QHeaderView,
QMessageBox,
QPushButton,
QTableWidget,
QVBoxLayout,
)
from .. import journal
from .. import money as m
from ..models import CONSUMPTION_KINDS, SALE_KIND_LABELS, SALE_KINDS
from . import theme
from . import widgets as w
from .page_sales import price_source_for
DEFAULT_PERIOD_DAYS = 30
COL_DATE, COL_KIND, COL_WHO, COL_PRODUCT, COL_QTY, COL_UOM, COL_PRICE, COL_PAID, COL_TOTAL = range(9)
HEADERS = ["Дата", "Тип", "Кому", "Товар", "Кол-во", "Фасовка", "Цена", "Оплачено", "Сумма"]
# Пользователь поправил оплату руками — больше не подставляем её автоматически.
TOUCHED = "paid_touched"
class QuickSalesDialog(QDialog):
def __init__(self, ctx, parent=None):
super().__init__(parent)
self.ctx = ctx
self.products = [p for p in ctx.vault.doc.products if not p.archived]
self.setWindowTitle("Быстрый ввод продаж")
self.setMinimumSize(1120, 620)
self.resize(1320, 700)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 16, 20, 16)
layout.setSpacing(10)
layout.addWidget(w.heading("Быстрый ввод продаж"))
layout.addLayout(self._period_row())
self.hint = w.label("", "dim")
self.hint.setWordWrap(True)
layout.addWidget(self.hint)
self.table = QTableWidget(0, len(HEADERS))
self.table.setHorizontalHeaderLabels(HEADERS)
self.table.verticalHeader().setVisible(False)
# Строки целиком из полей ввода: по содержимому Qt делает их слишком
# низкими, и текст обрезается.
self.table.verticalHeader().setDefaultSectionSize(36)
header = self.table.horizontalHeader()
header.setSectionResizeMode(COL_PRODUCT, QHeaderView.Stretch)
# Ширины заданы руками: ResizeToContents схлопывает пустой выпадающий
# список до нечитаемого огрызка.
for column, width in (
(COL_DATE, 110), (COL_KIND, 200), (COL_WHO, 135), (COL_QTY, 85),
(COL_UOM, 100), (COL_PRICE, 115), (COL_PAID, 115), (COL_TOTAL, 105),
):
header.setSectionResizeMode(column, QHeaderView.Interactive)
self.table.setColumnWidth(column, width)
layout.addWidget(self.table, 1)
self.total_label = w.label("", "h2")
layout.addWidget(
w.row(
w.button("+ Строка (Enter)", on_click=self.add_row),
w.button("Дублировать (Ctrl+D)", on_click=self.duplicate_row),
w.button("Удалить строку", role="danger", on_click=self.remove_row),
None,
self.total_label,
)
)
layout.addWidget(
w.row(
w.label("Записать всё — Ctrl+Enter", "dim"),
None,
w.button("Отмена", on_click=self.reject),
w.button("Записать всё", "primary", self.accept),
)
)
# Enter в диалоге по умолчанию нажимает кнопку — здесь он нужен для
# добавления строки, поэтому автонажатие отключаем у всех кнопок.
for widget in self.findChildren(QPushButton):
widget.setAutoDefault(False)
widget.setDefault(False)
QShortcut(QKeySequence(Qt.Key_Return), self, activated=self.add_row)
QShortcut(QKeySequence(Qt.Key_Enter), self, activated=self.add_row)
QShortcut(QKeySequence("Ctrl+D"), self, activated=self.duplicate_row)
QShortcut(QKeySequence("Ctrl+Return"), self, activated=self.accept)
QShortcut(QKeySequence("Ctrl+Enter"), self, activated=self.accept)
self._update_hint()
if self.products:
self.add_row()
# --- период ---
def _period_row(self):
today = date.today()
self.period_from = w.DateInput(today - timedelta(days=DEFAULT_PERIOD_DAYS))
self.period_to = w.DateInput(today)
self.period_from.dateChanged.connect(self._update_hint)
self.period_to.dateChanged.connect(self._update_hint)
holder = QVBoxLayout()
holder.addWidget(
w.row(
w.label("Вношу данные за период:"),
self.period_from,
w.label(""),
self.period_to,
None,
)
)
return holder
def _update_hint(self) -> None:
start, end = self.period_from.get_date(), self.period_to.get_date()
if end < start:
self.hint.setText("Конец периода раньше начала — поправь даты.")
return
# Показываем, что за период уже записано: дешёвая страховка от того,
# чтобы не внести один и тот же месяц дважды.
existing = [s for s in self.ctx.vault.doc.sales if start <= s.date <= end]
already = (
f"За этот период уже записано продаж: {len(existing)}."
if existing
else "За этот период продаж ещё нет."
)
self.hint.setText(
f"{already} Дата новой строки наследуется от предыдущей — "
"меняй её, когда переходишь к следующему дню."
)
self._recalc()
# --- строки ---
def _last_row_state(self) -> dict:
r = self.table.rowCount() - 1
if r < 0:
return {
"date": self.period_from.get_date(),
"kind": SALE_KINDS[0],
"who": "",
"product_id": None,
"qty": 1,
"uom": "",
"price": None,
}
return {
"date": self.table.cellWidget(r, COL_DATE).get_date(),
"kind": self.table.cellWidget(r, COL_KIND).currentData(),
"who": self.table.cellWidget(r, COL_WHO).currentText(),
"product_id": None,
"qty": 1,
"uom": "",
"price": None,
}
def add_row(self, state: dict | None = None) -> None:
if not self.products:
return
state = state or self._last_row_state()
r = self.table.rowCount()
self.table.insertRow(r)
date_widget = w.DateInput(state["date"])
date_widget.dateChanged.connect(self._recalc)
self.table.setCellWidget(r, COL_DATE, date_widget)
kind = QComboBox()
for value in SALE_KINDS:
kind.addItem(SALE_KIND_LABELS[value], value)
kind.setCurrentIndex(SALE_KINDS.index(state["kind"]))
kind.currentIndexChanged.connect(lambda _, rr=r: self._on_kind_changed(rr))
self.table.setCellWidget(r, COL_KIND, kind)
who = QComboBox()
who.setEditable(True)
who.setInsertPolicy(QComboBox.NoInsert)
who.addItem("")
for cp in sorted(self.ctx.vault.doc.counterparties, key=lambda c: c.name.lower()):
who.addItem(cp.name)
who.setCurrentText(state["who"])
who.setToolTip("Незнакомое имя будет заведено как новый контрагент.")
self.table.setCellWidget(r, COL_WHO, who)
product = w.ProductCombo(self.products)
if state["product_id"]:
product.select_product(state["product_id"])
product.currentIndexChanged.connect(lambda _, rr=r: self._on_product_changed(rr))
self.table.setCellWidget(r, COL_PRODUCT, product)
qty = w.QtySpin()
qty.set_decimal(state["qty"])
qty.valueChanged.connect(lambda _, rr=r: self._on_amount_changed(rr))
self.table.setCellWidget(r, COL_QTY, qty)
uom = QComboBox()
self.table.setCellWidget(r, COL_UOM, uom)
self._fill_uoms(r, product.current_product_id(), state["uom"])
uom.currentIndexChanged.connect(lambda _, rr=r: self._on_uom_changed(rr))
price = w.MoneySpin(self.ctx.currency)
self.table.setCellWidget(r, COL_PRICE, price)
price.valueChanged.connect(lambda _, rr=r: self._on_amount_changed(rr))
paid = w.MoneySpin(self.ctx.currency)
paid.setProperty(TOUCHED, False)
paid.valueChanged.connect(lambda _, rr=r: self._on_paid_edited(rr))
self.table.setCellWidget(r, COL_PAID, paid)
self.table.setItem(r, COL_TOTAL, w.text_item(""))
self._apply_price(r)
self._on_kind_changed(r)
self.table.setCurrentCell(r, COL_PRODUCT)
product.setFocus()
def duplicate_row(self) -> None:
r = self.table.currentRow()
if r < 0:
return
self.add_row(
{
"date": self.table.cellWidget(r, COL_DATE).get_date(),
"kind": self.table.cellWidget(r, COL_KIND).currentData(),
"who": self.table.cellWidget(r, COL_WHO).currentText(),
"product_id": self.table.cellWidget(r, COL_PRODUCT).current_product_id(),
"qty": self.table.cellWidget(r, COL_QTY).value_decimal(),
"uom": self.table.cellWidget(r, COL_UOM).currentText(),
"price": self.table.cellWidget(r, COL_PRICE).value_decimal(),
}
)
def remove_row(self) -> None:
r = self.table.currentRow()
if r >= 0:
self.table.removeRow(r)
self._recalc()
# --- реакции ---
def _product(self, product_id):
return next((p for p in self.products if p.id == product_id), None)
def _fill_uoms(self, r: int, product_id, selected: str = "") -> None:
combo = self.table.cellWidget(r, COL_UOM)
product = self._product(product_id)
names = product.uom_names() if product else []
combo.blockSignals(True)
combo.clear()
combo.addItems(names)
if selected and selected in names:
combo.setCurrentText(selected)
combo.blockSignals(False)
combo.setEnabled(len(names) > 1)
def _apply_price(self, r: int) -> None:
product = self._product(self.table.cellWidget(r, COL_PRODUCT).current_product_id())
if product is None:
return
kind = self.table.cellWidget(r, COL_KIND).currentData()
uom = self.table.cellWidget(r, COL_UOM).currentText()
self.table.cellWidget(r, COL_PRICE).set_decimal(price_source_for(kind)(product, uom))
def _on_product_changed(self, r: int) -> None:
self._fill_uoms(r, self.table.cellWidget(r, COL_PRODUCT).current_product_id())
self._apply_price(r)
self._on_amount_changed(r)
def _on_uom_changed(self, r: int) -> None:
self._apply_price(r)
self._on_amount_changed(r)
def _on_kind_changed(self, r: int) -> None:
kind = self.table.cellWidget(r, COL_KIND).currentData()
consumption = kind in CONSUMPTION_KINDS
self._apply_price(r)
paid = self.table.cellWidget(r, COL_PAID)
paid.setEnabled(not consumption)
self.table.cellWidget(r, COL_PRICE).setEnabled(not consumption)
if consumption:
paid.setProperty(TOUCHED, False)
self._on_amount_changed(r)
def _on_amount_changed(self, r: int) -> None:
paid = self.table.cellWidget(r, COL_PAID)
# Полная оплата — самый частый случай. Если её правили руками, больше
# не трогаем: значит, там долг.
if not paid.property(TOUCHED):
paid.blockSignals(True)
paid.set_decimal(self._row_total(r))
paid.blockSignals(False)
self._recalc()
def _on_paid_edited(self, r: int) -> None:
self.table.cellWidget(r, COL_PAID).setProperty(TOUCHED, True)
self._recalc()
def _row_total(self, r: int) -> Decimal:
kind = self.table.cellWidget(r, COL_KIND).currentData()
if kind in CONSUMPTION_KINDS:
return m.ZERO
qty = self.table.cellWidget(r, COL_QTY).value_decimal()
price = self.table.cellWidget(r, COL_PRICE).value_decimal()
return m.money(qty * price)
def _recalc(self) -> None:
start, end = self.period_from.get_date(), self.period_to.get_date()
total = m.ZERO
outside = 0
for r in range(self.table.rowCount()):
row_total = self._row_total(r)
total += row_total
item = self.table.item(r, COL_TOTAL)
if item is not None:
item.setText(m.fmt_money(row_total, self.ctx.currency))
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
# Дата вне периода — не ошибка, но стоит показать: скорее всего опечатка.
row_date = self.table.cellWidget(r, COL_DATE).get_date()
off = not (start <= row_date <= end)
outside += off
self.table.cellWidget(r, COL_DATE).setStyleSheet(
f"color: {theme.WARN};" if off else ""
)
suffix = f" • вне периода: {outside}" if outside else ""
self.total_label.setText(
f"Строк: {self.table.rowCount()} • на {m.fmt_money(total, self.ctx.currency)}{suffix}"
)
# --- запись ---
def collect(self) -> list[dict]:
"""Собрать строки в описания продаж. Пустые строки просто пропускаются."""
result = []
for r in range(self.table.rowCount()):
product_id = self.table.cellWidget(r, COL_PRODUCT).current_product_id()
qty = self.table.cellWidget(r, COL_QTY).value_decimal()
if not product_id or qty <= 0:
continue
kind = self.table.cellWidget(r, COL_KIND).currentData()
consumption = kind in CONSUMPTION_KINDS
price = m.ZERO if consumption else self.table.cellWidget(r, COL_PRICE).value_decimal()
paid = m.ZERO if consumption else self.table.cellWidget(r, COL_PAID).value_decimal()
result.append(
{
"date": self.table.cellWidget(r, COL_DATE).get_date(),
"kind": kind,
"who": self.table.cellWidget(r, COL_WHO).currentText().strip(),
"line": {
"product_id": product_id,
"qty": qty,
"uom": self.table.cellWidget(r, COL_UOM).currentText(),
"unit_price": price,
},
"paid": paid,
}
)
return result
def accept(self) -> None:
rows = self.collect()
if not rows:
QMessageBox.information(self, "Пусто", "Заполни хотя бы одну строку.")
return
try:
created = write_sales(self.ctx.vault, rows)
except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc))
return
self.created = created
super().accept()
def write_sales(vault, rows: list[dict]) -> int:
"""Записать собранные строки как отдельные продажи.
Незнакомое имя в колонке «Кому» заводится как новый контрагент: при вводе
истории останавливаться и создавать людей вручную лишняя морока.
"""
known = {c.name.casefold(): c.id for c in vault.doc.counterparties}
created = 0
for row in rows:
counterparty_id = None
name = row["who"]
if name:
key = name.casefold()
if key not in known:
known[key] = journal.create_counterparty(vault, name).id
counterparty_id = known[key]
journal.create_sale(
vault,
row["date"],
row["kind"],
[row["line"]],
counterparty_id=counterparty_id,
paid_amount=row["paid"],
)
created += 1
return created

View File

@ -6,7 +6,7 @@
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import Qt from PySide6.QtCore import QLocale, Qt
from PySide6.QtGui import QColor, QPalette from PySide6.QtGui import QColor, QPalette
BG = "#1c1f24" BG = "#1c1f24"
@ -33,6 +33,9 @@ STATUS_COLORS = {
def apply(app) -> None: def apply(app) -> None:
app.setStyle("Fusion") app.setStyle("Fusion")
# Иначе поля ввода показывают «1,800.00», а таблицы рядом — «1 800,00».
QLocale.setDefault(QLocale(QLocale.Russian, QLocale.Russia))
palette = QPalette() palette = QPalette()
palette.setColor(QPalette.Window, QColor(BG)) palette.setColor(QPalette.Window, QColor(BG))
palette.setColor(QPalette.WindowText, QColor(TEXT)) palette.setColor(QPalette.WindowText, QColor(TEXT))

View File

@ -105,6 +105,18 @@ class ProductCombo(QComboBox):
completer.setCompletionMode(QCompleter.PopupCompletion) completer.setCompletionMode(QCompleter.PopupCompletion)
for product in products: for product in products:
self.addItem(product.name, product.id) self.addItem(product.name, product.id)
self.currentIndexChanged.connect(lambda _: self._show_beginning())
self._show_beginning()
def _show_beginning(self) -> None:
"""Показывать начало названия, а не хвост.
В редактируемом списке курсор встаёт в конец строки, и в узкой колонке
«Печенье овсяное» превращается в «е овсяное».
"""
edit = self.lineEdit()
if edit is not None:
edit.setCursorPosition(0)
def current_product_id(self) -> str | None: def current_product_id(self) -> str | None:
return self.currentData() return self.currentData()
@ -113,6 +125,7 @@ class ProductCombo(QComboBox):
index = self.findData(product_id) index = self.findData(product_id)
if index >= 0: if index >= 0:
self.setCurrentIndex(index) self.setCurrentIndex(index)
self._show_beginning()
# --- мелкие сборки -------------------------------------------------------- # --- мелкие сборки --------------------------------------------------------
@ -284,11 +297,15 @@ class LinesEditor(QWidget):
Один виджет на оба случая: отличается только подпись колонки цены и то, Один виджет на оба случая: отличается только подпись колонки цены и то,
откуда берётся подстановка себестоимость или цена продажи. откуда берётся подстановка себестоимость или цена продажи.
Колонка фасовки позволяет в одной строке взять пачку, а в другой штуку
того же товара. Количество и цена вводятся в выбранной фасовке; в базовые
единицы всё пересчитывается уже в расчётах.
""" """
changed = Signal() changed = Signal()
COL_PRODUCT, COL_QTY, COL_PRICE, COL_TOTAL = range(4) COL_PRODUCT, COL_QTY, COL_UOM, COL_PRICE, COL_TOTAL = range(5)
def __init__(self, products, price_title: str, price_source, currency: str = "", parent=None): def __init__(self, products, price_title: str, price_source, currency: str = "", parent=None):
super().__init__(parent) super().__init__(parent)
@ -300,14 +317,23 @@ class LinesEditor(QWidget):
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6) layout.setSpacing(6)
self.table = QTableWidget(0, 4) self.table = QTableWidget(0, 5)
self.table.setHorizontalHeaderLabels(["Товар", "Кол-во", price_title, "Сумма"]) self.table.setHorizontalHeaderLabels(
["Товар", "Кол-во", "Фасовка", price_title, "Сумма"]
)
self.table.verticalHeader().setVisible(False) self.table.verticalHeader().setVisible(False)
# Строки состоят из полей ввода — по содержимому они выходят слишком
# низкими и текст обрезается.
self.table.verticalHeader().setDefaultSectionSize(36)
header = self.table.horizontalHeader() header = self.table.horizontalHeader()
header.setSectionResizeMode(self.COL_PRODUCT, QHeaderView.Stretch) header.setSectionResizeMode(self.COL_PRODUCT, QHeaderView.Stretch)
for column in (self.COL_QTY, self.COL_PRICE, self.COL_TOTAL): for column, width in (
header.setSectionResizeMode(column, QHeaderView.ResizeToContents) (self.COL_QTY, 95), (self.COL_UOM, 115), (self.COL_PRICE, 130), (self.COL_TOTAL, 120)
self.table.setMinimumHeight(180) ):
header.setSectionResizeMode(column, QHeaderView.Interactive)
self.table.setColumnWidth(column, width)
self.table.setMinimumHeight(190)
layout.addWidget(self.table) layout.addWidget(self.table)
self.total_label = label("", "h2") self.total_label = label("", "h2")
@ -322,7 +348,7 @@ class LinesEditor(QWidget):
# --- содержимое --- # --- содержимое ---
def add_line(self, product_id: str | None = None, quantity=1, price=None) -> None: def add_line(self, product_id: str | None = None, quantity=1, price=None, uom: str = "") -> None:
if not self.products: if not self.products:
return return
@ -340,9 +366,14 @@ class LinesEditor(QWidget):
qty_spin.valueChanged.connect(self._recalc) qty_spin.valueChanged.connect(self._recalc)
self.table.setCellWidget(r, self.COL_QTY, qty_spin) self.table.setCellWidget(r, self.COL_QTY, qty_spin)
uom_combo = QComboBox()
self.table.setCellWidget(r, self.COL_UOM, uom_combo)
self._fill_uoms(r, combo.current_product_id(), uom)
uom_combo.currentIndexChanged.connect(lambda _, rr=r: self._on_uom_changed(rr))
price_spin = MoneySpin(self.currency) price_spin = MoneySpin(self.currency)
price_spin.set_decimal( price_spin.set_decimal(
price if price is not None else self._suggested_price(combo.current_product_id()) price if price is not None else self._suggested_price(combo.current_product_id(), uom)
) )
price_spin.valueChanged.connect(self._recalc) price_spin.valueChanged.connect(self._recalc)
self.table.setCellWidget(r, self.COL_PRICE, price_spin) self.table.setCellWidget(r, self.COL_PRICE, price_spin)
@ -359,7 +390,7 @@ class LinesEditor(QWidget):
def set_lines(self, lines: list, value_attr: str) -> None: def set_lines(self, lines: list, value_attr: str) -> None:
self.table.setRowCount(0) self.table.setRowCount(0)
for line in lines: for line in lines:
self.add_line(line.product_id, line.qty, getattr(line, value_attr)) self.add_line(line.product_id, line.qty, getattr(line, value_attr), line.uom)
def lines(self, value_key: str) -> list[dict]: def lines(self, value_key: str) -> list[dict]:
result = [] result = []
@ -372,11 +403,37 @@ class LinesEditor(QWidget):
{ {
"product_id": product_id, "product_id": product_id,
"qty": self.table.cellWidget(r, self.COL_QTY).value_decimal(), "qty": self.table.cellWidget(r, self.COL_QTY).value_decimal(),
"uom": self._current_uom(r),
value_key: self.table.cellWidget(r, self.COL_PRICE).value_decimal(), value_key: self.table.cellWidget(r, self.COL_PRICE).value_decimal(),
} }
) )
return result return result
# --- фасовки ---
def _product(self, product_id: str | None):
return next((p for p in self.products if p.id == product_id), None)
def _current_uom(self, r: int) -> str:
combo = self.table.cellWidget(r, self.COL_UOM)
return combo.currentText() if combo else ""
def _fill_uoms(self, r: int, product_id: str | None, selected: str = "") -> None:
combo = self.table.cellWidget(r, self.COL_UOM)
if combo is None:
return
product = self._product(product_id)
names = product.uom_names() if product else []
combo.blockSignals(True)
combo.clear()
combo.addItems(names)
if selected and selected in names:
combo.setCurrentText(selected)
combo.blockSignals(False)
# Выбирать не из чего — не отвлекаем внимание.
combo.setEnabled(len(names) > 1)
def total(self) -> Decimal: def total(self) -> Decimal:
total = m.ZERO total = m.ZERO
for r in range(self.table.rowCount()): for r in range(self.table.rowCount()):
@ -389,23 +446,32 @@ class LinesEditor(QWidget):
def refresh_prices(self) -> None: def refresh_prices(self) -> None:
"""Пересобрать подстановку цен — например, после смены типа продажи.""" """Пересобрать подстановку цен — например, после смены типа продажи."""
for r in range(self.table.rowCount()): for r in range(self.table.rowCount()):
combo = self.table.cellWidget(r, self.COL_PRODUCT) self._apply_suggested_price(r)
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() self._recalc()
# --- внутреннее --- # --- внутреннее ---
def _suggested_price(self, product_id: str | None) -> Decimal: def _suggested_price(self, product_id: str | None, uom: str) -> Decimal:
product = next((p for p in self.products if p.id == product_id), None) product = self._product(product_id)
return self.price_source(product) if product else m.ZERO return self.price_source(product, uom) if product else m.ZERO
def _on_product_changed(self, r: int) -> None: def _apply_suggested_price(self, r: int) -> None:
combo = self.table.cellWidget(r, self.COL_PRODUCT) combo = self.table.cellWidget(r, self.COL_PRODUCT)
price_widget = self.table.cellWidget(r, self.COL_PRICE) price_widget = self.table.cellWidget(r, self.COL_PRICE)
if combo and price_widget: if combo and price_widget:
price_widget.set_decimal(self._suggested_price(combo.current_product_id())) price_widget.set_decimal(
self._suggested_price(combo.current_product_id(), self._current_uom(r))
)
def _on_product_changed(self, r: int) -> None:
combo = self.table.cellWidget(r, self.COL_PRODUCT)
# У нового товара свои фасовки, поэтому список пересобирается целиком.
self._fill_uoms(r, combo.current_product_id() if combo else None)
self._apply_suggested_price(r)
self._recalc()
def _on_uom_changed(self, r: int) -> None:
self._apply_suggested_price(r)
self._recalc() self._recalc()
def _recalc(self) -> None: def _recalc(self) -> None:

View File

@ -8,7 +8,14 @@ from app.storage import Vault
@pytest.fixture @pytest.fixture
def vault(tmp_path): def vault(tmp_path):
return Vault.create(tmp_path / "vault.fmdb", "pw") v = Vault.create(tmp_path / "vault.fmdb", "pw")
# По умолчанию адрес репозитория данных уже прописан, а тесты не должны
# ходить в сеть и уж тем более трогать настоящий репозиторий. Проверки
# синхронизации работают против локального bare-репо в test_gitsync.
v.doc.settings.git.remote_url = ""
v.doc.settings.git.enabled = False
v.save(force=True)
return v
@pytest.fixture @pytest.fixture

View File

@ -1,4 +1,7 @@
"""Тесты синхронизации против локального bare-репозитория — без сети.""" """Тесты синхронизации против локального bare-репозитория — без сети.
Репозиторий данных отдельный от репозитория с кодом, и база лежит в его корне.
"""
import subprocess import subprocess
@ -12,7 +15,8 @@ pytestmark = pytest.mark.skipif(
gitsync.git_executable() is None, reason="git не установлен" gitsync.git_executable() is None, reason="git не установлен"
) )
REL = "data/vault.fmdb" REL = "vault.fmdb"
SETUP = {".gitattributes", ".gitignore"}
def git(cwd, *args): def git(cwd, *args):
@ -33,13 +37,26 @@ def remote(tmp_path):
@pytest.fixture @pytest.fixture
def sync(tmp_path, remote): def sync(tmp_path, remote):
work = tmp_path / "work" work = tmp_path / "data"
(work / "data").mkdir(parents=True) work.mkdir(parents=True)
(work / "data" / "vault.fmdb").write_bytes(b"encrypted-v1") (work / REL).write_bytes(b"encrypted-v1")
settings = GitSettings(remote_url=remote.as_posix(), branch="main") settings = GitSettings(remote_url=remote.as_posix(), branch="main")
return GitSync(work, REL, settings) return GitSync(work, REL, settings)
@pytest.fixture
def settled(sync, remote):
"""Репозиторий после двух синхронизаций.
Первый коммит несёт служебные файлы, поэтому переписывать его нельзя.
Дальнейшие коммиты чистая база, и вот они уже схлопываются.
"""
sync.sync()
write_vault(sync, b"encrypted-v2")
sync.sync()
return sync
def write_vault(sync, content: bytes): def write_vault(sync, content: bytes):
(sync.repo_dir / REL).write_bytes(content) (sync.repo_dir / REL).write_bytes(content)
@ -48,10 +65,12 @@ def remote_log(remote):
return git(remote, "log", "--format=%H %s").splitlines() return git(remote, "log", "--format=%H %s").splitlines()
def remote_files(remote):
return set(git(remote, "ls-tree", "-r", "--name-only", "main").split())
def remote_vault(remote): def remote_vault(remote):
proc = subprocess.run( proc = subprocess.run(["git", "show", f"main:{REL}"], cwd=remote, capture_output=True)
["git", "show", f"main:{REL}"], cwd=remote, capture_output=True
)
assert proc.returncode == 0, proc.stderr assert proc.returncode == 0, proc.stderr
return proc.stdout return proc.stdout
@ -66,22 +85,33 @@ def test_first_sync_creates_repo_and_pushes(sync, remote):
assert len(remote_log(remote)) == 1 assert len(remote_log(remote)) == 1
def test_sync_without_changes_does_nothing(sync, remote): def test_sync_without_changes_does_nothing(settled, remote):
sync.sync() result = settled.sync()
result = sync.sync()
assert result.committed is False assert result.committed is False
assert result.pushed is False assert result.pushed is False
assert len(remote_log(remote)) == 1 assert len(remote_log(remote)) == 2
def test_commit_touches_only_the_vault(sync, remote): def test_data_repo_holds_nothing_but_the_vault(sync, remote):
"""Незакоммиченные правки исходников не должны уезжать вместе с данными.""" """Ради этого база и вынесена в свой репозиторий.
(sync.repo_dir / "app.py").write_text("print('работа в процессе')", encoding="utf-8")
Что бы ни оказалось в папке рядом в репозиторий данных уезжает только
сама база и пара служебных файлов.
"""
(sync.repo_dir / "main.py").write_text("код программы", encoding="utf-8")
(sync.repo_dir / "notes.txt").write_text("черновик", encoding="utf-8") (sync.repo_dir / "notes.txt").write_text("черновик", encoding="utf-8")
(sync.repo_dir / "app").mkdir()
(sync.repo_dir / "app" / "ledger.py").write_text("код", encoding="utf-8")
sync.sync() sync.sync()
files = git(remote, "show", "--name-only", "--format=", "main").split() assert remote_files(remote) == {REL} | SETUP
assert files == [REL]
def test_setup_files_protect_the_binary(sync, remote):
"""Без пометки binary git подставил бы CRLF и база перестала бы читаться."""
sync.sync()
attributes = git(remote, "show", "main:.gitattributes")
assert "vault.fmdb binary" in attributes
def test_commit_message_leaks_no_business_numbers(sync, remote): def test_commit_message_leaks_no_business_numbers(sync, remote):
@ -91,31 +121,81 @@ def test_commit_message_leaks_no_business_numbers(sync, remote):
assert subject.startswith("vault ") assert subject.startswith("vault ")
def seed_remote(tmp_path, remote, files: dict[str, bytes]) -> str:
"""Положить в удалённый репозиторий начальную историю."""
seed = tmp_path / "seed"
seed.mkdir()
git(seed, "init", "-b", "main")
for name, content in files.items():
(seed / name).write_bytes(content)
git(seed, "add", "-A")
git(seed, "-c", "user.email=s@s", "-c", "user.name=s", "commit", "-m", "начало")
git(seed, "push", remote.as_posix(), "main")
return git(seed, "rev-parse", "HEAD")
def test_first_sync_builds_on_existing_remote_history(sync, remote, tmp_path):
"""Репозиторий заводят с README — своя параллельная история не годится."""
seeded = seed_remote(tmp_path, remote, {"README.md": b"# food-records\n"})
result = sync.sync()
assert result.pushed is True
assert remote_vault(remote) == b"encrypted-v1"
assert "README.md" in remote_files(remote)
# История линейная: наш коммит лёг поверх, а не рядом.
log = remote_log(remote)
assert len(log) == 2
assert log[-1].startswith(seeded)
def test_existing_remote_vault_is_never_silently_replaced(sync, remote, tmp_path):
"""На сервере уже есть база, локально завели новую — обе настоящие."""
seed_remote(tmp_path, remote, {REL: b"encrypted-on-server"})
copy = sync.repo_dir / "vault.remote.fmdb"
with pytest.raises(Diverged):
sync.sync(remote_copy_path=copy)
assert (sync.repo_dir / REL).read_bytes() == b"encrypted-v1"
assert copy.read_bytes() == b"encrypted-on-server"
assert remote_vault(remote) == b"encrypted-on-server"
# --- схлопывание коммитов по дням ----------------------------------------- # --- схлопывание коммитов по дням -----------------------------------------
def test_same_day_syncs_collapse_into_one_commit(sync, remote): def test_same_day_syncs_collapse_into_one_commit(settled, remote):
"""Ради этого всё и затевалось: не 24 копии файла в сутки, а одна.""" """Ради этого всё и затевалось: не 24 копии файла в сутки, а одна."""
sync.sync() before = len(remote_log(remote))
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 for i in range(3, 6):
assert remote_vault(remote) == b"encrypted-v4" write_vault(settled, f"encrypted-v{i}".encode())
assert settled.sync().amended is True
assert len(remote_log(remote)) == before
assert remote_vault(remote) == b"encrypted-v5"
def test_squash_can_be_turned_off(sync, remote): def test_the_setup_commit_is_never_rewritten(sync, remote):
sync.settings.daily_squash = False """Первая вершина несёт не только базу, поэтому amend по ней запрещён."""
sync.sync() sync.sync()
write_vault(sync, b"encrypted-v2") write_vault(sync, b"encrypted-v2")
result = sync.sync()
result = sync.sync()
assert result.amended is False assert result.amended is False
assert len(remote_log(remote)) == 2 assert len(remote_log(remote)) == 2
def test_squash_can_be_turned_off(settled, remote):
settled.settings.daily_squash = False
before = len(remote_log(remote))
write_vault(settled, b"encrypted-v9")
assert settled.sync().amended is False
assert len(remote_log(remote)) == before + 1
def test_no_amend_when_head_is_not_on_the_server(sync): def test_no_amend_when_head_is_not_on_the_server(sync):
"""Переписывать можно только ту вершину, которая точно наша и уже запушена.""" """Переписывать можно только ту вершину, которая точно наша и уже запушена."""
sync.ensure_repo() sync.ensure_repo()
@ -146,88 +226,94 @@ def test_no_amend_over_a_commit_touching_other_files(sync):
assert sync._can_amend(remote=sync.head_sha()) is False assert sync._can_amend(remote=sync.head_sha()) is False
def test_yesterdays_commit_is_not_amended(sync, remote): def test_yesterdays_commit_is_not_amended(settled):
sync.sync() settled._run(["commit", "--amend", "-m", "vault 2020-01-01"])
sync._run(["commit", "--amend", "-m", "vault 2020-01-01"]) assert settled._can_amend(remote=settled.head_sha()) is False
assert sync._can_amend(remote=sync.head_sha()) is False
# --- защита от затирания чужой работы ------------------------------------- # --- защита от затирания чужой работы -------------------------------------
def test_stale_lease_is_refused_and_remote_survives(sync, remote, tmp_path): def clone_and_push(tmp_path, remote, content: bytes) -> str:
"""Смоделировать вторую машину, которая запушила своё."""
other = tmp_path / "other"
git(tmp_path, "clone", remote.as_posix(), "other")
(other / REL).write_bytes(content)
git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01")
git(other, "push", "origin", "main")
return git(other, "rev-parse", "HEAD")
def test_stale_lease_is_refused_and_remote_survives(settled, remote, tmp_path):
"""Ключевая проверка безопасности схлопывания. """Ключевая проверка безопасности схлопывания.
Между нашим fetch и push другая машина успела запушить своё. Мы уже Между нашим fetch и push другая машина успела запушить своё. Мы уже
переписали локальную вершину force-with-lease обязан это поймать. переписали локальную вершину force-with-lease обязан это поймать.
""" """
sync.sync() stale = settled.head_sha()
stale = sync.head_sha() theirs = clone_and_push(tmp_path, remote, b"encrypted-from-other-machine")
other = tmp_path / "other" write_vault(settled, b"encrypted-ours")
git(tmp_path, "clone", remote.as_posix(), "other") committed, amended, lease = settled._commit(remote=stale)
(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) assert (committed, amended, lease) == (True, True, stale)
with pytest.raises(Diverged): with pytest.raises(Diverged):
sync._push(amended=True, lease=lease) settled._push(amended=True, lease=lease)
# Чужой коммит на месте, наш форс его не снёс. # Чужой коммит на месте, наш форс его не снёс.
assert git(remote, "rev-parse", "main") == theirs assert git(remote, "rev-parse", "main") == theirs
assert remote_vault(remote) == b"encrypted-from-other-machine" assert remote_vault(remote) == b"encrypted-from-other-machine"
def test_remote_ahead_and_clean_fast_forwards(sync, remote, tmp_path): def test_remote_ahead_and_clean_fast_forwards(settled, remote, tmp_path):
sync.sync() clone_and_push(tmp_path, remote, b"encrypted-from-other-machine")
other = tmp_path / "other" result = settled.sync()
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 result.pulled is True
assert (sync.repo_dir / REL).read_bytes() == b"encrypted-from-other-machine" assert (settled.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): def test_edits_on_both_machines_raise_and_keep_both_copies(settled, remote, tmp_path):
"""Ничего не сливаем и ничего не теряем — решение за пользователем.""" """Ничего не сливаем и ничего не теряем — решение за пользователем."""
sync.sync() clone_and_push(tmp_path, remote, b"encrypted-from-other-machine")
other = tmp_path / "other" write_vault(settled, b"encrypted-ours")
git(tmp_path, "clone", remote.as_posix(), "other") copy = settled.repo_dir / "vault.remote.fmdb"
(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): with pytest.raises(Diverged):
sync.sync(remote_copy_path=copy) settled.sync(remote_copy_path=copy)
assert (sync.repo_dir / REL).read_bytes() == b"encrypted-ours" assert (settled.repo_dir / REL).read_bytes() == b"encrypted-ours"
assert copy.read_bytes() == b"encrypted-from-other-machine" assert copy.read_bytes() == b"encrypted-from-other-machine"
def test_force_push_overwrites_only_when_asked(settled, remote, tmp_path):
clone_and_push(tmp_path, remote, b"encrypted-from-other-machine")
write_vault(settled, b"encrypted-ours")
settled.force_push()
assert remote_vault(remote) == b"encrypted-ours"
def test_reset_to_remote_drops_local_changes(settled, remote, tmp_path):
clone_and_push(tmp_path, remote, b"encrypted-from-other-machine")
write_vault(settled, b"encrypted-ours")
settled.reset_to_remote()
assert (settled.repo_dir / REL).read_bytes() == b"encrypted-from-other-machine"
# --- обращение с токеном -------------------------------------------------- # --- обращение с токеном --------------------------------------------------
def test_auth_url_injects_token(): def test_auth_url_injects_token():
url = auth_url("https://gt.ser.gay/kizya/food-market.git", "glpat-secret") url = auth_url("https://gt.ser.gay/kizya/food-records.git", "glpat-secret")
assert url == "https://oauth2:glpat-secret@gt.ser.gay/kizya/food-market.git" assert url == "https://oauth2:glpat-secret@gt.ser.gay/kizya/food-records.git"
def test_auth_url_replaces_existing_credentials(): def test_auth_url_replaces_existing_credentials():
url = auth_url("https://old:creds@gt.ser.gay/kizya/food-market.git", "glpat-new") url = auth_url("https://old:creds@gt.ser.gay/kizya/food-records.git", "glpat-new")
assert url == "https://oauth2:glpat-new@gt.ser.gay/kizya/food-market.git" assert url == "https://oauth2:glpat-new@gt.ser.gay/kizya/food-records.git"
def test_auth_url_escapes_special_characters(): def test_auth_url_escapes_special_characters():
@ -280,3 +366,8 @@ def test_ensure_repo_updates_a_changed_remote(sync, tmp_path):
sync.settings.remote_url = (tmp_path / "elsewhere.git").as_posix() sync.settings.remote_url = (tmp_path / "elsewhere.git").as_posix()
sync.ensure_repo() sync.ensure_repo()
assert git(sync.repo_dir, "remote", "get-url", "origin").endswith("elsewhere.git") assert git(sync.repo_dir, "remote", "get-url", "origin").endswith("elsewhere.git")
def test_default_remote_points_at_the_data_repo():
"""Настройки по умолчанию должны вести в репозиторий данных, а не в код."""
assert "food-records" in GitSettings().remote_url

View File

@ -307,6 +307,128 @@ def test_empty_database_reports_zeroes(vault, today):
assert report.warnings == [] assert report.warnings == []
# --- Фасовки: пачка и штука — один товар ----------------------------------
@pytest.fixture
def cookies(vault):
"""Печенье: берётся пачками по 10, продаётся и пачкой, и поштучно.
Себестоимость 20 за штуку, то есть 200 за пачку. Поштучно уходит
по 30 , пачкой целиком за 200 .
"""
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
journal.set_packs(vault, product.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
return vault.doc.product(product.id)
def pack_buy(product, qty, cost):
return {"product_id": product.id, "qty": qty, "uom": "пачка", "unit_cost": cost}
def test_buying_packs_gives_stock_in_pieces(vault, cookies, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [pack_buy(cookies, 5, 200)])
report = ledger.build(vault.doc, today)
batch = report.batches[0]
assert batch.cost_total == Decimal("1000.00") # 5 пачек × 200
assert batch.qty_total == Decimal("50.000") # но 50 штук
assert batch.qty_left == Decimal("50.000")
def test_selling_singles_depletes_the_packs(vault, cookies, today, in_two_weeks):
"""Главное: печенька поштучно берётся из пачки, это не второй товар."""
journal.create_batch(vault, today, in_two_weeks, [pack_buy(cookies, 5, 200)])
journal.create_sale(vault, today, KIND_RETAIL, [line(cookies, 3, 30)])
report = ledger.build(vault.doc, today)
batch = report.batches[0]
assert batch.qty_sold == Decimal("3.000")
assert batch.qty_left == Decimal("47.000")
assert batch.cash_collected == Decimal("90.00") # 3 × 30
# Наценка считается от себестоимости штуки, выведенной из цены пачки.
assert report.summary.gross_margin == Decimal("30.00") # 3 × (30 20)
def test_selling_a_whole_pack_and_singles_together(vault, cookies, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [pack_buy(cookies, 5, 200)])
journal.create_sale(vault, today, KIND_RETAIL, [line(cookies, 3, 30)])
journal.create_sale(
vault, today, KIND_RETAIL,
[{"product_id": cookies.id, "qty": 1, "uom": "пачка", "unit_price": 200}],
)
report = ledger.build(vault.doc, today)
batch = report.batches[0]
assert batch.qty_sold == Decimal("13.000") # 3 штуки + 1 пачка
assert batch.qty_left == Decimal("37.000")
assert batch.cash_collected == Decimal("290.00") # 90 + 200
# Пачка ушла ровно по себестоимости, наценка только с поштучных.
assert report.summary.gross_margin == Decimal("30.00")
def test_stock_value_uses_cost_per_piece(vault, cookies, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [pack_buy(cookies, 5, 200)])
journal.create_sale(vault, today, KIND_RETAIL, [line(cookies, 3, 30)])
summary = ledger.build(vault.doc, today).summary
assert summary.stock_qty == Decimal("47.000")
assert summary.stock_cost == Decimal("940.00") # 47 × 20
def test_singles_can_outlast_one_batch_and_spill_into_the_next(vault, cookies, today):
journal.create_batch(
vault, today - timedelta(days=5), today + timedelta(days=9), [pack_buy(cookies, 1, 200)]
)
second = journal.create_batch(
vault, today, today + timedelta(days=14), [pack_buy(cookies, 1, 200)]
)
journal.create_sale(vault, today, KIND_RETAIL, [line(cookies, 14, 30)])
report = ledger.build(vault.doc, today)
assert report.batches[0].qty_sold == Decimal("10.000")
assert report.batch_report(second.id).qty_sold == Decimal("4.000")
def test_pack_size_is_snapshotted(vault, cookies, today, in_two_weeks):
"""Переопределили пачку — уже записанные документы не должны поехать."""
journal.create_batch(vault, today, in_two_weeks, [pack_buy(cookies, 2, 200)])
journal.set_packs(vault, cookies.id, [{"name": "пачка", "size": 25, "retail_price": 500}])
report = ledger.build(vault.doc, today)
assert report.batches[0].qty_total == Decimal("20.000") # по 10, как и покупали
def test_odd_pack_size_does_not_lose_kopecks(vault, today, in_two_weeks):
"""200 на 3 не делится нацело — округление не должно копиться."""
product = journal.create_product(vault, "Пирожки", unit="шт", cost_price=0, retail_price=100)
journal.set_packs(vault, product.id, [{"name": "тройка", "size": 3, "retail_price": 200}])
journal.create_batch(
vault, today, in_two_weeks,
[{"product_id": product.id, "qty": 1, "uom": "тройка", "unit_cost": 200}],
)
journal.create_sale(
vault, today, KIND_RETAIL,
[{"product_id": product.id, "qty": 1, "uom": "тройка", "unit_price": 200}],
)
report = ledger.build(vault.doc, today)
assert report.batches[0].cash_collected == Decimal("200.00")
assert report.summary.gross_margin == 0
assert report.summary.stock_qty == 0
def test_product_without_packs_behaves_exactly_as_before(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["мак"], 4, 35)])
report = ledger.build(vault.doc, today)
assert report.batches[0].qty_left == Decimal("6.000")
assert report.batches[0].cash_collected == Decimal("140.00")
def test_stock_lists_products_with_remaining_quantity(scenario, today): 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} rows = {r.name: r.qty for r in ledger.build(scenario["vault"].doc, today).stock}
assert rows["Булка с повидлом"] == Decimal("2.000") assert rows["Булка с повидлом"] == Decimal("2.000")

297
tests/test_quick_sales.py Normal file
View File

@ -0,0 +1,297 @@
"""Быстрый ввод продаж за период."""
import os
from datetime import date, 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, ledger # noqa: E402
from app.models import KIND_FRIEND, KIND_RETAIL, KIND_SELF, SALE_KINDS # noqa: E402
from app.ui import theme # noqa: E402
from app.ui.quick_sales import ( # noqa: E402
COL_DATE,
COL_KIND,
COL_PAID,
COL_PRICE,
COL_PRODUCT,
COL_QTY,
COL_UOM,
COL_WHO,
QuickSalesDialog,
write_sales,
)
@pytest.fixture(scope="session")
def qapp():
app = QApplication.instance() or QApplication([])
theme.apply(app)
return app
class FakeCtx:
"""Минимальный контекст: диалогу нужны только база, валюта и уведомление."""
def __init__(self, vault):
self.vault = vault
self.report = ledger.build(vault.doc)
self.changed_calls = 0
@property
def currency(self):
return self.vault.doc.settings.currency
def changed(self):
self.report = ledger.build(self.vault.doc)
self.changed_calls += 1
@pytest.fixture
def ctx(qapp, vault, buns):
return FakeCtx(vault)
@pytest.fixture
def dialog(ctx):
d = QuickSalesDialog(ctx)
yield d
d.deleteLater()
def fill(dialog, row, product_id, qty, kind=None, who=None, on_date=None, uom=None, paid=None):
if on_date is not None:
dialog.table.cellWidget(row, COL_DATE).set_date(on_date)
if kind is not None:
dialog.table.cellWidget(row, COL_KIND).setCurrentIndex(SALE_KINDS.index(kind))
if who is not None:
dialog.table.cellWidget(row, COL_WHO).setCurrentText(who)
dialog.table.cellWidget(row, COL_PRODUCT).select_product(product_id)
if uom is not None:
dialog.table.cellWidget(row, COL_UOM).setCurrentText(uom)
dialog.table.cellWidget(row, COL_QTY).set_decimal(qty)
if paid is not None:
dialog.table.cellWidget(row, COL_PAID).set_decimal(paid)
# --- период ---------------------------------------------------------------
def test_period_defaults_to_the_last_month(dialog):
span = dialog.period_to.get_date() - dialog.period_from.get_date()
assert span == timedelta(days=30)
assert dialog.period_to.get_date() == date.today()
def test_period_is_freely_adjustable(dialog):
"""Период не обязан быть месяцем — его задаёт пользователь."""
dialog.period_from.set_date(date(2026, 3, 1))
dialog.period_to.set_date(date(2026, 3, 9))
assert (dialog.period_to.get_date() - dialog.period_from.get_date()).days == 8
def test_hint_warns_about_already_entered_sales(ctx, buns, qapp):
"""Страховка от того, чтобы не внести один и тот же период дважды."""
journal.create_sale(
ctx.vault, date.today() - timedelta(days=3), KIND_RETAIL,
[{"product_id": buns["мак"].id, "qty": 1, "unit_price": 35}],
)
d = QuickSalesDialog(ctx)
assert "уже записано продаж: 1" in d.hint.text()
d.deleteLater()
def test_first_row_starts_at_the_period_beginning(dialog):
assert dialog.table.cellWidget(0, COL_DATE).get_date() == dialog.period_from.get_date()
def test_dates_outside_the_period_are_counted(dialog, buns):
fill(dialog, 0, buns["мак"].id, 1, on_date=date(2020, 1, 1))
assert "вне периода: 1" in dialog.total_label.text()
# --- строки ---------------------------------------------------------------
def test_new_row_inherits_date_and_kind(dialog, buns):
when = date.today() - timedelta(days=5)
fill(dialog, 0, buns["мак"].id, 1, kind=KIND_FRIEND, on_date=when)
dialog.add_row()
assert dialog.table.cellWidget(1, COL_DATE).get_date() == when
assert dialog.table.cellWidget(1, COL_KIND).currentData() == KIND_FRIEND
# Товар не наследуется: следующая продажа почти всегда другая.
assert dialog.table.rowCount() == 2
def test_duplicate_copies_the_whole_row(dialog, buns):
fill(dialog, 0, buns["повидло"].id, 7, kind=KIND_RETAIL, who="Вася")
dialog.table.setCurrentCell(0, COL_PRODUCT)
dialog.duplicate_row()
assert dialog.table.cellWidget(1, COL_PRODUCT).current_product_id() == buns["повидло"].id
assert dialog.table.cellWidget(1, COL_QTY).value_decimal() == Decimal("7.000")
assert dialog.table.cellWidget(1, COL_WHO).currentText() == "Вася"
def test_remove_row(dialog, buns):
dialog.add_row()
dialog.table.setCurrentCell(1, COL_PRODUCT)
dialog.remove_row()
assert dialog.table.rowCount() == 1
# --- цены и оплата --------------------------------------------------------
def test_price_follows_the_kind(dialog, buns):
fill(dialog, 0, buns["мак"].id, 1, kind=KIND_RETAIL)
assert dialog.table.cellWidget(0, COL_PRICE).value_decimal() == Decimal("35.00")
fill(dialog, 0, buns["мак"].id, 1, kind=KIND_FRIEND)
assert dialog.table.cellWidget(0, COL_PRICE).value_decimal() == Decimal("20.00")
def test_consumption_disables_money(dialog, buns):
fill(dialog, 0, buns["мак"].id, 2, kind=KIND_SELF)
assert dialog.table.cellWidget(0, COL_PRICE).isEnabled() is False
assert dialog.table.cellWidget(0, COL_PAID).isEnabled() is False
assert dialog._row_total(0) == 0
def test_paid_follows_the_total_until_edited(dialog, buns):
fill(dialog, 0, buns["мак"].id, 3)
assert dialog.table.cellWidget(0, COL_PAID).value_decimal() == Decimal("105.00")
dialog.table.cellWidget(0, COL_PAID).set_decimal(50)
dialog.table.cellWidget(0, COL_QTY).set_decimal(4)
# Оплату правили руками — значит, там долг, и подставлять её больше нельзя.
assert dialog.table.cellWidget(0, COL_PAID).value_decimal() == Decimal("50.00")
def test_running_total(dialog, buns):
fill(dialog, 0, buns["мак"].id, 2) # 70
dialog.add_row()
fill(dialog, 1, buns["повидло"].id, 1) # 50
assert "120,00 ₽" in dialog.total_label.text()
def test_pack_selection_available(ctx, qapp):
cookies = journal.create_product(ctx.vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
journal.set_packs(ctx.vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
d = QuickSalesDialog(ctx)
fill(d, 0, cookies.id, 2, uom="пачка")
assert d.table.cellWidget(0, COL_PRICE).value_decimal() == Decimal("200.00")
assert d._row_total(0) == Decimal("400.00")
d.deleteLater()
# --- запись ---------------------------------------------------------------
def test_writes_every_row_as_its_own_sale(dialog, ctx, buns):
day = date.today() - timedelta(days=10)
fill(dialog, 0, buns["мак"].id, 3, on_date=day)
dialog.add_row()
fill(dialog, 1, buns["повидло"].id, 2, on_date=day + timedelta(days=1))
created = write_sales(ctx.vault, dialog.collect())
assert created == 2
sales = sorted(ctx.vault.doc.sales, key=lambda s: s.date)
assert [s.date for s in sales] == [day, day + timedelta(days=1)]
assert sales[0].total == Decimal("105.00")
assert sales[1].total == Decimal("100.00")
def test_empty_rows_are_skipped(dialog, ctx, buns):
fill(dialog, 0, buns["мак"].id, 1)
dialog.add_row()
dialog.table.cellWidget(1, COL_QTY).set_decimal(0)
assert len(dialog.collect()) == 1
def test_unknown_person_becomes_a_counterparty(dialog, ctx, buns):
"""При вводе истории останавливаться и заводить людей вручную — морока."""
fill(dialog, 0, buns["мак"].id, 5, kind=KIND_FRIEND, who="Николай", paid=0)
write_sales(ctx.vault, dialog.collect())
names = [c.name for c in ctx.vault.doc.counterparties]
assert names == ["Николай"]
assert ctx.vault.doc.sales[0].counterparty_id == ctx.vault.doc.counterparties[0].id
def test_existing_person_is_reused_case_insensitively(dialog, ctx, buns):
vasya = journal.create_counterparty(ctx.vault, "Вася")
fill(dialog, 0, buns["мак"].id, 1, who="вася")
write_sales(ctx.vault, dialog.collect())
assert len(ctx.vault.doc.counterparties) == 1
assert ctx.vault.doc.sales[0].counterparty_id == vasya.id
def test_same_person_twice_creates_one_counterparty(dialog, ctx, buns):
fill(dialog, 0, buns["мак"].id, 1, who="Петя")
dialog.add_row()
fill(dialog, 1, buns["повидло"].id, 1, who="Петя")
write_sales(ctx.vault, dialog.collect())
assert len(ctx.vault.doc.counterparties) == 1
def test_partial_payment_becomes_a_debt(dialog, ctx, buns):
fill(dialog, 0, buns["мак"].id, 4, paid=100) # всего 140
write_sales(ctx.vault, dialog.collect())
assert ctx.vault.doc.sales[0].debt == Decimal("40.00")
def test_backdated_sales_land_in_the_right_batch(dialog, ctx, buns):
"""Ради этого экрана всё и делалось: история должна ложиться на партии."""
today = date.today()
batch = journal.create_batch(
ctx.vault,
today - timedelta(days=20),
today + timedelta(days=5),
[{"product_id": buns["мак"].id, "qty": 30, "unit_cost": 20}],
)
for row in range(3):
if row:
dialog.add_row()
fill(dialog, row, buns["мак"].id, 4, on_date=today - timedelta(days=15 - row))
write_sales(ctx.vault, dialog.collect())
report = ledger.build(ctx.vault.doc, today).batch_report(batch.id)
assert report.qty_sold == Decimal("12.000")
assert report.cash_collected == Decimal("420.00") # 12 × 35
assert report.qty_left == Decimal("18.000")
def test_writing_history_produces_no_shortfall_warnings(dialog, ctx, buns):
today = date.today()
journal.create_batch(
ctx.vault, today - timedelta(days=20), today + timedelta(days=5),
[{"product_id": buns["мак"].id, "qty": 30, "unit_cost": 20}],
)
fill(dialog, 0, buns["мак"].id, 10, on_date=today - timedelta(days=15))
write_sales(ctx.vault, dialog.collect())
assert ledger.build(ctx.vault.doc, today).warnings == []
def test_every_written_sale_is_journalled(dialog, ctx, buns):
fill(dialog, 0, buns["мак"].id, 1)
dialog.add_row()
fill(dialog, 1, buns["повидло"].id, 1)
before = len(ctx.vault.doc.journal)
write_sales(ctx.vault, dialog.collect())
actions = [e.action for e in ctx.vault.doc.journal[before:]]
assert actions == ["sale.create", "sale.create"]

View File

@ -4,6 +4,7 @@ from decimal import Decimal
import pytest import pytest
from app import crypto, journal from app import crypto, journal
from app.models import SCHEMA_VERSION, Document
from app.storage import Vault, content_hash, migrate from app.storage import Vault, content_hash, migrate
@ -117,10 +118,43 @@ def test_export_plain_json(tmp_path):
def test_migration_adds_journal(): def test_migration_adds_journal():
raw = migrate({"schema_version": 0, "products": []}) raw = migrate({"schema_version": 0, "products": []})
assert raw["schema_version"] == 1 assert raw["schema_version"] == SCHEMA_VERSION
assert raw["journal"] == [] assert raw["journal"] == []
def test_migration_adds_packaging_without_changing_behaviour():
"""Старая база должна открыться так, будто всё записано в базовых единицах."""
raw = migrate(
{
"schema_version": 1,
"products": [{"id": "p_1", "name": "Печенье", "unit": "шт", "cost_price": "20"}],
"batches": [
{
"id": "b_1", "number": 1, "date": "2026-08-01", "due_date": "2026-08-15",
"lines": [{"product_id": "p_1", "qty": "10", "unit_cost": "20"}],
}
],
"sales": [
{
"id": "s_1", "date": "2026-08-02", "kind": "retail",
"lines": [{"product_id": "p_1", "qty": "3", "unit_price": "30"}],
}
],
}
)
assert raw["schema_version"] == SCHEMA_VERSION
assert raw["products"][0]["packs"] == []
assert raw["batches"][0]["lines"][0] == {
"product_id": "p_1", "qty": "10", "unit_cost": "20", "uom": "", "uom_size": "1"
}
doc = Document.from_dict(raw)
assert doc.batches[0].lines[0].base_qty == Decimal("10.000")
assert doc.batches[0].lines[0].base_unit_cost == Decimal("20.00")
assert doc.sales[0].lines[0].base_unit_price == Decimal("30.00")
def test_future_schema_refuses_to_open(): def test_future_schema_refuses_to_open():
with pytest.raises(crypto.UnsupportedFormat, match="новой версией"): with pytest.raises(crypto.UnsupportedFormat, match="новой версией"):
migrate({"schema_version": 999}) migrate({"schema_version": 999})

View File

@ -160,8 +160,8 @@ def test_products_table_shows_stock_and_margin(window):
assert "Булка с маком" in names assert "Булка с маком" in names
row = names.index("Булка с маком") row = names.index("Булка с маком")
assert "15" in page.table.item(row, 4).text() # 35 20 assert "15" in page.table.item(row, 5).text() # наценка: 35 20
assert page.table.item(row, 5).text() == "5" # 10 закуплено 5 отдано assert page.table.item(row, 6).text() == "5" # 10 закуплено 5 отдано
def test_archived_products_are_hidden_by_default(window, buns): def test_archived_products_are_hidden_by_default(window, buns):
@ -319,9 +319,23 @@ def test_sale_dialog_prefills_full_payment(window, buns):
def test_price_source_for_each_kind(buns): def test_price_source_for_each_kind(buns):
assert price_source_for(KIND_RETAIL)(buns["мак"]) == Decimal("35.00") bun = buns["мак"]
assert price_source_for(KIND_FRIEND)(buns["мак"]) == Decimal("20.00") assert price_source_for(KIND_RETAIL)(bun, "шт") == Decimal("35.00")
assert price_source_for(KIND_SELF)(buns["мак"]) == 0 assert price_source_for(KIND_FRIEND)(bun, "шт") == Decimal("20.00")
assert price_source_for(KIND_SELF)(bun, "шт") == 0
def test_price_source_uses_the_pack_price(vault, buns):
"""Пачка стоит своих денег, а не десяти поштучных цен."""
from app import journal as j
cookies = j.create_product(vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
j.set_packs(vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
cookies = vault.doc.product(cookies.id)
assert price_source_for(KIND_RETAIL)(cookies, "шт") == Decimal("30.00")
assert price_source_for(KIND_RETAIL)(cookies, "пачка") == Decimal("200.00")
assert price_source_for(KIND_FRIEND)(cookies, "пачка") == Decimal("200.00") # 10 × 20
def test_price_dialog_shows_history(window, buns): def test_price_dialog_shows_history(window, buns):
@ -343,6 +357,55 @@ def test_product_dialog_hides_prices_when_editing(window, buns):
creating.deleteLater() creating.deleteLater()
def test_lines_editor_switches_price_with_the_pack(window):
"""Выбрал пачку — цена и подпись меняются, товар остаётся тем же."""
from app import journal as j
cookies = j.create_product(window.vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
j.set_packs(window.vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
window.changed()
dialog = SaleDialog(window)
editor = dialog.lines
editor.table.cellWidget(0, editor.COL_PRODUCT).select_product(cookies.id)
uom = editor.table.cellWidget(0, editor.COL_UOM)
assert [uom.itemText(i) for i in range(uom.count())] == ["шт", "пачка"]
assert uom.isEnabled() is True
assert editor.lines("unit_price")[0]["unit_price"] == Decimal("30.00")
uom.setCurrentText("пачка")
line = editor.lines("unit_price")[0]
assert line["uom"] == "пачка"
assert line["unit_price"] == Decimal("200.00")
dialog.deleteLater()
def test_pack_column_is_disabled_without_packs(window, buns):
dialog = SaleDialog(window)
editor = dialog.lines
editor.table.cellWidget(0, editor.COL_PRODUCT).select_product(buns["мак"].id)
uom = editor.table.cellWidget(0, editor.COL_UOM)
assert uom.currentText() == "шт"
assert uom.isEnabled() is False
dialog.deleteLater()
def test_packs_editor_round_trip(window):
from app import journal as j
from app.ui.page_products import ProductDialog as PD
cookies = j.create_product(window.vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
j.set_packs(window.vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
dialog = PD("", window.vault.doc.product(cookies.id))
assert dialog.packs.packs() == [
{"name": "пачка", "size": Decimal("10.000"), "retail_price": Decimal("200.00")}
]
dialog.deleteLater()
def test_lines_editor_add_and_remove(window, buns): def test_lines_editor_add_and_remove(window, buns):
dialog = BatchDialog(window) dialog = BatchDialog(window)
editor = dialog.lines editor = dialog.lines