Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела бы закрытой, когда за булки ещё должны. Ввёл оплату больше суммы — булка 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 кг сыра
|
Количества хранятся с точностью до тысячных, поэтому 0,5 л сока, 1,125 кг сыра
|
||||||
@ -71,8 +91,9 @@
|
|||||||
поэтому на себестоимость это не влияет — FIFO нужен только чтобы понимать,
|
поэтому на себестоимость это не влияет — FIFO нужен только чтобы понимать,
|
||||||
деньги за какую партию уже пришли.
|
деньги за какую партию уже пришли.
|
||||||
|
|
||||||
Прибыль считается как наценка с проданного **минус** себестоимость съеденного
|
Прибыль считается как наценка с проданного **плюс** чаевые **минус**
|
||||||
и подаренного: за них пекарне платить всё равно, и покрывается это из маржи.
|
себестоимость съеденного и подаренного: за съеденное пекарне платить всё
|
||||||
|
равно, и покрывается это из маржи.
|
||||||
|
|
||||||
## Как запустить
|
## Как запустить
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)),
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|
||||||
# --- Дробные количества: розлив и вес ------------------------------------
|
# --- Дробные количества: розлив и вес ------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|
||||||
# --- дробные количества ---------------------------------------------------
|
# --- дробные количества ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user