Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела бы закрытой, когда за булки ещё должны. Ввёл оплату больше суммы — булка 70, дали 100 — разница уходит в чаевые сама. Разбор делается по окончании ввода, а не на каждом нажатии, иначе поле дёргалось бы посреди набора. То же при приёме оплаты по долгу: заплатили больше, чем оставалось, — излишек становится чаевыми, а не теряется, как было раньше. В расчётах чаевые идут в покрытие партии (это живые деньги, ими так же рассчитываются с пекарней) и целиком добавляются в прибыль, но не входят в выручку и не создают долга. Продажа, списанная с нескольких партий, делит чаевые между ними пропорционально выручке. По съеденному и подаренному чаевых не бывает — поле отключается. Миграция схемы 2→3 проставляет ноль: у кого чаевых нет, тот появления этой фичи не заметит. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
4dd9a071c0
commit
582f041dba
25
README.md
25
README.md
@ -32,6 +32,26 @@
|
||||
- **Журнал** — что менялось в базе, когда и с какой машины.
|
||||
- **Сводка** — долг пекарне, дебиторка, прибыль, остатки, ближайший дедлайн.
|
||||
|
||||
### Чаевые
|
||||
|
||||
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
|
||||
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела бы
|
||||
закрытой, когда за булки ещё должны.
|
||||
|
||||
Если вводишь оплату больше суммы — булка 70, дали 100 — программа сама
|
||||
предложит записать разницу в чаевые. То же самое при приёме оплаты по долгу:
|
||||
заплатили больше, чем оставалось, — излишек становится чаевыми, а не теряется.
|
||||
|
||||
В расчётах чаевые:
|
||||
|
||||
- **идут в покрытие партии** — это живые деньги, ими так же рассчитываются
|
||||
с пекарней. Если продажа списалась с нескольких партий, чаевые делятся
|
||||
между ними пропорционально выручке;
|
||||
- **добавляются в прибыль целиком** — себестоимости за ними нет;
|
||||
- **не входят в выручку и не создают долга** — за них никто ничего не должен.
|
||||
|
||||
По съеденному, подаренному и списанному чаевых не бывает — поле отключается.
|
||||
|
||||
### Дробные количества
|
||||
|
||||
Количества хранятся с точностью до тысячных, поэтому 0,5 л сока, 1,125 кг сыра
|
||||
@ -71,8 +91,9 @@
|
||||
поэтому на себестоимость это не влияет — FIFO нужен только чтобы понимать,
|
||||
деньги за какую партию уже пришли.
|
||||
|
||||
Прибыль считается как наценка с проданного **минус** себестоимость съеденного
|
||||
и подаренного: за них пекарне платить всё равно, и покрывается это из маржи.
|
||||
Прибыль считается как наценка с проданного **плюс** чаевые **минус**
|
||||
себестоимость съеденного и подаренного: за съеденное пекарне платить всё
|
||||
равно, и покрывается это из маржи.
|
||||
|
||||
## Как запустить
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
),
|
||||
(
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
# --- Дробные количества: розлив и вес ------------------------------------
|
||||
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
# --- дробные количества ---------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user