diff --git a/.gitignore b/.gitignore index 4ed4f49..2c53091 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 416afc9..7c2cfcc 100644 --- a/README.md +++ b/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`, которые сами считают разницу и diff --git a/app/gitsync.py b/app/gitsync.py index 725f37b..64dc374 100644 --- a/app/gitsync.py +++ b/app/gitsync.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) diff --git a/app/journal.py b/app/journal.py index d3493d5..a58ed3a 100644 --- a/app/journal.py +++ b/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, } @@ -120,11 +123,22 @@ def _flat_counterparty(doc, c: Counterparty) -> dict[str, str]: def _lines_text(doc, lines, price_attr: str) -> str: + cur = doc.settings.currency + 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)}" + ) + return "; ".join(parts) + + +def _packs_text(doc, product: Product) -> str: cur = doc.settings.currency return "; ".join( - f"{doc.product_name(x.product_id)} × {m.fmt_qty(x.qty)} " - f"по {m.fmt_money(getattr(x, price_attr), cur)}" - for x in lines + 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("В продаже должна быть хотя бы одна строка.") diff --git a/app/ledger.py b/app/ledger.py index 79753a8..e85c2e3 100644 --- a/app/ledger.py +++ b/app/ledger.py @@ -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, ) diff --git a/app/models.py b/app/models.py index 6c57495..991219b 100644 --- a/app/models.py +++ b/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" diff --git a/app/paths.py b/app/paths.py index 6676609..da7d7fb 100644 --- a/app/paths.py +++ b/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: diff --git a/app/storage.py b/app/storage.py index 28c1c1e..57af8fb 100644 --- a/app/storage.py +++ b/app/storage.py @@ -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: diff --git a/app/ui/page_batches.py b/app/ui/page_batches.py index a66a36a..fcd3294 100644 --- a/app/ui/page_batches.py +++ b/app/ui/page_batches.py @@ -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") diff --git a/app/ui/page_debts.py b/app/ui/page_debts.py index 8476763..767ebad 100644 --- a/app/ui/page_debts.py +++ b/app/ui/page_debts.py @@ -122,7 +122,8 @@ 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"{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( diff --git a/app/ui/page_products.py b/app/ui/page_products.py index a8bbd93..7f0c877 100644 --- a/app/ui/page_products.py +++ b/app/ui/page_products.py @@ -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 diff --git a/app/ui/page_sales.py b/app/ui/page_sales.py index 5fba0e9..5a6baca 100644 --- a/app/ui/page_sales.py +++ b/app/ui/page_sales.py @@ -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), @@ -203,7 +205,8 @@ class SalesPage(QWidget): continue what = ", ".join( - f"{doc.product_name(line.product_id)} × {m.fmt_qty(line.qty)}" + f"{doc.product_name(line.product_id)} × {m.fmt_qty(line.qty)} " + f"{line.unit_name(doc.product(line.product_id))}" for line in sale.lines ) 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: diff --git a/app/ui/quick_sales.py b/app/ui/quick_sales.py new file mode 100644 index 0000000..6a59730 --- /dev/null +++ b/app/ui/quick_sales.py @@ -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 diff --git a/app/ui/theme.py b/app/ui/theme.py index 38537b9..301f100 100644 --- a/app/ui/theme.py +++ b/app/ui/theme.py @@ -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)) diff --git a/app/ui/widgets.py b/app/ui/widgets.py index 0bcb172..9f475b3 100644 --- a/app/ui/widgets.py +++ b/app/ui/widgets.py @@ -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: diff --git a/tests/conftest.py b/tests/conftest.py index eb7abef..8324d9a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_gitsync.py b/tests/test_gitsync.py index 4d495b1..ef179e0 100644 --- a/tests/test_gitsync.py +++ b/tests/test_gitsync.py @@ -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 diff --git a/tests/test_ledger.py b/tests/test_ledger.py index 85d9c34..a24d9d2 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -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") diff --git a/tests/test_quick_sales.py b/tests/test_quick_sales.py new file mode 100644 index 0000000..ad72e29 --- /dev/null +++ b/tests/test_quick_sales.py @@ -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"] diff --git a/tests/test_storage.py b/tests/test_storage.py index a5bcadd..dda7c38 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -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}) diff --git a/tests/test_ui_smoke.py b/tests/test_ui_smoke.py index 99aae68..1d595cd 100644 --- a/tests/test_ui_smoke.py +++ b/tests/test_ui_smoke.py @@ -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