Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям. 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:
parent
c8b9f5048b
commit
efce552e2b
10
.gitignore
vendored
10
.gitignore
vendored
@ -12,9 +12,9 @@ dist/
|
||||
# бинарник в нём не нужен, он собирается из исходников одной командой.
|
||||
/food-market.exe
|
||||
|
||||
# Рабочие артефакты базы. Сама база data/vault.fmdb НЕ игнорируется —
|
||||
# она и есть предмет синхронизации.
|
||||
*.tmp
|
||||
data/*.bak
|
||||
data/vault.remote.fmdb
|
||||
data/export-*.json
|
||||
|
||||
# Папка data — самостоятельный клон репозитория с данными
|
||||
# (gt.ser.gay/kizya/food-records) со своим .git. Код и база живут врозь,
|
||||
# поэтому здесь её не должно быть ни в каком виде.
|
||||
/data/
|
||||
|
||||
64
README.md
64
README.md
@ -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 нужен только чтобы понимать,
|
||||
деньги за какую партию уже пришли.
|
||||
@ -41,9 +74,15 @@ pip install -r requirements-dev.txt
|
||||
pyinstaller food-market.spec
|
||||
```
|
||||
|
||||
Готовый `dist/food-market.exe` (~50 МБ) положи в корень клона этого
|
||||
репозитория. Базу он ищет в `data/vault.fmdb` рядом с собой, поэтому достаточно
|
||||
склонировать репозиторий, положить exe в корень и запустить.
|
||||
Готовый `dist/food-market.exe` (~50 МБ) можно положить куда угодно — базу он
|
||||
ищет в папке `data/` рядом с собой и при первом запуске сам заведёт там клон
|
||||
репозитория с данными.
|
||||
|
||||
На новой машине быстрее склонировать данные сразу:
|
||||
|
||||
```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 только на
|
||||
время вызова — в `.git/config` он не пишется и в журнал не попадает.
|
||||
@ -101,12 +141,14 @@ pytest -q
|
||||
| файл | зачем |
|
||||
|---|---|
|
||||
| `app/crypto.py` | формат файла, scrypt + AES-GCM |
|
||||
| `app/models.py` | структура документа, деньги в Decimal |
|
||||
| `app/models.py` | структура документа, фасовки, деньги в Decimal |
|
||||
| `app/storage.py` | загрузка, атомарная запись, миграции схемы, хеш «грязности» |
|
||||
| `app/journal.py` | **единственный путь записи** в базу + аудит-лог |
|
||||
| `app/ledger.py` | FIFO, покрытие партий, долги, остатки, сводка |
|
||||
| `app/ledger.py` | FIFO в базовых единицах, покрытие партий, долги, остатки |
|
||||
| `app/gitsync.py` | git, схлопывание коммитов, разрешение расхождений |
|
||||
| `app/ui/` | экраны на PySide6 |
|
||||
| `app/paths.py` | где лежат данные и почему отдельно от кода |
|
||||
| `app/ui/quick_sales.py` | быстрый ввод продаж за период |
|
||||
| `app/ui/` | остальные экраны на PySide6 |
|
||||
|
||||
Главное архитектурное правило: **ни один экран не меняет документ напрямую**.
|
||||
Всё идёт через функции `app/journal.py`, которые сами считают разницу и
|
||||
|
||||
@ -34,6 +34,24 @@ from .models import GitSettings
|
||||
COMMIT_PREFIX = "vault"
|
||||
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
|
||||
NETWORK_TIMEOUT = 180
|
||||
|
||||
@ -263,22 +281,39 @@ class GitSync:
|
||||
touched = self._out(["show", "--name-only", "--format=", "HEAD"]).split("\n")
|
||||
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]:
|
||||
"""Закоммитить базу. Возвращает (закоммитили, через amend, sha до amend)."""
|
||||
self._run(["add", "--", self.rel_path])
|
||||
# Явный список путей — гарантия, что в автокоммит не утащит ничего
|
||||
# постороннего, что окажется в папке.
|
||||
targets = [self.rel_path, *self._pending_setup_files()]
|
||||
self._run(["add", "--", *targets])
|
||||
|
||||
staged = self._run(
|
||||
["diff", "--cached", "--quiet", "--", self.rel_path], check=False
|
||||
["diff", "--cached", "--quiet", "--", *targets], check=False
|
||||
).returncode != 0
|
||||
if not staged:
|
||||
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()
|
||||
self._run(["commit", "--amend", "--no-edit", "--", self.rel_path])
|
||||
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
|
||||
|
||||
def _push(self, amended: bool, lease: str | None) -> None:
|
||||
@ -324,6 +359,10 @@ class GitSync:
|
||||
result = SyncResult()
|
||||
|
||||
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:
|
||||
behind = self._is_ancestor(head, remote)
|
||||
ahead = self._is_ancestor(remote, head)
|
||||
@ -389,6 +428,26 @@ class GitSync:
|
||||
if head:
|
||||
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:
|
||||
if remote_copy_path is not None:
|
||||
self.save_remote_copy(remote_copy_path)
|
||||
|
||||
120
app/journal.py
120
app/journal.py
@ -22,6 +22,7 @@ from datetime import date, datetime
|
||||
|
||||
from . import money as m
|
||||
from .models import (
|
||||
ONE,
|
||||
SALE_KIND_LABELS,
|
||||
BakeryPayment,
|
||||
Batch,
|
||||
@ -29,6 +30,7 @@ from .models import (
|
||||
Change,
|
||||
Counterparty,
|
||||
JournalEntry,
|
||||
Pack,
|
||||
Payment,
|
||||
PricePoint,
|
||||
Product,
|
||||
@ -110,6 +112,7 @@ def _flat_product(doc, p: Product) -> dict[str, str]:
|
||||
"единица": p.unit,
|
||||
"себестоимость": m.fmt_money(p.cost_price, doc.settings.currency),
|
||||
"цена продажи": m.fmt_money(p.retail_price, doc.settings.currency),
|
||||
"фасовки": _packs_text(doc, p),
|
||||
"в архиве": "да" if p.archived else "",
|
||||
"заметка": p.note,
|
||||
}
|
||||
@ -121,10 +124,21 @@ def _flat_counterparty(doc, c: Counterparty) -> dict[str, str]:
|
||||
|
||||
def _lines_text(doc, lines, price_attr: str) -> str:
|
||||
cur = doc.settings.currency
|
||||
return "; ".join(
|
||||
f"{doc.product_name(x.product_id)} × {m.fmt_qty(x.qty)} "
|
||||
parts = []
|
||||
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)}"
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""Удалить товар, если он нигде не использован.
|
||||
|
||||
@ -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]:
|
||||
result: list[BatchLine] = []
|
||||
for raw in lines:
|
||||
product_id = raw.get("product_id")
|
||||
if not product_id or doc.product(product_id) is None:
|
||||
raise ValidationError("В закупке есть строка без существующего товара.")
|
||||
quantity = m.qty(raw.get("qty"))
|
||||
if quantity <= 0:
|
||||
raise ValidationError(
|
||||
f"Количество для «{doc.product_name(product_id)}» должно быть больше нуля."
|
||||
)
|
||||
_, quantity, uom, size = _resolve_line(doc, raw, "закупке")
|
||||
cost = m.money(raw.get("unit_cost"))
|
||||
if cost < 0:
|
||||
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:
|
||||
raise ValidationError("В закупке должна быть хотя бы одна строка.")
|
||||
@ -513,18 +590,19 @@ def delete_batch(vault, batch_id: str) -> None:
|
||||
def _build_sale_lines(doc, lines: list[dict]) -> list[SaleLine]:
|
||||
result: list[SaleLine] = []
|
||||
for raw in lines:
|
||||
product_id = raw.get("product_id")
|
||||
if not product_id or doc.product(product_id) is None:
|
||||
raise ValidationError("В продаже есть строка без существующего товара.")
|
||||
quantity = m.qty(raw.get("qty"))
|
||||
if quantity <= 0:
|
||||
raise ValidationError(
|
||||
f"Количество для «{doc.product_name(product_id)}» должно быть больше нуля."
|
||||
)
|
||||
_, quantity, uom, size = _resolve_line(doc, raw, "продаже")
|
||||
price = m.money(raw.get("unit_price"))
|
||||
if price < 0:
|
||||
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:
|
||||
raise ValidationError("В продаже должна быть хотя бы одна строка.")
|
||||
|
||||
@ -91,6 +91,7 @@ class BatchReport:
|
||||
days_left: int
|
||||
status: str
|
||||
stock: dict[str, Decimal] = field(default_factory=dict)
|
||||
stock_cost: Decimal = m.ZERO
|
||||
|
||||
@property
|
||||
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]))
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Возвращает аллокации, нехватку товара и остаток по каждой партии.
|
||||
|
||||
Намеренно не проверяем, что дата продажи позже даты партии: люди заводят
|
||||
документы в произвольном порядке, и жаловаться на это было бы шумом.
|
||||
Значение имеет только очередь партий.
|
||||
документы в произвольном порядке — особенно когда вносят историю задним
|
||||
числом, — и жаловаться на это было бы шумом. Значение имеет только очередь
|
||||
партий.
|
||||
"""
|
||||
# Всё внутри — в базовых единицах. Иначе поштучная продажа не вычиталась бы
|
||||
# из купленной пачки: это один товар, а не два разных.
|
||||
remaining: dict[str, dict[str, Decimal]] = {}
|
||||
for _, batch in _ordered_batches(doc):
|
||||
per_product = remaining.setdefault(batch.id, {})
|
||||
for line in batch.lines:
|
||||
per_product[line.product_id] = per_product.get(line.product_id, m.ZERO) + line.qty
|
||||
per_product[line.product_id] = per_product.get(line.product_id, m.ZERO) + line.base_qty
|
||||
|
||||
# Себестоимость берём из строки партии — это слепок цены на момент закупки.
|
||||
unit_costs: dict[tuple[str, str], Decimal] = {
|
||||
(batch.id, line.product_id): line.unit_cost
|
||||
for _, batch in _ordered_batches(doc)
|
||||
for line in batch.lines
|
||||
}
|
||||
unit_costs = batch_unit_costs(doc)
|
||||
order = [batch.id for _, batch in _ordered_batches(doc)]
|
||||
|
||||
allocations: list[Allocation] = []
|
||||
@ -223,7 +244,7 @@ def allocate(doc: Document) -> tuple[list[Allocation], list[Shortfall], dict[str
|
||||
for _, sale in _ordered_sales(doc):
|
||||
fraction = _paid_fraction(sale)
|
||||
for line_index, line in enumerate(sale.lines):
|
||||
need = line.qty
|
||||
need = line.base_qty
|
||||
for batch_id in order:
|
||||
if need <= 0:
|
||||
break
|
||||
@ -242,7 +263,7 @@ def allocate(doc: Document) -> tuple[list[Allocation], list[Shortfall], dict[str
|
||||
batch_id=batch_id,
|
||||
qty=take,
|
||||
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,
|
||||
paid_fraction=fraction,
|
||||
)
|
||||
@ -307,6 +328,7 @@ def build(doc: Document, today: date | None = None) -> Report:
|
||||
"""Пересчитать всё. Дешевле, чем поддерживать инкрементальное состояние."""
|
||||
today = today or date.today()
|
||||
allocations, shortfalls, remaining = allocate(doc)
|
||||
unit_costs = batch_unit_costs(doc)
|
||||
|
||||
cost_totals = {batch.id: batch.cost_total for batch in doc.batches}
|
||||
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,
|
||||
status=_status(batch, max(m.ZERO, cost_total - paid), days_left),
|
||||
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]:
|
||||
unit_costs = batch_unit_costs(doc)
|
||||
qty_by_product: dict[str, Decimal] = {}
|
||||
cost_by_product: dict[str, Decimal] = {}
|
||||
for report in batch_reports:
|
||||
for product_id, qty in report.stock.items():
|
||||
unit_cost = next(
|
||||
(line.unit_cost for line in report.batch.lines if line.product_id == product_id),
|
||||
m.ZERO,
|
||||
)
|
||||
unit_cost = unit_costs.get((report.batch.id, product_id), m.ZERO)
|
||||
qty_by_product[product_id] = qty_by_product.get(product_id, m.ZERO) + qty
|
||||
cost_by_product[product_id] = cost_by_product.get(product_id, m.ZERO) + qty * unit_cost
|
||||
|
||||
@ -446,18 +475,7 @@ def _summary(
|
||||
gross_margin=m.money(margin),
|
||||
consumed_cost=m.money(consumed),
|
||||
stock_qty=m.qty(sum((r.qty_left for r in batch_reports), m.ZERO)),
|
||||
stock_cost=m.money(
|
||||
sum(
|
||||
(
|
||||
qty * line.unit_cost
|
||||
for r in batch_reports
|
||||
for pid, qty in r.stock.items()
|
||||
for line in r.batch.lines
|
||||
if line.product_id == pid
|
||||
),
|
||||
m.ZERO,
|
||||
)
|
||||
),
|
||||
stock_cost=m.money(sum((r.stock_cost for r in batch_reports), m.ZERO)),
|
||||
overdue_count=sum(1 for r in batch_reports if r.status == STATUS_OVERDUE),
|
||||
next_due=next_due,
|
||||
)
|
||||
|
||||
138
app/models.py
138
app/models.py
@ -15,7 +15,9 @@ from typing import Any
|
||||
|
||||
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
|
||||
class Product:
|
||||
"""Товар. Цены хранятся за базовую единицу, фасовки — надстройка над ней."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
unit: str = "шт"
|
||||
@ -103,8 +137,40 @@ class Product:
|
||||
retail_price: Decimal = m.ZERO
|
||||
archived: bool = False
|
||||
note: str = ""
|
||||
packs: list[Pack] = 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
|
||||
def from_dict(cls, d: dict) -> "Product":
|
||||
return cls(
|
||||
@ -115,6 +181,7 @@ class Product:
|
||||
retail_price=m.money(d.get("retail_price")),
|
||||
archived=bool(d.get("archived", False)),
|
||||
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", [])],
|
||||
)
|
||||
|
||||
@ -127,6 +194,7 @@ class Product:
|
||||
"retail_price": m.dumps(self.retail_price),
|
||||
"archived": self.archived,
|
||||
"note": self.note,
|
||||
"packs": [p.to_dict() for p in self.packs],
|
||||
"price_history": [p.to_dict() for p in self.price_history],
|
||||
}
|
||||
|
||||
@ -155,20 +223,60 @@ class Counterparty:
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchLine:
|
||||
class DocumentLine:
|
||||
"""Общее для строк закупки и продажи.
|
||||
|
||||
Количество и цена хранятся в той фасовке, которую выбрал пользователь —
|
||||
«2 пачки по 200 ₽» так и остаётся двумя пачками по 200. Размер фасовки
|
||||
сохраняется слепком: если пачку потом переопределят с 10 штук на 12,
|
||||
уже записанные документы не должны поехать.
|
||||
"""
|
||||
|
||||
product_id: str
|
||||
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
|
||||
def total(self) -> Decimal:
|
||||
return m.money(self.qty * self.unit_cost)
|
||||
|
||||
@property
|
||||
def base_unit_cost(self) -> Decimal:
|
||||
return self._base_price(self.unit_cost)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "BatchLine":
|
||||
uom, size = cls._uom_from_dict(d)
|
||||
return cls(
|
||||
product_id=d["product_id"],
|
||||
qty=m.qty(d.get("qty")),
|
||||
uom=uom,
|
||||
uom_size=size,
|
||||
unit_cost=m.money(d.get("unit_cost")),
|
||||
)
|
||||
|
||||
@ -176,6 +284,8 @@ class BatchLine:
|
||||
return {
|
||||
"product_id": self.product_id,
|
||||
"qty": m.dumps(self.qty),
|
||||
"uom": self.uom,
|
||||
"uom_size": m.dumps(self.uom_size),
|
||||
"unit_cost": m.dumps(self.unit_cost),
|
||||
}
|
||||
|
||||
@ -203,7 +313,8 @@ class Batch:
|
||||
|
||||
@property
|
||||
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
|
||||
def from_dict(cls, d: dict) -> "Batch":
|
||||
@ -233,20 +344,25 @@ class Batch:
|
||||
|
||||
|
||||
@dataclass
|
||||
class SaleLine:
|
||||
product_id: str
|
||||
qty: Decimal
|
||||
unit_price: Decimal
|
||||
class SaleLine(DocumentLine):
|
||||
unit_price: Decimal = m.ZERO
|
||||
|
||||
@property
|
||||
def total(self) -> Decimal:
|
||||
return m.money(self.qty * self.unit_price)
|
||||
|
||||
@property
|
||||
def base_unit_price(self) -> Decimal:
|
||||
return self._base_price(self.unit_price)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "SaleLine":
|
||||
uom, size = cls._uom_from_dict(d)
|
||||
return cls(
|
||||
product_id=d["product_id"],
|
||||
qty=m.qty(d.get("qty")),
|
||||
uom=uom,
|
||||
uom_size=size,
|
||||
unit_price=m.money(d.get("unit_price")),
|
||||
)
|
||||
|
||||
@ -254,6 +370,8 @@ class SaleLine:
|
||||
return {
|
||||
"product_id": self.product_id,
|
||||
"qty": m.dumps(self.qty),
|
||||
"uom": self.uom,
|
||||
"uom_size": m.dumps(self.uom_size),
|
||||
"unit_price": m.dumps(self.unit_price),
|
||||
}
|
||||
|
||||
@ -435,7 +553,9 @@ class GitSettings:
|
||||
В .git/config он не попадает — иначе лежал бы открытым рядом с шифром.
|
||||
"""
|
||||
|
||||
remote_url: str = ""
|
||||
# По умолчанию — репозиторий данных, а не исходников. Код и база
|
||||
# намеренно живут врозь: пуш отправляет ветку целиком.
|
||||
remote_url: str = "https://gt.ser.gay/kizya/food-records.git"
|
||||
token: str = ""
|
||||
branch: str = "main"
|
||||
author_name: str = "food-market"
|
||||
|
||||
18
app/paths.py
18
app/paths.py
@ -1,8 +1,13 @@
|
||||
"""Где лежат данные.
|
||||
|
||||
Собранный exe кладётся в корень клона репозитория, а база — в data/ рядом
|
||||
с ним. При запуске из исходников корнем считается сама папка проекта, поэтому
|
||||
разработка и собранное приложение работают с одним и тем же файлом.
|
||||
Код и база живут в **разных** репозиториях. Исходники — в food-market,
|
||||
зашифрованная база — в food-records. Смешивать их нельзя: пуш отправляет всю
|
||||
ветку целиком, поэтому в общем репозитории история данных неизбежно тащила бы
|
||||
за собой историю программы.
|
||||
|
||||
На диске это выглядит так: exe лежит в корне, а рядом с ним папка data —
|
||||
самостоятельный клон репозитория с данными, со своим .git. Исходники папку
|
||||
data игнорируют полностью.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -13,6 +18,8 @@ from pathlib import Path
|
||||
VAULT_NAME = "vault.fmdb"
|
||||
DATA_DIR_NAME = "data"
|
||||
|
||||
DEFAULT_DATA_REMOTE = "https://gt.ser.gay/kizya/food-records.git"
|
||||
|
||||
|
||||
def app_root() -> Path:
|
||||
if getattr(sys, "frozen", False):
|
||||
@ -21,6 +28,7 @@ def app_root() -> Path:
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
"""Корень репозитория с данными. Внутри — только база и её служебные файлы."""
|
||||
return app_root() / DATA_DIR_NAME
|
||||
|
||||
|
||||
@ -29,8 +37,8 @@ def vault_path() -> Path:
|
||||
|
||||
|
||||
def vault_rel_posix() -> str:
|
||||
"""Путь базы для git — всегда со слешами и относительно корня репозитория."""
|
||||
return f"{DATA_DIR_NAME}/{VAULT_NAME}"
|
||||
"""Путь базы внутри репозитория данных. База лежит в его корне."""
|
||||
return VAULT_NAME
|
||||
|
||||
|
||||
def backup_path() -> Path:
|
||||
|
||||
@ -46,7 +46,29 @@ def _migrate_0_to_1(raw: dict) -> dict:
|
||||
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:
|
||||
|
||||
@ -59,7 +59,7 @@ class BatchDialog(QDialog):
|
||||
layout.addWidget(w.label("Что взял", "h2"))
|
||||
products = [p for p in ctx.vault.doc.products if not p.archived]
|
||||
self.lines = w.LinesEditor(
|
||||
products, "Себестоимость", lambda p: p.cost_price, ctx.currency
|
||||
products, "Себестоимость", lambda p, uom: p.cost_for(uom), ctx.currency
|
||||
)
|
||||
if batch:
|
||||
self.lines.set_lines(batch.lines, "unit_cost")
|
||||
|
||||
@ -123,6 +123,7 @@ class DebtsPage(QWidget):
|
||||
for sale in sorted(debtor.sales, key=lambda s: s.date):
|
||||
what = ", ".join(
|
||||
f"{doc.product_name(line.product_id)} × {m.fmt_qty(line.qty)} "
|
||||
f"{line.unit_name(doc.product(line.product_id))}"
|
||||
for line in sale.lines
|
||||
)
|
||||
child = QTreeWidgetItem(
|
||||
|
||||
@ -10,6 +10,8 @@ from PySide6.QtWidgets import (
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@ -20,6 +22,90 @@ from . import theme
|
||||
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):
|
||||
"""Карточка товара. Цены здесь только при создании — потом через историю."""
|
||||
|
||||
@ -27,7 +113,7 @@ class ProductDialog(QDialog):
|
||||
super().__init__(parent)
|
||||
self.product = product
|
||||
self.setWindowTitle("Товар" if product else "Новый товар")
|
||||
self.setMinimumWidth(400)
|
||||
self.setMinimumWidth(520)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
@ -41,6 +127,7 @@ class ProductDialog(QDialog):
|
||||
form.addRow("Название", self.name)
|
||||
|
||||
self.unit = QLineEdit(product.unit if product else "шт")
|
||||
self.unit.setToolTip("Базовая единица: в ней считаются остатки и себестоимость.")
|
||||
form.addRow("Единица", self.unit)
|
||||
|
||||
if product is None:
|
||||
@ -52,8 +139,24 @@ class ProductDialog(QDialog):
|
||||
self.note = QPlainTextEdit(product.note if product else "")
|
||||
self.note.setMaximumHeight(70)
|
||||
form.addRow("Заметка", self.note)
|
||||
|
||||
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:
|
||||
layout.addWidget(
|
||||
w.label("Цены меняются отдельной кнопкой — так сохраняется их история.", "dim")
|
||||
@ -140,8 +243,15 @@ class ProductsPage(QWidget):
|
||||
)
|
||||
|
||||
self.table = w.table(
|
||||
["Название", "Ед.", "Себестоимость", "Цена продажи", "Наценка", "Остаток", "Статус"]
|
||||
[
|
||||
"Название", "Ед.", "Фасовки", "Себестоимость",
|
||||
"Цена продажи", "Наценка", "Остаток", "Статус",
|
||||
]
|
||||
)
|
||||
# По содержимому список фасовок разрастается и отжимает название
|
||||
# в многоточие. Ограничиваем его и отдаём остаток названию.
|
||||
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Interactive)
|
||||
self.table.setColumnWidth(2, 330)
|
||||
self.table.doubleClicked.connect(self.edit_prices)
|
||||
layout.addWidget(self.table, 1)
|
||||
|
||||
@ -163,10 +273,16 @@ class ProductsPage(QWidget):
|
||||
|
||||
margin = m.money(product.retail_price - product.cost_price)
|
||||
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(
|
||||
[
|
||||
w.text_item(product.name, theme.MUTED if product.archived else ""),
|
||||
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.retail_price, currency), product.retail_price),
|
||||
w.sortable_num_item(
|
||||
@ -196,7 +312,7 @@ class ProductsPage(QWidget):
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
try:
|
||||
journal.create_product(
|
||||
product = journal.create_product(
|
||||
self.ctx.vault,
|
||||
dialog.name.text(),
|
||||
dialog.unit.text(),
|
||||
@ -204,6 +320,7 @@ class ProductsPage(QWidget):
|
||||
dialog.retail.value_decimal(),
|
||||
dialog.note.toPlainText(),
|
||||
)
|
||||
journal.set_packs(self.ctx.vault, product.id, dialog.packs.packs())
|
||||
except journal.ValidationError as exc:
|
||||
QMessageBox.warning(self, "Не получилось", str(exc))
|
||||
return
|
||||
@ -225,6 +342,7 @@ class ProductsPage(QWidget):
|
||||
unit=dialog.unit.text(),
|
||||
note=dialog.note.toPlainText(),
|
||||
)
|
||||
journal.set_packs(self.ctx.vault, product.id, dialog.packs.packs())
|
||||
except journal.ValidationError as exc:
|
||||
QMessageBox.warning(self, "Не получилось", str(exc))
|
||||
return
|
||||
|
||||
@ -26,16 +26,17 @@ from . import widgets as w
|
||||
|
||||
|
||||
def price_source_for(kind: str):
|
||||
"""Откуда берётся цена по умолчанию для этого типа выбытия.
|
||||
"""Откуда берётся цена по умолчанию для этого типа выбытия и фасовки.
|
||||
|
||||
Ради этого типы и заведены: друг платит себестоимость, съеденное не стоит
|
||||
ничего, розница идёт по цене продажи.
|
||||
ничего, розница идёт по цене продажи. Цена всегда за одну штуку выбранной
|
||||
фасовки — пачка стоит своих денег, а не десяти поштучных цен.
|
||||
"""
|
||||
if kind == KIND_FRIEND:
|
||||
return lambda product: product.cost_price
|
||||
return lambda product, uom: product.cost_for(uom)
|
||||
if kind in CONSUMPTION_KINDS:
|
||||
return lambda product: m.ZERO
|
||||
return lambda product: product.retail_price
|
||||
return lambda product, uom: m.ZERO
|
||||
return lambda product, uom: product.retail_for(uom)
|
||||
|
||||
|
||||
class SaleDialog(QDialog):
|
||||
@ -176,6 +177,7 @@ class SalesPage(QWidget):
|
||||
layout.addWidget(
|
||||
w.row(
|
||||
w.button("+ Продажа", "primary", self.create),
|
||||
w.button("Быстрый ввод за период", on_click=self.quick_entry),
|
||||
w.button("Изменить", on_click=self.edit),
|
||||
w.button("Принять оплату", on_click=self.take_payment),
|
||||
w.button("Удалить", "danger", self.delete),
|
||||
@ -204,6 +206,7 @@ class SalesPage(QWidget):
|
||||
|
||||
what = ", ".join(
|
||||
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
|
||||
)
|
||||
debt = sale.debt
|
||||
@ -261,6 +264,21 @@ class SalesPage(QWidget):
|
||||
return
|
||||
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:
|
||||
sale = self._selected()
|
||||
if sale is None:
|
||||
|
||||
443
app/ui/quick_sales.py
Normal file
443
app/ui/quick_sales.py
Normal 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
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtCore import QLocale, Qt
|
||||
from PySide6.QtGui import QColor, QPalette
|
||||
|
||||
BG = "#1c1f24"
|
||||
@ -33,6 +33,9 @@ STATUS_COLORS = {
|
||||
def apply(app) -> None:
|
||||
app.setStyle("Fusion")
|
||||
|
||||
# Иначе поля ввода показывают «1,800.00», а таблицы рядом — «1 800,00».
|
||||
QLocale.setDefault(QLocale(QLocale.Russian, QLocale.Russia))
|
||||
|
||||
palette = QPalette()
|
||||
palette.setColor(QPalette.Window, QColor(BG))
|
||||
palette.setColor(QPalette.WindowText, QColor(TEXT))
|
||||
|
||||
@ -105,6 +105,18 @@ class ProductCombo(QComboBox):
|
||||
completer.setCompletionMode(QCompleter.PopupCompletion)
|
||||
for product in products:
|
||||
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:
|
||||
return self.currentData()
|
||||
@ -113,6 +125,7 @@ class ProductCombo(QComboBox):
|
||||
index = self.findData(product_id)
|
||||
if index >= 0:
|
||||
self.setCurrentIndex(index)
|
||||
self._show_beginning()
|
||||
|
||||
|
||||
# --- мелкие сборки --------------------------------------------------------
|
||||
@ -284,11 +297,15 @@ class LinesEditor(QWidget):
|
||||
|
||||
Один виджет на оба случая: отличается только подпись колонки цены и то,
|
||||
откуда берётся подстановка — себестоимость или цена продажи.
|
||||
|
||||
Колонка фасовки позволяет в одной строке взять пачку, а в другой — штуку
|
||||
того же товара. Количество и цена вводятся в выбранной фасовке; в базовые
|
||||
единицы всё пересчитывается уже в расчётах.
|
||||
"""
|
||||
|
||||
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):
|
||||
super().__init__(parent)
|
||||
@ -300,14 +317,23 @@ class LinesEditor(QWidget):
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(6)
|
||||
|
||||
self.table = QTableWidget(0, 4)
|
||||
self.table.setHorizontalHeaderLabels(["Товар", "Кол-во", price_title, "Сумма"])
|
||||
self.table = QTableWidget(0, 5)
|
||||
self.table.setHorizontalHeaderLabels(
|
||||
["Товар", "Кол-во", "Фасовка", price_title, "Сумма"]
|
||||
)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
# Строки состоят из полей ввода — по содержимому они выходят слишком
|
||||
# низкими и текст обрезается.
|
||||
self.table.verticalHeader().setDefaultSectionSize(36)
|
||||
|
||||
header = self.table.horizontalHeader()
|
||||
header.setSectionResizeMode(self.COL_PRODUCT, QHeaderView.Stretch)
|
||||
for column in (self.COL_QTY, self.COL_PRICE, self.COL_TOTAL):
|
||||
header.setSectionResizeMode(column, QHeaderView.ResizeToContents)
|
||||
self.table.setMinimumHeight(180)
|
||||
for column, width in (
|
||||
(self.COL_QTY, 95), (self.COL_UOM, 115), (self.COL_PRICE, 130), (self.COL_TOTAL, 120)
|
||||
):
|
||||
header.setSectionResizeMode(column, QHeaderView.Interactive)
|
||||
self.table.setColumnWidth(column, width)
|
||||
self.table.setMinimumHeight(190)
|
||||
layout.addWidget(self.table)
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
@ -340,9 +366,14 @@ class LinesEditor(QWidget):
|
||||
qty_spin.valueChanged.connect(self._recalc)
|
||||
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.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)
|
||||
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:
|
||||
self.table.setRowCount(0)
|
||||
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]:
|
||||
result = []
|
||||
@ -372,11 +403,37 @@ class LinesEditor(QWidget):
|
||||
{
|
||||
"product_id": product_id,
|
||||
"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(),
|
||||
}
|
||||
)
|
||||
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:
|
||||
total = m.ZERO
|
||||
for r in range(self.table.rowCount()):
|
||||
@ -389,23 +446,32 @@ class LinesEditor(QWidget):
|
||||
def refresh_prices(self) -> None:
|
||||
"""Пересобрать подстановку цен — например, после смены типа продажи."""
|
||||
for r in range(self.table.rowCount()):
|
||||
combo = self.table.cellWidget(r, self.COL_PRODUCT)
|
||||
price_widget = self.table.cellWidget(r, self.COL_PRICE)
|
||||
if combo and price_widget:
|
||||
price_widget.set_decimal(self._suggested_price(combo.current_product_id()))
|
||||
self._apply_suggested_price(r)
|
||||
self._recalc()
|
||||
|
||||
# --- внутреннее ---
|
||||
|
||||
def _suggested_price(self, product_id: str | None) -> Decimal:
|
||||
product = next((p for p in self.products if p.id == product_id), None)
|
||||
return self.price_source(product) if product else m.ZERO
|
||||
def _suggested_price(self, product_id: str | None, uom: str) -> Decimal:
|
||||
product = self._product(product_id)
|
||||
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)
|
||||
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()))
|
||||
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()
|
||||
|
||||
def _recalc(self) -> None:
|
||||
|
||||
@ -8,7 +8,14 @@ from app.storage import Vault
|
||||
|
||||
@pytest.fixture
|
||||
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
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
"""Тесты синхронизации против локального bare-репозитория — без сети."""
|
||||
"""Тесты синхронизации против локального bare-репозитория — без сети.
|
||||
|
||||
Репозиторий данных отдельный от репозитория с кодом, и база лежит в его корне.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
|
||||
@ -12,7 +15,8 @@ pytestmark = pytest.mark.skipif(
|
||||
gitsync.git_executable() is None, reason="git не установлен"
|
||||
)
|
||||
|
||||
REL = "data/vault.fmdb"
|
||||
REL = "vault.fmdb"
|
||||
SETUP = {".gitattributes", ".gitignore"}
|
||||
|
||||
|
||||
def git(cwd, *args):
|
||||
@ -33,13 +37,26 @@ def remote(tmp_path):
|
||||
|
||||
@pytest.fixture
|
||||
def sync(tmp_path, remote):
|
||||
work = tmp_path / "work"
|
||||
(work / "data").mkdir(parents=True)
|
||||
(work / "data" / "vault.fmdb").write_bytes(b"encrypted-v1")
|
||||
work = tmp_path / "data"
|
||||
work.mkdir(parents=True)
|
||||
(work / REL).write_bytes(b"encrypted-v1")
|
||||
settings = GitSettings(remote_url=remote.as_posix(), branch="main")
|
||||
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):
|
||||
(sync.repo_dir / REL).write_bytes(content)
|
||||
|
||||
@ -48,10 +65,12 @@ def remote_log(remote):
|
||||
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):
|
||||
proc = subprocess.run(
|
||||
["git", "show", f"main:{REL}"], cwd=remote, capture_output=True
|
||||
)
|
||||
proc = subprocess.run(["git", "show", f"main:{REL}"], cwd=remote, capture_output=True)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return proc.stdout
|
||||
|
||||
@ -66,22 +85,33 @@ def test_first_sync_creates_repo_and_pushes(sync, remote):
|
||||
assert len(remote_log(remote)) == 1
|
||||
|
||||
|
||||
def test_sync_without_changes_does_nothing(sync, remote):
|
||||
sync.sync()
|
||||
result = sync.sync()
|
||||
def test_sync_without_changes_does_nothing(settled, remote):
|
||||
result = settled.sync()
|
||||
assert result.committed 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):
|
||||
"""Незакоммиченные правки исходников не должны уезжать вместе с данными."""
|
||||
(sync.repo_dir / "app.py").write_text("print('работа в процессе')", encoding="utf-8")
|
||||
def test_data_repo_holds_nothing_but_the_vault(sync, remote):
|
||||
"""Ради этого база и вынесена в свой репозиторий.
|
||||
|
||||
Что бы ни оказалось в папке рядом — в репозиторий данных уезжает только
|
||||
сама база и пара служебных файлов.
|
||||
"""
|
||||
(sync.repo_dir / "main.py").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()
|
||||
files = git(remote, "show", "--name-only", "--format=", "main").split()
|
||||
assert files == [REL]
|
||||
assert remote_files(remote) == {REL} | SETUP
|
||||
|
||||
|
||||
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):
|
||||
@ -91,31 +121,81 @@ def test_commit_message_leaks_no_business_numbers(sync, remote):
|
||||
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 копии файла в сутки, а одна."""
|
||||
sync.sync()
|
||||
for i in range(2, 5):
|
||||
write_vault(sync, f"encrypted-v{i}".encode())
|
||||
result = sync.sync()
|
||||
assert result.amended is True
|
||||
before = len(remote_log(remote))
|
||||
|
||||
assert len(remote_log(remote)) == 1
|
||||
assert remote_vault(remote) == b"encrypted-v4"
|
||||
for i in range(3, 6):
|
||||
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):
|
||||
sync.settings.daily_squash = False
|
||||
def test_the_setup_commit_is_never_rewritten(sync, remote):
|
||||
"""Первая вершина несёт не только базу, поэтому amend по ней запрещён."""
|
||||
sync.sync()
|
||||
write_vault(sync, b"encrypted-v2")
|
||||
result = sync.sync()
|
||||
|
||||
result = sync.sync()
|
||||
assert result.amended is False
|
||||
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):
|
||||
"""Переписывать можно только ту вершину, которая точно наша и уже запушена."""
|
||||
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
|
||||
|
||||
|
||||
def test_yesterdays_commit_is_not_amended(sync, remote):
|
||||
sync.sync()
|
||||
sync._run(["commit", "--amend", "-m", "vault 2020-01-01"])
|
||||
assert sync._can_amend(remote=sync.head_sha()) is False
|
||||
def test_yesterdays_commit_is_not_amended(settled):
|
||||
settled._run(["commit", "--amend", "-m", "vault 2020-01-01"])
|
||||
assert settled._can_amend(remote=settled.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 другая машина успела запушить своё. Мы уже
|
||||
переписали локальную вершину — force-with-lease обязан это поймать.
|
||||
"""
|
||||
sync.sync()
|
||||
stale = sync.head_sha()
|
||||
stale = settled.head_sha()
|
||||
theirs = clone_and_push(tmp_path, remote, b"encrypted-from-other-machine")
|
||||
|
||||
other = tmp_path / "other"
|
||||
git(tmp_path, "clone", remote.as_posix(), "other")
|
||||
(other / "data" / "vault.fmdb").write_bytes(b"encrypted-from-other-machine")
|
||||
git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01")
|
||||
git(other, "push", "origin", "main")
|
||||
theirs = git(other, "rev-parse", "HEAD")
|
||||
|
||||
write_vault(sync, b"encrypted-ours")
|
||||
committed, amended, lease = sync._commit(remote=stale)
|
||||
write_vault(settled, b"encrypted-ours")
|
||||
committed, amended, lease = settled._commit(remote=stale)
|
||||
assert (committed, amended, lease) == (True, True, stale)
|
||||
|
||||
with pytest.raises(Diverged):
|
||||
sync._push(amended=True, lease=lease)
|
||||
settled._push(amended=True, lease=lease)
|
||||
|
||||
# Чужой коммит на месте, наш форс его не снёс.
|
||||
assert git(remote, "rev-parse", "main") == theirs
|
||||
assert remote_vault(remote) == b"encrypted-from-other-machine"
|
||||
|
||||
|
||||
def test_remote_ahead_and_clean_fast_forwards(sync, remote, tmp_path):
|
||||
sync.sync()
|
||||
def test_remote_ahead_and_clean_fast_forwards(settled, remote, tmp_path):
|
||||
clone_and_push(tmp_path, remote, b"encrypted-from-other-machine")
|
||||
|
||||
other = tmp_path / "other"
|
||||
git(tmp_path, "clone", remote.as_posix(), "other")
|
||||
(other / "data" / "vault.fmdb").write_bytes(b"encrypted-from-other-machine")
|
||||
git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01")
|
||||
git(other, "push", "origin", "main")
|
||||
|
||||
result = sync.sync()
|
||||
result = settled.sync()
|
||||
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"
|
||||
git(tmp_path, "clone", remote.as_posix(), "other")
|
||||
(other / "data" / "vault.fmdb").write_bytes(b"encrypted-from-other-machine")
|
||||
git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01")
|
||||
git(other, "push", "origin", "main")
|
||||
|
||||
write_vault(sync, b"encrypted-ours")
|
||||
copy = sync.repo_dir / "data" / "vault.remote.fmdb"
|
||||
write_vault(settled, b"encrypted-ours")
|
||||
copy = settled.repo_dir / "vault.remote.fmdb"
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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():
|
||||
url = auth_url("https://gt.ser.gay/kizya/food-market.git", "glpat-secret")
|
||||
assert url == "https://oauth2:glpat-secret@gt.ser.gay/kizya/food-market.git"
|
||||
url = auth_url("https://gt.ser.gay/kizya/food-records.git", "glpat-secret")
|
||||
assert url == "https://oauth2:glpat-secret@gt.ser.gay/kizya/food-records.git"
|
||||
|
||||
|
||||
def test_auth_url_replaces_existing_credentials():
|
||||
url = auth_url("https://old:creds@gt.ser.gay/kizya/food-market.git", "glpat-new")
|
||||
assert url == "https://oauth2:glpat-new@gt.ser.gay/kizya/food-market.git"
|
||||
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-records.git"
|
||||
|
||||
|
||||
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.ensure_repo()
|
||||
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
|
||||
|
||||
@ -307,6 +307,128 @@ def test_empty_database_reports_zeroes(vault, today):
|
||||
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):
|
||||
rows = {r.name: r.qty for r in ledger.build(scenario["vault"].doc, today).stock}
|
||||
assert rows["Булка с повидлом"] == Decimal("2.000")
|
||||
|
||||
297
tests/test_quick_sales.py
Normal file
297
tests/test_quick_sales.py
Normal 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"]
|
||||
@ -4,6 +4,7 @@ from decimal import Decimal
|
||||
import pytest
|
||||
|
||||
from app import crypto, journal
|
||||
from app.models import SCHEMA_VERSION, Document
|
||||
from app.storage import Vault, content_hash, migrate
|
||||
|
||||
|
||||
@ -117,10 +118,43 @@ def test_export_plain_json(tmp_path):
|
||||
|
||||
def test_migration_adds_journal():
|
||||
raw = migrate({"schema_version": 0, "products": []})
|
||||
assert raw["schema_version"] == 1
|
||||
assert raw["schema_version"] == SCHEMA_VERSION
|
||||
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():
|
||||
with pytest.raises(crypto.UnsupportedFormat, match="новой версией"):
|
||||
migrate({"schema_version": 999})
|
||||
|
||||
@ -160,8 +160,8 @@ def test_products_table_shows_stock_and_margin(window):
|
||||
assert "Булка с маком" in names
|
||||
|
||||
row = names.index("Булка с маком")
|
||||
assert "15" in page.table.item(row, 4).text() # 35 − 20
|
||||
assert page.table.item(row, 5).text() == "5" # 10 закуплено − 5 отдано
|
||||
assert "15" in page.table.item(row, 5).text() # наценка: 35 − 20
|
||||
assert page.table.item(row, 6).text() == "5" # 10 закуплено − 5 отдано
|
||||
|
||||
|
||||
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):
|
||||
assert price_source_for(KIND_RETAIL)(buns["мак"]) == Decimal("35.00")
|
||||
assert price_source_for(KIND_FRIEND)(buns["мак"]) == Decimal("20.00")
|
||||
assert price_source_for(KIND_SELF)(buns["мак"]) == 0
|
||||
bun = buns["мак"]
|
||||
assert price_source_for(KIND_RETAIL)(bun, "шт") == Decimal("35.00")
|
||||
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):
|
||||
@ -343,6 +357,55 @@ def test_product_dialog_hides_prices_when_editing(window, buns):
|
||||
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):
|
||||
dialog = BatchDialog(window)
|
||||
editor = dialog.lines
|
||||
|
||||
Loading…
Reference in New Issue
Block a user