Чаевые

Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела
бы закрытой, когда за булки ещё должны.

Ввёл оплату больше суммы — булка 70, дали 100 — разница уходит в чаевые
сама. Разбор делается по окончании ввода, а не на каждом нажатии, иначе
поле дёргалось бы посреди набора. То же при приёме оплаты по долгу:
заплатили больше, чем оставалось, — излишек становится чаевыми, а не
теряется, как было раньше.

В расчётах чаевые идут в покрытие партии (это живые деньги, ими так же
рассчитываются с пекарней) и целиком добавляются в прибыль, но не входят
в выручку и не создают долга. Продажа, списанная с нескольких партий,
делит чаевые между ними пропорционально выручке.

По съеденному и подаренному чаевых не бывает — поле отключается.

Миграция схемы 2→3 проставляет ноль: у кого чаевых нет, тот появления
этой фичи не заметит.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-08-23 00:56:26 +03:00
parent 4dd9a071c0
commit 582f041dba
11 changed files with 574 additions and 44 deletions

View File

@ -32,6 +32,26 @@
- **Журнал** — что менялось в базе, когда и с какой машины. - **Журнал** — что менялось в базе, когда и с какой машины.
- **Сводка** — долг пекарне, дебиторка, прибыль, остатки, ближайший дедлайн. - **Сводка** — долг пекарне, дебиторка, прибыль, остатки, ближайший дедлайн.
### Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела бы
закрытой, когда за булки ещё должны.
Если вводишь оплату больше суммы — булка 70, дали 100 — программа сама
предложит записать разницу в чаевые. То же самое при приёме оплаты по долгу:
заплатили больше, чем оставалось, — излишек становится чаевыми, а не теряется.
В расчётах чаевые:
- **идут в покрытие партии** — это живые деньги, ими так же рассчитываются
с пекарней. Если продажа списалась с нескольких партий, чаевые делятся
между ними пропорционально выручке;
- **добавляются в прибыль целиком** — себестоимости за ними нет;
- **не входят в выручку и не создают долга** — за них никто ничего не должен.
По съеденному, подаренному и списанному чаевых не бывает — поле отключается.
### Дробные количества ### Дробные количества
Количества хранятся с точностью до тысячных, поэтому 0,5 л сока, 1,125 кг сыра Количества хранятся с точностью до тысячных, поэтому 0,5 л сока, 1,125 кг сыра
@ -71,8 +91,9 @@
поэтому на себестоимость это не влияет — FIFO нужен только чтобы понимать, поэтому на себестоимость это не влияет — FIFO нужен только чтобы понимать,
деньги за какую партию уже пришли. деньги за какую партию уже пришли.
Прибыль считается как наценка с проданного **минус** себестоимость съеденного Прибыль считается как наценка с проданного **плюс** чаевые **минус**
и подаренного: за них пекарне платить всё равно, и покрывается это из маржи. себестоимость съеденного и подаренного: за съеденное пекарне платить всё
равно, и покрывается это из маржи.
## Как запустить ## Как запустить

View File

@ -19,6 +19,7 @@ from __future__ import annotations
import socket import socket
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal
from . import money as m from . import money as m
from .models import ( from .models import (
@ -162,6 +163,7 @@ def _flat_sale(doc, s: Sale) -> dict[str, str]:
"состав": _lines_text(doc, s.lines, "unit_price"), "состав": _lines_text(doc, s.lines, "unit_price"),
"сумма": m.fmt_money(s.total, cur), "сумма": m.fmt_money(s.total, cur),
"оплачено": m.fmt_money(s.paid, cur), "оплачено": m.fmt_money(s.paid, cur),
"чаевые": m.fmt_money(s.tip, cur) if s.tip else "",
"заметка": s.note, "заметка": s.note,
} }
@ -609,6 +611,18 @@ def _build_sale_lines(doc, lines: list[dict]) -> list[SaleLine]:
return result 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( def create_sale(
vault, vault,
on_date: date, on_date: date,
@ -617,11 +631,12 @@ def create_sale(
counterparty_id: str | None = None, counterparty_id: str | None = None,
note: str = "", note: str = "",
paid_amount=None, paid_amount=None,
tip=None,
) -> Sale: ) -> Sale:
"""Записать выбытие товара. """Записать выбытие товара.
paid_amount=None означает «оплачено полностью» самый частый случай. paid_amount=None означает «оплачено полностью» самый частый случай.
Явный ноль или частичная сумма создают долг. Явный ноль или частичная сумма создают долг, а переплата чаевые.
""" """
doc = vault.doc doc = vault.doc
sale = Sale( sale = Sale(
@ -635,8 +650,12 @@ def create_sale(
if not sale.is_consumption: if not sale.is_consumption:
amount = sale.total if paid_amount is None else m.money(paid_amount) amount = sale.total if paid_amount is None else m.money(paid_amount)
if amount < 0: tip_amount = m.money(tip or 0)
raise ValidationError("Оплата не может быть отрицательной.") 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: if amount > 0:
sale.payments.append(Payment(date=sale.date, amount=amount)) sale.payments.append(Payment(date=sale.date, amount=amount))
@ -660,6 +679,7 @@ def update_sale(
lines: list[dict] | None = None, lines: list[dict] | None = None,
counterparty_id: str | None = None, counterparty_id: str | None = None,
note: str | None = None, note: str | None = None,
tip=None,
) -> Sale: ) -> Sale:
"""Правка продажи. Платежи не трогает — для них есть отдельные операции.""" """Правка продажи. Платежи не трогает — для них есть отдельные операции."""
doc = vault.doc doc = vault.doc
@ -678,6 +698,14 @@ def update_sale(
sale.counterparty_id = counterparty_id or None sale.counterparty_id = counterparty_id or None
if note is not None: if note is not None:
sale.note = note.strip() 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)) changes = _diff(before, _flat_sale(doc, sale))
if changes: 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: def add_sale_payment(vault, sale_id: str, amount, on_date: date | None = None, note: str = "") -> Sale:
"""Принять оплату по продаже.
Заплатили больше, чем оставалось должны, излишек становится чаевыми,
а не исчезает. Раньше он просто терялся: долг упирался в ноль, и деньги
нигде не отражались.
"""
doc = vault.doc doc = vault.doc
sale = doc.sale(sale_id) sale = doc.sale(sale_id)
if sale is None: 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("Сумма оплаты должна быть больше нуля.") raise ValidationError("Сумма оплаты должна быть больше нуля.")
before = _flat_sale(doc, sale) before = _flat_sale(doc, sale)
sale.payments.append( payment, sale.tip = split_overpayment(sale.debt, amount, sale.tip)
Payment(date=parse_date(on_date) if on_date else date.today(), amount=amount, note=note.strip()) if payment > 0:
) sale.payments.append(
Payment(
date=parse_date(on_date) if on_date else date.today(),
amount=payment,
note=note.strip(),
)
)
_record( _record(
vault, vault,
"sale.payment_add", "sale.payment_add",
@ -722,6 +762,34 @@ def add_sale_payment(vault, sale_id: str, amount, on_date: date | None = None, n
return sale 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: def delete_sale_payment(vault, sale_id: str, index: int) -> Sale:
doc = vault.doc doc = vault.doc
sale = doc.sale(sale_id) sale = doc.sale(sale_id)

View File

@ -52,6 +52,10 @@ class Allocation:
unit_price: Decimal unit_price: Decimal
kind: str kind: str
paid_fraction: Decimal paid_fraction: Decimal
# Доля чаевых с продажи, пришедшаяся на эту партию. Продажа может списаться
# с нескольких партий, поэтому чаевые делятся между ними пропорционально
# выручке.
tip_share: Decimal = m.ZERO
@property @property
def cost(self) -> Decimal: def cost(self) -> Decimal:
@ -59,13 +63,17 @@ class Allocation:
@property @property
def revenue(self) -> Decimal: def revenue(self) -> Decimal:
"""Начислено — включая то, что ещё не оплачено.""" """Начислено за товар — включая то, что ещё не оплачено."""
return self.qty * self.unit_price return self.qty * self.unit_price
@property @property
def cash(self) -> Decimal: def cash(self) -> Decimal:
"""Реально полученные деньги с учётом частичной оплаты.""" """Реально полученные деньги: оплата за товар плюс чаевые.
return self.revenue * self.paid_fraction
Чаевые входят сюда, потому что это живые деньги на руках ими так же
рассчитываются с пекарней, как и выручкой.
"""
return self.revenue * self.paid_fraction + self.tip_share
@dataclass @dataclass
@ -138,6 +146,7 @@ class Summary:
revenue: Decimal revenue: Decimal
cash_collected: Decimal cash_collected: Decimal
gross_margin: Decimal gross_margin: Decimal
tips: Decimal
consumed_cost: 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 @dataclass
@ -247,6 +257,10 @@ def allocate(
for _, sale in _ordered_sales(doc): for _, sale in _ordered_sales(doc):
fraction = _paid_fraction(sale) fraction = _paid_fraction(sale)
# Аллокации одной продажи собираются отдельно: доли чаевых можно
# посчитать, только когда известны все куски продажи целиком.
of_this_sale: list[Allocation] = []
for line_index, line in enumerate(sale.lines): for line_index, line in enumerate(sale.lines):
need = line.base_qty need = line.base_qty
for batch_id in order: for batch_id in order:
@ -259,7 +273,7 @@ def allocate(
take = min(available, need) take = min(available, need)
remaining[batch_id][line.product_id] = available - take remaining[batch_id][line.product_id] = available - take
need -= take need -= take
allocations.append( of_this_sale.append(
Allocation( Allocation(
sale_id=sale.id, sale_id=sale.id,
line_index=line_index, line_index=line_index,
@ -278,9 +292,36 @@ def allocate(
Shortfall(sale_id=sale.id, product_id=line.product_id, qty=need) 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 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), revenue=m.money(revenue),
cash_collected=m.money(cash), cash_collected=m.money(cash),
gross_margin=m.money(margin), gross_margin=m.money(margin),
# Считаем по продажам, а не по аллокациям: если товара не хватило и
# часть продажи никуда не легла, чаевые всё равно получены полностью.
tips=m.money(sum((s.tip for s in doc.sales), m.ZERO)),
consumed_cost=m.money(consumed), consumed_cost=m.money(consumed),
stock_qty=m.qty(sum((r.qty_left for r in batch_reports), m.ZERO)), stock_qty=m.qty(sum((r.qty_left for r in batch_reports), m.ZERO)),
stock_cost=m.money(sum((r.stock_cost for r in batch_reports), m.ZERO)), stock_cost=m.money(sum((r.stock_cost for r in batch_reports), m.ZERO)),

View File

@ -15,7 +15,7 @@ from typing import Any
from . import money as m from . import money as m
SCHEMA_VERSION = 2 SCHEMA_VERSION = 3
ONE = Decimal(1) ONE = Decimal(1)
@ -410,16 +410,26 @@ class Sale:
counterparty_id: str | None = None counterparty_id: str | None = None
lines: list[SaleLine] = field(default_factory=list) lines: list[SaleLine] = field(default_factory=list)
payments: list[Payment] = field(default_factory=list) payments: list[Payment] = field(default_factory=list)
# Деньги сверх стоимости товара. Живут отдельно от payments намеренно:
# иначе чаевые раздували бы «оплачено» и продажа выглядела бы закрытой,
# когда за булки ещё должны.
tip: Decimal = m.ZERO
note: str = "" note: str = ""
@property @property
def total(self) -> Decimal: def total(self) -> Decimal:
"""Стоимость товара. Чаевые сюда не входят — за них ничего не должны."""
return m.money(sum((line.total for line in self.lines), m.ZERO)) return m.money(sum((line.total for line in self.lines), m.ZERO))
@property @property
def paid(self) -> Decimal: def paid(self) -> Decimal:
return m.money(sum((p.amount for p in self.payments), m.ZERO)) 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 @property
def debt(self) -> Decimal: def debt(self) -> Decimal:
"""Сколько ещё должны по этой продаже (не бывает отрицательным).""" """Сколько ещё должны по этой продаже (не бывает отрицательным)."""
@ -438,6 +448,7 @@ class Sale:
counterparty_id=d.get("counterparty_id") or None, counterparty_id=d.get("counterparty_id") or None,
lines=[SaleLine.from_dict(x) for x in d.get("lines", [])], lines=[SaleLine.from_dict(x) for x in d.get("lines", [])],
payments=[Payment.from_dict(x) for x in d.get("payments", [])], payments=[Payment.from_dict(x) for x in d.get("payments", [])],
tip=m.money(d.get("tip")),
note=d.get("note", ""), note=d.get("note", ""),
) )
@ -449,6 +460,7 @@ class Sale:
"counterparty_id": self.counterparty_id, "counterparty_id": self.counterparty_id,
"lines": [x.to_dict() for x in self.lines], "lines": [x.to_dict() for x in self.lines],
"payments": [x.to_dict() for x in self.payments], "payments": [x.to_dict() for x in self.payments],
"tip": m.dumps(self.tip),
"note": self.note, "note": self.note,
} }

View File

@ -68,7 +68,20 @@ def _migrate_1_to_2(raw: dict) -> dict:
return raw 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: def migrate(raw: dict) -> dict:

View File

@ -16,6 +16,15 @@ from . import theme
from . import widgets as w 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): class DashboardPage(QWidget):
def __init__(self, ctx, parent=None): def __init__(self, ctx, parent=None):
super().__init__(parent) super().__init__(parent)
@ -94,8 +103,7 @@ class DashboardPage(QWidget):
( (
"Прибыль", "Прибыль",
m.fmt_money(summary.profit, currency), m.fmt_money(summary.profit, currency),
f"наценка {m.fmt_money(summary.gross_margin, currency)} " _profit_hint(summary, currency),
f" съедено {m.fmt_money(summary.consumed_cost, currency)}",
theme.OK if summary.profit > 0 else theme.DANGER if summary.profit < 0 else theme.TEXT, theme.OK if summary.profit > 0 else theme.DANGER if summary.profit < 0 else theme.TEXT,
), ),
( (

View File

@ -83,6 +83,7 @@ class SaleDialog(QDialog):
# в обработчик, для которого self.paid ещё не существует, — Qt съедает # в обработчик, для которого self.paid ещё не существует, — Qt съедает
# исключение, и подстановка оплаты молча не работает. # исключение, и подстановка оплаты молча не работает.
self.paid = None self.paid = None
self.tip = None
self.paid_hint = None self.paid_hint = None
layout.addWidget(w.label("Что ушло", "h2")) layout.addWidget(w.label("Что ушло", "h2"))
@ -99,13 +100,26 @@ class SaleDialog(QDialog):
# Оплату задаём только при создании: у существующей продажи платежи # Оплату задаём только при создании: у существующей продажи платежи
# живут своей жизнью и правятся на экране долгов. # живут своей жизнью и правятся на экране долгов.
pay_form = QFormLayout()
if sale is None: if sale is None:
pay_form = QFormLayout()
self.paid = w.MoneySpin(ctx.currency) self.paid = w.MoneySpin(ctx.currency)
# Переплату разбираем, когда человек закончил вводить сумму,
# а не на каждом нажатии: иначе поле дёргалось бы посреди набора.
self.paid.editingFinished.connect(self._absorb_overpayment)
pay_form.addRow("Оплачено сразу", self.paid) 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( layout.addWidget(
w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сохранить", "primary", self.accept)) 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) self.paid.setEnabled(not consumption)
if consumption: if consumption:
self.paid.set_decimal(0) 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() self._sync_payment_default()
def _sync_payment_default(self) -> None: 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() def _absorb_overpayment(self) -> None:
if kind in CONSUMPTION_KINDS: """Дали больше суммы — разница уходит в чаевые.
self.paid_hint.setText("Денег нет по определению, но себестоимость пекарне вернуть надо.")
Булка стоит 70, дали 100, сдачи не надо. Оставлять 100 в «оплачено»
нельзя: столько за товар никто не должен, и продажа выглядела бы
переплаченной вместо того, чтобы показать чаевые.
"""
if self.paid is None or self.tip is None:
return return
total = self.lines.total() total = self.lines.total()
# Полная оплата — самый частый случай, поэтому она и подставляется. extra = m.money(self.paid.value_decimal() - total)
# Уменьшил сумму — получил долг, который поедет на экран «Долги». if extra <= 0:
if not self.paid.hasFocus(): self._update_payment_hint()
self.paid.set_decimal(total) return
debt = m.money(total - self.paid.value_decimal()) self.paid.set_decimal(total)
if debt > 0: self.tip.set_decimal(self.tip.value_decimal() + extra)
self.paid_hint.setText(f"В долг уйдёт {m.fmt_money(debt, self.ctx.currency)}.") self.paid_hint.setText(
else: f"Переплата {m.fmt_money(extra, self.ctx.currency)} записана в чаевые."
self.paid_hint.setText("Оплачено полностью.") )
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: def _add_counterparty(self) -> None:
from PySide6.QtWidgets import QInputDialog from PySide6.QtWidgets import QInputDialog
@ -193,7 +242,8 @@ class SalesPage(QWidget):
) )
self.table = w.table( self.table = w.table(
["Дата", "Тип", "Кому", "Что", "Сумма", "Оплачено", "Долг"], stretch_column=3 ["Дата", "Тип", "Кому", "Что", "Сумма", "Оплачено", "Чаевые", "Долг"],
stretch_column=3,
) )
self.table.doubleClicked.connect(self.edit) self.table.doubleClicked.connect(self.edit)
layout.addWidget(self.table, 1) layout.addWidget(self.table, 1)
@ -227,6 +277,11 @@ class SalesPage(QWidget):
w.text_item(what), w.text_item(what),
w.sortable_num_item(m.fmt_money(sale.total, currency), sale.total), 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.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( w.sortable_num_item(
m.fmt_money(debt, currency) if debt > 0 else "", m.fmt_money(debt, currency) if debt > 0 else "",
debt, debt,
@ -264,6 +319,7 @@ class SalesPage(QWidget):
dialog.counterparty.currentData(), dialog.counterparty.currentData(),
dialog.note.text(), dialog.note.text(),
dialog.paid.value_decimal(), dialog.paid.value_decimal(),
dialog.tip.value_decimal(),
) )
except journal.ValidationError as exc: except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc)) QMessageBox.warning(self, "Не получилось", str(exc))
@ -302,6 +358,7 @@ class SalesPage(QWidget):
dialog.lines.lines("unit_price"), dialog.lines.lines("unit_price"),
dialog.counterparty.currentData() or "", dialog.counterparty.currentData() or "",
dialog.note.text(), dialog.note.text(),
tip=dialog.tip.value_decimal(),
) )
except journal.ValidationError as exc: except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc)) QMessageBox.warning(self, "Не получилось", str(exc))

View File

@ -35,9 +35,15 @@ from .page_sales import price_source_for
DEFAULT_PERIOD_DAYS = 30 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" TOUCHED = "paid_touched"
@ -76,8 +82,9 @@ class QuickSalesDialog(QDialog):
# Ширины заданы руками: ResizeToContents схлопывает пустой выпадающий # Ширины заданы руками: ResizeToContents схлопывает пустой выпадающий
# список до нечитаемого огрызка. # список до нечитаемого огрызка.
for column, width in ( for column, width in (
(COL_DATE, 110), (COL_KIND, 200), (COL_WHO, 135), (COL_QTY, 85), (COL_DATE, 110), (COL_KIND, 195), (COL_WHO, 130), (COL_QTY, 80),
(COL_UOM, 100), (COL_PRICE, 115), (COL_PAID, 115), (COL_TOTAL, 105), (COL_UOM, 95), (COL_PRICE, 110), (COL_PAID, 110), (COL_TIP, 105),
(COL_TOTAL, 100),
): ):
header.setSectionResizeMode(column, QHeaderView.Interactive) header.setSectionResizeMode(column, QHeaderView.Interactive)
self.table.setColumnWidth(column, width) self.table.setColumnWidth(column, width)
@ -237,6 +244,11 @@ class QuickSalesDialog(QDialog):
paid.valueChanged.connect(lambda _, rr=r: self._on_paid_edited(rr)) paid.valueChanged.connect(lambda _, rr=r: self._on_paid_edited(rr))
self.table.setCellWidget(r, COL_PAID, paid) 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.table.setItem(r, COL_TOTAL, w.text_item(""))
self._apply_price(r) self._apply_price(r)
@ -258,6 +270,7 @@ class QuickSalesDialog(QDialog):
"qty": self.table.cellWidget(r, COL_QTY).value_decimal(), "qty": self.table.cellWidget(r, COL_QTY).value_decimal(),
"uom": self.table.cellWidget(r, COL_UOM).currentText(), "uom": self.table.cellWidget(r, COL_UOM).currentText(),
"price": self.table.cellWidget(r, COL_PRICE).value_decimal(), "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) self._apply_price(r)
paid = self.table.cellWidget(r, COL_PAID) paid = self.table.cellWidget(r, COL_PAID)
tip = self.table.cellWidget(r, COL_TIP)
paid.setEnabled(not consumption) paid.setEnabled(not consumption)
tip.setEnabled(not consumption)
self.table.cellWidget(r, COL_PRICE).setEnabled(not consumption) self.table.cellWidget(r, COL_PRICE).setEnabled(not consumption)
if consumption: if consumption:
paid.setProperty(TOUCHED, False) paid.setProperty(TOUCHED, False)
tip.set_decimal(0)
self._on_amount_changed(r) self._on_amount_changed(r)
def _on_amount_changed(self, r: int) -> None: def _on_amount_changed(self, r: int) -> None:
@ -359,10 +375,25 @@ class QuickSalesDialog(QDialog):
f"color: {theme.WARN};" if off else "" f"color: {theme.WARN};" if off else ""
) )
suffix = f" • вне периода: {outside}" if outside else "" tips = m.money(
self.total_label.setText( sum(
f"Строк: {self.table.rowCount()} • на {m.fmt_money(total, self.ctx.currency)}{suffix}" (
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 consumption = kind in CONSUMPTION_KINDS
price = m.ZERO if consumption else self.table.cellWidget(r, COL_PRICE).value_decimal() 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() 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( result.append(
{ {
@ -392,6 +424,7 @@ class QuickSalesDialog(QDialog):
"unit_price": price, "unit_price": price,
}, },
"paid": paid, "paid": paid,
"tip": tip,
} }
) )
return result return result
@ -437,6 +470,7 @@ def write_sales(vault, rows: list[dict]) -> int:
[row["line"]], [row["line"]],
counterparty_id=counterparty_id, counterparty_id=counterparty_id,
paid_amount=row["paid"], paid_amount=row["paid"],
tip=row.get("tip"),
) )
created += 1 created += 1

View File

@ -420,6 +420,162 @@ def test_odd_pack_size_does_not_lose_kopecks(vault, today, in_two_weeks):
assert report.summary.stock_qty == 0 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")
# --- Дробные количества: розлив и вес ------------------------------------ # --- Дробные количества: розлив и вес ------------------------------------

View File

@ -266,6 +266,41 @@ def test_fractional_quantities_in_bulk_entry(ctx, qapp):
d.deleteLater() 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): def test_partial_payment_becomes_a_debt(dialog, ctx, buns):
fill(dialog, 0, buns["мак"].id, 4, paid=100) # всего 140 fill(dialog, 0, buns["мак"].id, 4, paid=100) # всего 140
write_sales(ctx.vault, dialog.collect()) write_sales(ctx.vault, dialog.collect())

View File

@ -449,6 +449,88 @@ def test_lines_editor_add_and_remove(window, buns):
dialog.deleteLater() 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()
# --- дробные количества --------------------------------------------------- # --- дробные количества ---------------------------------------------------