food-market/app/ui/page_sales.py
Claude 582f041dba Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела
бы закрытой, когда за булки ещё должны.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:56:26 +03:00

404 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Продажи и прочее выбытие: розница, друзья по себестоимости, съел сам."""
from __future__ import annotations
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QFormLayout,
QLineEdit,
QMessageBox,
QVBoxLayout,
QWidget,
)
from .. import journal
from .. import money as m
from ..models import (
CONSUMPTION_KINDS,
KIND_FRIEND,
KIND_RETAIL,
SALE_KIND_LABELS,
SALE_KINDS,
)
from . import theme
from . import widgets as w
def price_source_for(kind: str):
"""Откуда берётся цена по умолчанию для этого типа выбытия и фасовки.
Ради этого типы и заведены: друг платит себестоимость, съеденное не стоит
ничего, розница идёт по цене продажи. Цена всегда за одну штуку выбранной
фасовки — пачка стоит своих денег, а не десяти поштучных цен.
"""
if kind == KIND_FRIEND:
return lambda product, uom: product.cost_for(uom)
if kind in CONSUMPTION_KINDS:
return lambda product, uom: m.ZERO
return lambda product, uom: product.retail_for(uom)
class SaleDialog(QDialog):
def __init__(self, ctx, sale=None, parent=None):
super().__init__(parent)
self.ctx = ctx
self.sale = sale
self.setWindowTitle("Продажа" if sale else "Новая продажа")
self.setMinimumSize(640, 520)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 16, 20, 16)
layout.setSpacing(10)
form = QFormLayout()
form.setSpacing(8)
self.date = w.DateInput(sale.date if sale else None)
form.addRow("Дата", self.date)
self.kind = QComboBox()
for kind in SALE_KINDS:
self.kind.addItem(SALE_KIND_LABELS[kind], kind)
self.kind.setCurrentIndex(SALE_KINDS.index(sale.kind) if sale else 0)
self.kind.currentIndexChanged.connect(self._on_kind_changed)
form.addRow("Тип", self.kind)
self.counterparty = QComboBox()
self.counterparty.addItem("— не указан —", None)
for cp in sorted(ctx.vault.doc.counterparties, key=lambda c: c.name.lower()):
self.counterparty.addItem(cp.name, cp.id)
if sale and sale.counterparty_id:
index = self.counterparty.findData(sale.counterparty_id)
if index >= 0:
self.counterparty.setCurrentIndex(index)
form.addRow("Кому", w.row(self.counterparty, w.button("+ Новый", on_click=self._add_counterparty), stretch_at=0))
self.note = QLineEdit(sale.note if sale else "")
form.addRow("Заметка", self.note)
layout.addLayout(form)
# Поля оплаты создаются ниже, но редактор строк начинает слать сигналы
# сразу при заполнении. Без этих заглушек первый же сигнал приходит
# в обработчик, для которого self.paid ещё не существует, — Qt съедает
# исключение, и подстановка оплаты молча не работает.
self.paid = None
self.tip = None
self.paid_hint = None
layout.addWidget(w.label("Что ушло", "h2"))
products = [p for p in ctx.vault.doc.products if not p.archived]
self.lines = w.LinesEditor(
products, "Цена", price_source_for(self.current_kind()), ctx.currency
)
self.lines.changed.connect(self._sync_payment_default)
if sale:
self.lines.set_lines(sale.lines, "unit_price")
elif products:
self.lines.add_line()
layout.addWidget(self.lines, 1)
# Оплату задаём только при создании: у существующей продажи платежи
# живут своей жизнью и правятся на экране долгов.
pay_form = QFormLayout()
if sale is None:
self.paid = w.MoneySpin(ctx.currency)
# Переплату разбираем, когда человек закончил вводить сумму,
# а не на каждом нажатии: иначе поле дёргалось бы посреди набора.
self.paid.editingFinished.connect(self._absorb_overpayment)
pay_form.addRow("Оплачено сразу", self.paid)
# Чаевые правятся и у записанной продажи: сдачу могли оставить
# и после того, как продажу занесли.
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))
)
self._on_kind_changed()
def current_kind(self) -> str:
return self.kind.currentData()
def _on_kind_changed(self) -> None:
kind = self.current_kind()
self.lines.price_source = price_source_for(kind)
self.lines.refresh_prices()
consumption = kind in CONSUMPTION_KINDS
if self.paid is not None:
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 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()
def _absorb_overpayment(self) -> None:
"""Дали больше суммы — разница уходит в чаевые.
Булка стоит 70, дали 100, сдачи не надо. Оставлять 100 в «оплачено»
нельзя: столько за товар никто не должен, и продажа выглядела бы
переплаченной вместо того, чтобы показать чаевые.
"""
if self.paid is None or self.tip is None:
return
total = self.lines.total()
extra = m.money(self.paid.value_decimal() - total)
if extra <= 0:
self._update_payment_hint()
return
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
name, ok = QInputDialog.getText(self, "Новый контрагент", "Имя")
if not ok or not name.strip():
return
try:
cp = journal.create_counterparty(self.ctx.vault, name)
except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc))
return
self.counterparty.addItem(cp.name, cp.id)
self.counterparty.setCurrentIndex(self.counterparty.count() - 1)
class SalesPage(QWidget):
def __init__(self, ctx, parent=None):
super().__init__(parent)
self.ctx = ctx
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 18, 20, 18)
layout.setSpacing(12)
layout.addWidget(w.heading("Продажи"))
self.filter = QComboBox()
self.filter.addItem("Все типы", None)
for kind in SALE_KINDS:
self.filter.addItem(SALE_KIND_LABELS[kind], kind)
self.filter.currentIndexChanged.connect(self.refresh)
layout.addWidget(
w.row(
w.button("+ Продажа", "primary", self.create),
w.button("Быстрый ввод за период", on_click=self.quick_entry),
w.button("Изменить", on_click=self.edit),
w.button("Принять оплату", on_click=self.take_payment),
w.button("Удалить", "danger", self.delete),
None,
self.filter,
)
)
self.table = w.table(
["Дата", "Тип", "Кому", "Что", "Сумма", "Оплачено", "Чаевые", "Долг"],
stretch_column=3,
)
self.table.doubleClicked.connect(self.edit)
layout.addWidget(self.table, 1)
# --- отрисовка ---
def refresh(self) -> None:
doc = self.ctx.vault.doc
currency = self.ctx.currency
wanted = self.filter.currentData()
rows, keys = [], []
for sale in sorted(doc.sales, key=lambda s: s.date, reverse=True):
if wanted and sale.kind != wanted:
continue
what = ", ".join(
f"{doc.product_name(line.product_id)} × {m.fmt_qty(line.qty)} "
f"{line.unit_name(doc.product(line.product_id))}"
for line in sale.lines
)
debt = sale.debt
rows.append(
[
w.text_item(sale.date.strftime("%d.%m.%Y")),
w.text_item(
SALE_KIND_LABELS.get(sale.kind, sale.kind),
theme.MUTED if sale.is_consumption else "",
),
w.text_item(doc.counterparty_name(sale.counterparty_id)),
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,
theme.WARN if debt > 0 else "",
),
]
)
keys.append(sale.id)
w.fill(self.table, rows, keys)
# --- действия ---
def _selected(self):
sale_id = w.selected_key(self.table)
sale = self.ctx.vault.doc.sale(sale_id) if sale_id else None
if sale is None:
QMessageBox.information(self, "Не выбрано", "Выбери продажу в списке.")
return sale
def create(self) -> None:
if not [p for p in self.ctx.vault.doc.products if not p.archived]:
QMessageBox.information(self, "Нет товаров", "Сначала заведи булки на вкладке «Товары».")
return
dialog = SaleDialog(self.ctx, parent=self)
if dialog.exec() != QDialog.Accepted:
return
try:
journal.create_sale(
self.ctx.vault,
dialog.date.get_date(),
dialog.current_kind(),
dialog.lines.lines("unit_price"),
dialog.counterparty.currentData(),
dialog.note.text(),
dialog.paid.value_decimal(),
dialog.tip.value_decimal(),
)
except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc))
return
self.ctx.changed()
def quick_entry(self) -> None:
"""Внести пачку продаж за прошедший период."""
if not [p for p in self.ctx.vault.doc.products if not p.archived]:
QMessageBox.information(self, "Нет товаров", "Сначала заведи булки на вкладке «Товары».")
return
from .quick_sales import QuickSalesDialog
dialog = QuickSalesDialog(self.ctx, parent=self)
if dialog.exec() != QDialog.Accepted:
return
self.ctx.changed()
QMessageBox.information(self, "Готово", f"Записано продаж: {dialog.created}.")
def edit(self) -> None:
sale = self._selected()
if sale is None:
return
dialog = SaleDialog(self.ctx, sale, parent=self)
if dialog.exec() != QDialog.Accepted:
return
try:
journal.update_sale(
self.ctx.vault,
sale.id,
dialog.date.get_date(),
dialog.current_kind(),
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))
return
self.ctx.changed()
def take_payment(self) -> None:
sale = self._selected()
if sale is None:
return
if sale.debt <= 0:
QMessageBox.information(self, "Долга нет", "Эта продажа уже оплачена.")
return
from .page_debts import ask_payment
amount = ask_payment(self, sale.debt, self.ctx.currency)
if amount is None:
return
try:
journal.add_sale_payment(self.ctx.vault, sale.id, amount)
except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc))
return
self.ctx.changed()
def delete(self) -> None:
sale = self._selected()
if sale is None:
return
confirm = QMessageBox.question(
self, "Удалить продажу?", "Товар вернётся в остатки, деньги — исчезнут из учёта."
)
if confirm != QMessageBox.Yes:
return
try:
journal.delete_sale(self.ctx.vault, sale.id)
except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc))
return
self.ctx.changed()