diff --git a/README.md b/README.md index 1cd40ec..80c99ff 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,26 @@ - **Журнал** — что менялось в базе, когда и с какой машины. - **Сводка** — долг пекарне, дебиторка, прибыль, остатки, ближайший дедлайн. +### Чаевые + +Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не +внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела бы +закрытой, когда за булки ещё должны. + +Если вводишь оплату больше суммы — булка 70, дали 100 — программа сама +предложит записать разницу в чаевые. То же самое при приёме оплаты по долгу: +заплатили больше, чем оставалось, — излишек становится чаевыми, а не теряется. + +В расчётах чаевые: + +- **идут в покрытие партии** — это живые деньги, ими так же рассчитываются + с пекарней. Если продажа списалась с нескольких партий, чаевые делятся + между ними пропорционально выручке; +- **добавляются в прибыль целиком** — себестоимости за ними нет; +- **не входят в выручку и не создают долга** — за них никто ничего не должен. + +По съеденному, подаренному и списанному чаевых не бывает — поле отключается. + ### Дробные количества Количества хранятся с точностью до тысячных, поэтому 0,5 л сока, 1,125 кг сыра @@ -71,8 +91,9 @@ поэтому на себестоимость это не влияет — FIFO нужен только чтобы понимать, деньги за какую партию уже пришли. -Прибыль считается как наценка с проданного **минус** себестоимость съеденного -и подаренного: за них пекарне платить всё равно, и покрывается это из маржи. +Прибыль считается как наценка с проданного **плюс** чаевые **минус** +себестоимость съеденного и подаренного: за съеденное пекарне платить всё +равно, и покрывается это из маржи. ## Как запустить diff --git a/app/journal.py b/app/journal.py index a58ed3a..32a4106 100644 --- a/app/journal.py +++ b/app/journal.py @@ -19,6 +19,7 @@ from __future__ import annotations import socket from datetime import date, datetime +from decimal import Decimal from . import money as m from .models import ( @@ -162,6 +163,7 @@ def _flat_sale(doc, s: Sale) -> dict[str, str]: "состав": _lines_text(doc, s.lines, "unit_price"), "сумма": m.fmt_money(s.total, cur), "оплачено": m.fmt_money(s.paid, cur), + "чаевые": m.fmt_money(s.tip, cur) if s.tip else "", "заметка": s.note, } @@ -609,6 +611,18 @@ def _build_sale_lines(doc, lines: list[dict]) -> list[SaleLine]: return result +def split_overpayment(total: Decimal, paid: Decimal, tip: Decimal) -> tuple[Decimal, Decimal]: + """Разложить полученные деньги на оплату товара и чаевые. + + Дали больше, чем стоит булка, — значит сдачу оставили. Излишек уходит + в чаевые, а не раздувает «оплачено»: иначе долг и процент оплаты продажи + считались бы по числу, которого за товар никто не должен. + """ + if paid > total: + return total, m.money(tip + (paid - total)) + return paid, tip + + def create_sale( vault, on_date: date, @@ -617,11 +631,12 @@ def create_sale( counterparty_id: str | None = None, note: str = "", paid_amount=None, + tip=None, ) -> Sale: """Записать выбытие товара. paid_amount=None означает «оплачено полностью» — самый частый случай. - Явный ноль или частичная сумма создают долг. + Явный ноль или частичная сумма создают долг, а переплата — чаевые. """ doc = vault.doc sale = Sale( @@ -635,8 +650,12 @@ def create_sale( if not sale.is_consumption: amount = sale.total if paid_amount is None else m.money(paid_amount) - if amount < 0: - raise ValidationError("Оплата не может быть отрицательной.") + tip_amount = m.money(tip or 0) + if amount < 0 or tip_amount < 0: + raise ValidationError("Оплата и чаевые не могут быть отрицательными.") + + amount, tip_amount = split_overpayment(sale.total, amount, tip_amount) + sale.tip = tip_amount if amount > 0: sale.payments.append(Payment(date=sale.date, amount=amount)) @@ -660,6 +679,7 @@ def update_sale( lines: list[dict] | None = None, counterparty_id: str | None = None, note: str | None = None, + tip=None, ) -> Sale: """Правка продажи. Платежи не трогает — для них есть отдельные операции.""" doc = vault.doc @@ -678,6 +698,14 @@ def update_sale( sale.counterparty_id = counterparty_id or None if note is not None: sale.note = note.strip() + if tip is not None: + amount = m.money(tip) + if amount < 0: + raise ValidationError("Чаевые не могут быть отрицательными.") + sale.tip = amount + if sale.is_consumption: + # Съеденное и подаренное денег не приносит по определению. + sale.tip = m.ZERO changes = _diff(before, _flat_sale(doc, sale)) if changes: @@ -698,6 +726,12 @@ def delete_sale(vault, sale_id: str) -> None: def add_sale_payment(vault, sale_id: str, amount, on_date: date | None = None, note: str = "") -> Sale: + """Принять оплату по продаже. + + Заплатили больше, чем оставалось должны, — излишек становится чаевыми, + а не исчезает. Раньше он просто терялся: долг упирался в ноль, и деньги + нигде не отражались. + """ doc = vault.doc sale = doc.sale(sale_id) if sale is None: @@ -708,9 +742,15 @@ def add_sale_payment(vault, sale_id: str, amount, on_date: date | None = None, n raise ValidationError("Сумма оплаты должна быть больше нуля.") before = _flat_sale(doc, sale) - sale.payments.append( - Payment(date=parse_date(on_date) if on_date else date.today(), amount=amount, note=note.strip()) - ) + payment, sale.tip = split_overpayment(sale.debt, amount, sale.tip) + if payment > 0: + sale.payments.append( + Payment( + date=parse_date(on_date) if on_date else date.today(), + amount=payment, + note=note.strip(), + ) + ) _record( vault, "sale.payment_add", @@ -722,6 +762,34 @@ def add_sale_payment(vault, sale_id: str, amount, on_date: date | None = None, n return sale +def set_sale_tip(vault, sale_id: str, amount) -> Sale: + """Задать чаевые по уже записанной продаже.""" + doc = vault.doc + sale = doc.sale(sale_id) + if sale is None: + raise ValidationError("Продажа не найдена.") + if sale.is_consumption: + raise ValidationError("По съеденному и подаренному чаевых не бывает.") + + amount = m.money(amount) + if amount < 0: + raise ValidationError("Чаевые не могут быть отрицательными.") + if amount == sale.tip: + return sale + + before = _flat_sale(doc, sale) + sale.tip = amount + _record( + vault, + "sale.tip", + "sale", + sale.id, + _sale_label(doc, sale), + _diff(before, _flat_sale(doc, sale)), + ) + return sale + + def delete_sale_payment(vault, sale_id: str, index: int) -> Sale: doc = vault.doc sale = doc.sale(sale_id) diff --git a/app/ledger.py b/app/ledger.py index eb64052..dc57183 100644 --- a/app/ledger.py +++ b/app/ledger.py @@ -52,6 +52,10 @@ class Allocation: unit_price: Decimal kind: str paid_fraction: Decimal + # Доля чаевых с продажи, пришедшаяся на эту партию. Продажа может списаться + # с нескольких партий, поэтому чаевые делятся между ними пропорционально + # выручке. + tip_share: Decimal = m.ZERO @property def cost(self) -> Decimal: @@ -59,13 +63,17 @@ class Allocation: @property def revenue(self) -> Decimal: - """Начислено — включая то, что ещё не оплачено.""" + """Начислено за товар — включая то, что ещё не оплачено.""" return self.qty * self.unit_price @property def cash(self) -> Decimal: - """Реально полученные деньги с учётом частичной оплаты.""" - return self.revenue * self.paid_fraction + """Реально полученные деньги: оплата за товар плюс чаевые. + + Чаевые входят сюда, потому что это живые деньги на руках — ими так же + рассчитываются с пекарней, как и выручкой. + """ + return self.revenue * self.paid_fraction + self.tip_share @dataclass @@ -138,6 +146,7 @@ class Summary: revenue: Decimal cash_collected: Decimal gross_margin: Decimal + tips: Decimal consumed_cost: Decimal # Осторожно: сумма по всем товарам сразу. Осмысленна, только когда единица # измерения у всех одна. Литры с килограммами складывать нельзя, поэтому @@ -153,9 +162,10 @@ class Summary: """Что осталось тебе. Съеденное и подаренное вычитается: пекарне за эти булки всё равно - платить, и покрывается это из наценки на проданных. + платить, и покрывается это из наценки на проданных. Чаевые + прибавляются целиком — себестоимости за ними нет. """ - return m.money(self.gross_margin - self.consumed_cost) + return m.money(self.gross_margin + self.tips - self.consumed_cost) @dataclass @@ -247,6 +257,10 @@ def allocate( for _, sale in _ordered_sales(doc): fraction = _paid_fraction(sale) + # Аллокации одной продажи собираются отдельно: доли чаевых можно + # посчитать, только когда известны все куски продажи целиком. + of_this_sale: list[Allocation] = [] + for line_index, line in enumerate(sale.lines): need = line.base_qty for batch_id in order: @@ -259,7 +273,7 @@ def allocate( take = min(available, need) remaining[batch_id][line.product_id] = available - take need -= take - allocations.append( + of_this_sale.append( Allocation( sale_id=sale.id, line_index=line_index, @@ -278,9 +292,36 @@ def allocate( Shortfall(sale_id=sale.id, product_id=line.product_id, qty=need) ) + _spread_tip(sale.tip, of_this_sale) + allocations.extend(of_this_sale) + return allocations, shortfalls, remaining +def _spread_tip(tip: Decimal, allocations: list[Allocation]) -> None: + """Разложить чаевые с продажи по партиям, из которых она списалась. + + Делим пропорционально выручке: чем больше денег принесла партия, тем + большая доля чаевых на неё приходится. Если выручки нет вовсе (отдал + даром, а денег всё равно дали) — делим по количеству. + """ + if tip <= 0 or not allocations: + return + + base = sum((a.revenue for a in allocations), m.ZERO) + if base <= 0: + base = sum((a.qty for a in allocations), m.ZERO) + weights = [a.qty for a in allocations] + else: + weights = [a.revenue for a in allocations] + + if base <= 0: + return + + for allocation, weight in zip(allocations, weights): + allocation.tip_share = tip * weight / base + + # --- Разнесение платежей пекарне ------------------------------------------ @@ -477,6 +518,9 @@ def _summary( revenue=m.money(revenue), cash_collected=m.money(cash), gross_margin=m.money(margin), + # Считаем по продажам, а не по аллокациям: если товара не хватило и + # часть продажи никуда не легла, чаевые всё равно получены полностью. + tips=m.money(sum((s.tip for s in doc.sales), m.ZERO)), 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((r.stock_cost for r in batch_reports), m.ZERO)), diff --git a/app/models.py b/app/models.py index 991219b..f545af7 100644 --- a/app/models.py +++ b/app/models.py @@ -15,7 +15,7 @@ from typing import Any from . import money as m -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 ONE = Decimal(1) @@ -410,16 +410,26 @@ class Sale: counterparty_id: str | None = None lines: list[SaleLine] = field(default_factory=list) payments: list[Payment] = field(default_factory=list) + # Деньги сверх стоимости товара. Живут отдельно от payments намеренно: + # иначе чаевые раздували бы «оплачено» и продажа выглядела бы закрытой, + # когда за булки ещё должны. + tip: Decimal = m.ZERO note: str = "" @property def total(self) -> Decimal: + """Стоимость товара. Чаевые сюда не входят — за них ничего не должны.""" return m.money(sum((line.total for line in self.lines), m.ZERO)) @property def paid(self) -> Decimal: return m.money(sum((p.amount for p in self.payments), m.ZERO)) + @property + def received(self) -> Decimal: + """Сколько денег реально пришло по этой продаже, вместе с чаевыми.""" + return m.money(self.paid + self.tip) + @property def debt(self) -> Decimal: """Сколько ещё должны по этой продаже (не бывает отрицательным).""" @@ -438,6 +448,7 @@ class Sale: counterparty_id=d.get("counterparty_id") or None, lines=[SaleLine.from_dict(x) for x in d.get("lines", [])], payments=[Payment.from_dict(x) for x in d.get("payments", [])], + tip=m.money(d.get("tip")), note=d.get("note", ""), ) @@ -449,6 +460,7 @@ class Sale: "counterparty_id": self.counterparty_id, "lines": [x.to_dict() for x in self.lines], "payments": [x.to_dict() for x in self.payments], + "tip": m.dumps(self.tip), "note": self.note, } diff --git a/app/storage.py b/app/storage.py index 57af8fb..3ebb333 100644 --- a/app/storage.py +++ b/app/storage.py @@ -68,7 +68,20 @@ def _migrate_1_to_2(raw: dict) -> dict: return raw -_MIGRATIONS = {0: _migrate_0_to_1, 1: _migrate_1_to_2} +def _migrate_2_to_3(raw: dict) -> dict: + """Появились чаевые. + + У всего, что записано раньше, их просто не было. Ноль не меняет ни одну + цифру в отчётах. + """ + for sale in raw.get("sales", []): + sale.setdefault("tip", "0") + + raw["schema_version"] = 3 + return raw + + +_MIGRATIONS = {0: _migrate_0_to_1, 1: _migrate_1_to_2, 2: _migrate_2_to_3} def migrate(raw: dict) -> dict: diff --git a/app/ui/page_dashboard.py b/app/ui/page_dashboard.py index 7e2e936..fd10d47 100644 --- a/app/ui/page_dashboard.py +++ b/app/ui/page_dashboard.py @@ -16,6 +16,15 @@ from . import theme from . import widgets as w +def _profit_hint(summary, currency: str) -> str: + """Расшифровка прибыли. Чаевые упоминаются, только когда они есть.""" + parts = [f"наценка {m.fmt_money(summary.gross_margin, currency)}"] + if summary.tips > 0: + parts.append(f"+ чаевые {m.fmt_money(summary.tips, currency)}") + parts.append(f"− съедено {m.fmt_money(summary.consumed_cost, currency)}") + return " ".join(parts) + + class DashboardPage(QWidget): def __init__(self, ctx, parent=None): super().__init__(parent) @@ -94,8 +103,7 @@ class DashboardPage(QWidget): ( "Прибыль", m.fmt_money(summary.profit, currency), - f"наценка {m.fmt_money(summary.gross_margin, currency)} " - f"− съедено {m.fmt_money(summary.consumed_cost, currency)}", + _profit_hint(summary, currency), theme.OK if summary.profit > 0 else theme.DANGER if summary.profit < 0 else theme.TEXT, ), ( diff --git a/app/ui/page_sales.py b/app/ui/page_sales.py index 6c6024a..7f93c6e 100644 --- a/app/ui/page_sales.py +++ b/app/ui/page_sales.py @@ -83,6 +83,7 @@ class SaleDialog(QDialog): # в обработчик, для которого self.paid ещё не существует, — Qt съедает # исключение, и подстановка оплаты молча не работает. self.paid = None + self.tip = None self.paid_hint = None layout.addWidget(w.label("Что ушло", "h2")) @@ -99,13 +100,26 @@ class SaleDialog(QDialog): # Оплату задаём только при создании: у существующей продажи платежи # живут своей жизнью и правятся на экране долгов. + pay_form = QFormLayout() if sale is None: - pay_form = QFormLayout() self.paid = w.MoneySpin(ctx.currency) + # Переплату разбираем, когда человек закончил вводить сумму, + # а не на каждом нажатии: иначе поле дёргалось бы посреди набора. + self.paid.editingFinished.connect(self._absorb_overpayment) pay_form.addRow("Оплачено сразу", self.paid) - layout.addLayout(pay_form) - self.paid_hint = w.label("", "dim") - layout.addWidget(self.paid_hint) + + # Чаевые правятся и у записанной продажи: сдачу могли оставить + # и после того, как продажу занесли. + self.tip = w.MoneySpin(ctx.currency) + if sale is not None: + self.tip.set_decimal(sale.tip) + self.tip.valueChanged.connect(lambda _: self._update_payment_hint()) + pay_form.addRow("Чаевые", self.tip) + layout.addLayout(pay_form) + + self.paid_hint = w.label("", "dim") + self.paid_hint.setWordWrap(True) + layout.addWidget(self.paid_hint) layout.addWidget( w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сохранить", "primary", self.accept)) @@ -125,28 +139,63 @@ class SaleDialog(QDialog): self.paid.setEnabled(not consumption) if consumption: self.paid.set_decimal(0) + if self.tip is not None: + self.tip.setEnabled(not consumption) + if consumption: + self.tip.set_decimal(0) self._sync_payment_default() def _sync_payment_default(self) -> None: - if self.paid is None: - return + """Подставить полную оплату — самый частый случай.""" + if self.paid is not None and self.current_kind() not in CONSUMPTION_KINDS: + # Пока поле в фокусе, не перебиваем то, что человек набирает. + if not self.paid.hasFocus(): + self.paid.set_decimal(self.lines.total()) + self._update_payment_hint() - kind = self.current_kind() - if kind in CONSUMPTION_KINDS: - self.paid_hint.setText("Денег нет по определению, но себестоимость пекарне вернуть надо.") + def _absorb_overpayment(self) -> None: + """Дали больше суммы — разница уходит в чаевые. + + Булка стоит 70, дали 100, сдачи не надо. Оставлять 100 в «оплачено» + нельзя: столько за товар никто не должен, и продажа выглядела бы + переплаченной вместо того, чтобы показать чаевые. + """ + if self.paid is None or self.tip is None: return total = self.lines.total() - # Полная оплата — самый частый случай, поэтому она и подставляется. - # Уменьшил сумму — получил долг, который поедет на экран «Долги». - if not self.paid.hasFocus(): - self.paid.set_decimal(total) + extra = m.money(self.paid.value_decimal() - total) + if extra <= 0: + self._update_payment_hint() + return - debt = m.money(total - self.paid.value_decimal()) - if debt > 0: - self.paid_hint.setText(f"В долг уйдёт {m.fmt_money(debt, self.ctx.currency)}.") - else: - self.paid_hint.setText("Оплачено полностью.") + self.paid.set_decimal(total) + self.tip.set_decimal(self.tip.value_decimal() + extra) + self.paid_hint.setText( + f"Переплата {m.fmt_money(extra, self.ctx.currency)} записана в чаевые." + ) + + def _update_payment_hint(self) -> None: + if self.paid_hint is None: + return + if self.current_kind() in CONSUMPTION_KINDS: + self.paid_hint.setText("Денег нет по определению, но себестоимость пекарне вернуть надо.") + return + + currency = self.ctx.currency + tip = self.tip.value_decimal() if self.tip is not None else m.ZERO + parts = [] + + if self.paid is not None: + debt = m.money(self.lines.total() - self.paid.value_decimal()) + parts.append( + f"В долг уйдёт {m.fmt_money(debt, currency)}." + if debt > 0 + else "Оплачено полностью." + ) + if tip > 0: + parts.append(f"Чаевые {m.fmt_money(tip, currency)} идут в покрытие партии и в прибыль.") + self.paid_hint.setText(" ".join(parts)) def _add_counterparty(self) -> None: from PySide6.QtWidgets import QInputDialog @@ -193,7 +242,8 @@ class SalesPage(QWidget): ) self.table = w.table( - ["Дата", "Тип", "Кому", "Что", "Сумма", "Оплачено", "Долг"], stretch_column=3 + ["Дата", "Тип", "Кому", "Что", "Сумма", "Оплачено", "Чаевые", "Долг"], + stretch_column=3, ) self.table.doubleClicked.connect(self.edit) layout.addWidget(self.table, 1) @@ -227,6 +277,11 @@ class SalesPage(QWidget): w.text_item(what), w.sortable_num_item(m.fmt_money(sale.total, currency), sale.total), w.sortable_num_item(m.fmt_money(sale.paid, currency), sale.paid), + w.sortable_num_item( + m.fmt_money(sale.tip, currency) if sale.tip > 0 else "", + sale.tip, + theme.OK if sale.tip > 0 else "", + ), w.sortable_num_item( m.fmt_money(debt, currency) if debt > 0 else "", debt, @@ -264,6 +319,7 @@ class SalesPage(QWidget): dialog.counterparty.currentData(), dialog.note.text(), dialog.paid.value_decimal(), + dialog.tip.value_decimal(), ) except journal.ValidationError as exc: QMessageBox.warning(self, "Не получилось", str(exc)) @@ -302,6 +358,7 @@ class SalesPage(QWidget): dialog.lines.lines("unit_price"), dialog.counterparty.currentData() or "", dialog.note.text(), + tip=dialog.tip.value_decimal(), ) except journal.ValidationError as exc: QMessageBox.warning(self, "Не получилось", str(exc)) diff --git a/app/ui/quick_sales.py b/app/ui/quick_sales.py index 6a59730..08be1b9 100644 --- a/app/ui/quick_sales.py +++ b/app/ui/quick_sales.py @@ -35,9 +35,15 @@ 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) +( + COL_DATE, COL_KIND, COL_WHO, COL_PRODUCT, COL_QTY, + COL_UOM, COL_PRICE, COL_PAID, COL_TIP, COL_TOTAL, +) = range(10) -HEADERS = ["Дата", "Тип", "Кому", "Товар", "Кол-во", "Фасовка", "Цена", "Оплачено", "Сумма"] +HEADERS = [ + "Дата", "Тип", "Кому", "Товар", "Кол-во", + "Фасовка", "Цена", "Оплачено", "Чаевые", "Сумма", +] # Пользователь поправил оплату руками — больше не подставляем её автоматически. TOUCHED = "paid_touched" @@ -76,8 +82,9 @@ class QuickSalesDialog(QDialog): # Ширины заданы руками: 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), + (COL_DATE, 110), (COL_KIND, 195), (COL_WHO, 130), (COL_QTY, 80), + (COL_UOM, 95), (COL_PRICE, 110), (COL_PAID, 110), (COL_TIP, 105), + (COL_TOTAL, 100), ): header.setSectionResizeMode(column, QHeaderView.Interactive) self.table.setColumnWidth(column, width) @@ -237,6 +244,11 @@ class QuickSalesDialog(QDialog): paid.valueChanged.connect(lambda _, rr=r: self._on_paid_edited(rr)) self.table.setCellWidget(r, COL_PAID, paid) + tip = w.MoneySpin(self.ctx.currency) + tip.set_decimal(state.get("tip") or 0) + tip.valueChanged.connect(self._recalc) + self.table.setCellWidget(r, COL_TIP, tip) + self.table.setItem(r, COL_TOTAL, w.text_item("")) self._apply_price(r) @@ -258,6 +270,7 @@ class QuickSalesDialog(QDialog): "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(), + "tip": self.table.cellWidget(r, COL_TIP).value_decimal(), } ) @@ -309,10 +322,13 @@ class QuickSalesDialog(QDialog): self._apply_price(r) paid = self.table.cellWidget(r, COL_PAID) + tip = self.table.cellWidget(r, COL_TIP) paid.setEnabled(not consumption) + tip.setEnabled(not consumption) self.table.cellWidget(r, COL_PRICE).setEnabled(not consumption) if consumption: paid.setProperty(TOUCHED, False) + tip.set_decimal(0) self._on_amount_changed(r) def _on_amount_changed(self, r: int) -> None: @@ -359,10 +375,25 @@ class QuickSalesDialog(QDialog): 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}" + tips = m.money( + sum( + ( + self.table.cellWidget(r, COL_TIP).value_decimal() + for r in range(self.table.rowCount()) + if self.table.cellWidget(r, COL_TIP) is not None + ), + m.ZERO, + ) ) + parts = [ + f"Строк: {self.table.rowCount()}", + f"на {m.fmt_money(total, self.ctx.currency)}", + ] + if tips > 0: + parts.append(f"чаевых {m.fmt_money(tips, self.ctx.currency)}") + if outside: + parts.append(f"вне периода: {outside}") + self.total_label.setText(" • ".join(parts)) # --- запись --- @@ -379,6 +410,7 @@ class QuickSalesDialog(QDialog): 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() + tip = m.ZERO if consumption else self.table.cellWidget(r, COL_TIP).value_decimal() result.append( { @@ -392,6 +424,7 @@ class QuickSalesDialog(QDialog): "unit_price": price, }, "paid": paid, + "tip": tip, } ) return result @@ -437,6 +470,7 @@ def write_sales(vault, rows: list[dict]) -> int: [row["line"]], counterparty_id=counterparty_id, paid_amount=row["paid"], + tip=row.get("tip"), ) created += 1 diff --git a/tests/test_ledger.py b/tests/test_ledger.py index 1f84122..9608488 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -420,6 +420,162 @@ def test_odd_pack_size_does_not_lose_kopecks(vault, today, in_two_weeks): assert report.summary.stock_qty == 0 +# --- Чаевые --------------------------------------------------------------- + + +def test_tip_is_not_a_debt(vault, buns, today, in_two_weeks): + """Чаевые не входят в стоимость товара — за них никто ничего не должен.""" + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) + sale = journal.create_sale( + vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)], paid_amount=70, tip=30 + ) + + assert sale.total == Decimal("70.00") + assert sale.paid == Decimal("70.00") + assert sale.tip == Decimal("30.00") + assert sale.received == Decimal("100.00") + assert sale.debt == 0 + assert ledger.build(vault.doc, today).summary.receivable == 0 + + +def test_overpayment_becomes_a_tip(vault, buns, today, in_two_weeks): + """Булка 70, дали 100, сдачи не надо — 30 это чаевые.""" + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) + sale = journal.create_sale( + vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)], paid_amount=100 + ) + + assert sale.paid == Decimal("70.00") + assert sale.tip == Decimal("30.00") + assert sale.debt == 0 + + +def test_tip_counts_toward_batch_coverage(vault, buns, today, in_two_weeks): + """Живые деньги на руках — ими так же рассчитываются с пекарней.""" + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) + journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)], tip=30) + + batch = ledger.build(vault.doc, today).batches[0] + assert batch.cost_total == Decimal("200.00") + assert batch.cash_collected == Decimal("100.00") # 70 за булки + 30 чаевых + assert batch.still_to_collect == Decimal("100.00") + assert batch.coverage_pct == Decimal("50.00") + + +def test_tip_goes_straight_into_profit(vault, buns, today, in_two_weeks): + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) + journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)], tip=30) + + summary = ledger.build(vault.doc, today).summary + assert summary.gross_margin == Decimal("30.00") # 2 × (35 − 20) + assert summary.tips == Decimal("30.00") + assert summary.profit == Decimal("60.00") + + +def test_tip_does_not_inflate_revenue(vault, buns, today, in_two_weeks): + """Выручка — это деньги за товар. Чаевые к товару отношения не имеют.""" + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) + journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)], tip=30) + + summary = ledger.build(vault.doc, today).summary + assert summary.revenue == Decimal("70.00") + assert summary.cash_collected == Decimal("100.00") + + +def test_tip_survives_a_partially_paid_sale(vault, buns, today, in_two_weeks): + """Чаевые дали в руки, а за булки остались должны — обе цифры отдельно.""" + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) + sale = journal.create_sale( + vault, today, KIND_RETAIL, [line(buns["мак"], 4, 35)], paid_amount=40, tip=25 + ) + + assert sale.debt == Decimal("100.00") + batch = ledger.build(vault.doc, today).batches[0] + assert batch.cash_collected == Decimal("65.00") # 40 оплаты + 25 чаевых + + +def test_tip_splits_between_batches_by_revenue(vault, buns, today): + """Продажа списалась с двух партий — чаевые делятся между ними.""" + old = journal.create_batch( + vault, today - timedelta(days=5), today + timedelta(days=9), [buy(buns["мак"], 3, 20)] + ) + new = journal.create_batch( + vault, today, today + timedelta(days=14), [buy(buns["мак"], 7, 20)] + ) + journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 4, 35)], tip=40) + + report = ledger.build(vault.doc, today) + # Три штуки из старой, одна из новой — чаевые в тех же долях. + assert report.batch_report(old.id).cash_collected == Decimal("135.00") # 105 + 30 + assert report.batch_report(new.id).cash_collected == Decimal("45.00") # 35 + 10 + assert report.summary.cash_collected == Decimal("180.00") + + +def test_paying_more_than_the_debt_becomes_a_tip(vault, buns, today, in_two_weeks): + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) + sale = journal.create_sale( + vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)], paid_amount=0 + ) + journal.add_sale_payment(vault, sale.id, 100) + + assert sale.paid == Decimal("70.00") + assert sale.tip == Decimal("30.00") + assert sale.debt == 0 + + +def test_consumption_never_gets_a_tip(vault, buns, today, in_two_weeks): + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) + sale = journal.create_sale( + vault, today, KIND_SELF, [line(buns["мак"], 2, 0)], paid_amount=50, tip=50 + ) + assert sale.tip == 0 + assert ledger.build(vault.doc, today).summary.tips == 0 + + with pytest.raises(journal.ValidationError, match="не бывает"): + journal.set_sale_tip(vault, sale.id, 100) + + +def test_tip_can_be_added_afterwards(vault, buns, today, in_two_weeks): + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) + sale = journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)]) + + journal.set_sale_tip(vault, sale.id, 15) + assert sale.tip == Decimal("15.00") + assert ledger.build(vault.doc, today).summary.tips == Decimal("15.00") + + +def test_tip_without_any_stock_still_counts_as_income(vault, buns, today): + """Продали то, чего не закупали: чаевые получены и не должны потеряться. + + Сама выручка при этом ни к какой партии не относится — на это ругается + предупреждение о нехватке. Чаевые же считаются по продажам напрямую, + поэтому в прибыль они попадают в любом случае. + """ + journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 1, 35)], tip=20) + + report = ledger.build(vault.doc, today) + assert report.summary.tips == Decimal("20.00") + assert report.summary.profit == Decimal("20.00") + assert any("больше" in w for w in report.warnings) + + +def test_negative_tip_rejected(vault, buns, today, in_two_weeks): + journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) + with pytest.raises(journal.ValidationError, match="отрицательн"): + journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 1, 35)], tip=-5) + + +def test_no_tips_changes_nothing(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)]) + + summary = ledger.build(vault.doc, today).summary + assert summary.tips == 0 + assert summary.cash_collected == Decimal("140.00") + assert summary.profit == Decimal("60.00") + + # --- Дробные количества: розлив и вес ------------------------------------ diff --git a/tests/test_quick_sales.py b/tests/test_quick_sales.py index 176db69..8048059 100644 --- a/tests/test_quick_sales.py +++ b/tests/test_quick_sales.py @@ -266,6 +266,41 @@ def test_fractional_quantities_in_bulk_entry(ctx, qapp): d.deleteLater() +def test_tips_in_bulk_entry(dialog, ctx, buns): + from app.ui.quick_sales import COL_TIP + + fill(dialog, 0, buns["мак"].id, 2) # 70 + dialog.table.cellWidget(0, COL_TIP).set_decimal(30) + assert "чаевых 30,00 ₽" in dialog.total_label.text() + + write_sales(ctx.vault, dialog.collect()) + sale = ctx.vault.doc.sales[0] + assert sale.total == Decimal("70.00") + assert sale.tip == Decimal("30.00") + assert sale.debt == 0 + + +def test_tips_are_not_inherited_by_the_next_row(dialog, buns): + """Чаевые — разовое событие, тянуть их в следующую строку нельзя.""" + from app.ui.quick_sales import COL_TIP + + fill(dialog, 0, buns["мак"].id, 1) + dialog.table.cellWidget(0, COL_TIP).set_decimal(50) + dialog.add_row() + assert dialog.table.cellWidget(1, COL_TIP).value_decimal() == 0 + + +def test_consumption_row_clears_the_tip(dialog, buns): + from app.ui.quick_sales import COL_TIP + + fill(dialog, 0, buns["мак"].id, 1) + dialog.table.cellWidget(0, COL_TIP).set_decimal(50) + fill(dialog, 0, buns["мак"].id, 1, kind=KIND_SELF) + + assert dialog.table.cellWidget(0, COL_TIP).isEnabled() is False + assert dialog.collect()[0]["tip"] == 0 + + 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()) diff --git a/tests/test_ui_smoke.py b/tests/test_ui_smoke.py index bb69aad..9e5f41c 100644 --- a/tests/test_ui_smoke.py +++ b/tests/test_ui_smoke.py @@ -449,6 +449,88 @@ def test_lines_editor_add_and_remove(window, buns): dialog.deleteLater() +# --- чаевые --------------------------------------------------------------- + + +def test_overpayment_moves_into_tips(window, buns): + """Булка 70, дали 100 — разница должна уехать в чаевые сама.""" + dialog = SaleDialog(window) + editor = dialog.lines + editor.table.cellWidget(0, editor.COL_PRODUCT).select_product(buns["мак"].id) + editor.table.cellWidget(0, editor.COL_QTY).set_decimal(2) + + dialog.paid.set_decimal(100) + dialog._absorb_overpayment() + + assert dialog.paid.value_decimal() == Decimal("70.00") + assert dialog.tip.value_decimal() == Decimal("30.00") + assert "чаевые" in dialog.paid_hint.text().lower() + dialog.deleteLater() + + +def test_underpayment_is_left_as_debt(window, buns): + """Недоплата — это долг, а не отрицательные чаевые.""" + dialog = SaleDialog(window) + editor = dialog.lines + editor.table.cellWidget(0, editor.COL_PRODUCT).select_product(buns["мак"].id) + editor.table.cellWidget(0, editor.COL_QTY).set_decimal(2) + + dialog.paid.set_decimal(40) + dialog._absorb_overpayment() + + assert dialog.paid.value_decimal() == Decimal("40.00") + assert dialog.tip.value_decimal() == 0 + assert "долг" in dialog.paid_hint.text().lower() + dialog.deleteLater() + + +def test_consumption_disables_tips(window, buns): + dialog = SaleDialog(window) + dialog.kind.setCurrentIndex(2) # съел сам + assert dialog.tip.isEnabled() is False + assert dialog.tip.value_decimal() == 0 + dialog.deleteLater() + + +def test_existing_sale_can_have_its_tip_edited(window, buns): + """Сдачу могли оставить и после того, как продажу занесли.""" + sale = window.vault.doc.sales[0] + journal.set_sale_tip(window.vault, sale.id, 15) + window.changed() + + dialog = SaleDialog(window, sale) + assert dialog.tip.value_decimal() == Decimal("15.00") + assert dialog.paid is None # платежи правятся на экране долгов + dialog.deleteLater() + + +def test_sales_table_shows_tips(window, buns): + sale = window.vault.doc.sales[0] + journal.set_sale_tip(window.vault, sale.id, 25) + window.changed() + + page = window.pages[2][1] + page.refresh() + tips = [page.table.item(r, 6).text() for r in range(page.table.rowCount())] + assert any("25,00" in t for t in tips) + # У продаж без чаевых колонка пустая, а не «0,00 ₽». + assert "" in tips + + +def test_dashboard_mentions_tips_only_when_there_are_any(window, buns): + from PySide6.QtWidgets import QLabel + + page = window.pages[0][1] + page.refresh() + texts = " ".join(label.text() for label in page.body.findChildren(QLabel)) + assert "чаевые" not in texts.lower() + + journal.set_sale_tip(window.vault, window.vault.doc.sales[0].id, 40) + window.changed() + texts = " ".join(label.text() for label in page.body.findChildren(QLabel)) + assert "чаевые 40,00 ₽" in texts.lower() + + # --- дробные количества ---------------------------------------------------