food-market/app/ui/page_sales.py
food-market d08d2421f7 Долг как явный выбор, история цен у фасовок
Долг было непонятно как записать, и не зря. Оплата была просто денежным
полем с полной суммой: долг получался, если догадаться стереть число
и вписать ноль. Дальше — хуже. Подсказка при этом продолжала уверять, что
оплачено полностью, а любая правка строк подставляла полную сумму обратно
и молча стирала выставленный долг.

Теперь выбор явный: заплатил полностью / взял в долг целиком / заплатил
часть. Пересчёт работает только в первом режиме. Поле суммы осталось
живым — вписанное руками число само зажигает подходящий переключатель.

Долг без имени записать нельзя: на экране долгов такая запись попадает
в кучу «без контрагента», и с кого спрашивать — уже не узнать. В быстром
вводе это предупреждение, а не запрет: внося историю, можно и правда не
помнить, кто это был.

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

Цены фасовок переехали в «Цены и история». Они меняются одним решением
поставщика вместе с ценой товара, а история была только у товара —
у коробки цена уезжала молча. Заодно закрыт второй вход: в карточке
товара цены фасовок теперь только показываются, правится там состав.

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

663 lines
27 KiB
Python
Raw Permalink 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.QtCore import Qt
from PySide6.QtWidgets import (
QButtonGroup,
QComboBox,
QDialog,
QFormLayout,
QLineEdit,
QMessageBox,
QRadioButton,
QVBoxLayout,
QWidget,
)
from .. import journal
from .. import money as m
from .. import salesfilter as sf
from ..models import (
CONSUMPTION_KINDS,
KIND_FRIEND,
KIND_RETAIL,
SALE_KIND_LABELS,
SALE_KINDS,
)
from . import theme
from . import widgets as w
# --- как рассчитались за продажу -----------------------------------------
#
# Раньше это был просто денежный ввод, заполненный полной суммой. Долг из него
# получался, если догадаться стереть число и вписать ноль, — а потом любая
# правка строк молча возвращала полную оплату обратно. Явный выбор режима
# делает «взял в долг» видимым вариантом, а не побочным эффектом.
PAY_FULL = "full"
PAY_DEBT = "debt"
PAY_PART = "part"
PAY_LABELS = {
PAY_FULL: "Заплатил полностью",
PAY_DEBT: "Взял в долг целиком",
PAY_PART: "Заплатил часть",
}
def pay_mode_for(total, paid):
"""Каким режимом описывается эта пара сумм.
Нужно, чтобы переключатель отражал набранное вручную: вписал ноль —
загорается «взял в долг», вписал всю сумму — «заплатил полностью».
"""
if paid <= 0:
return PAY_DEBT
if paid >= total:
return PAY_FULL
return PAY_PART
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)
# Выбор человека снимает предупреждение о долге без имени.
self.counterparty.currentIndexChanged.connect(lambda _: self._update_payment_hint())
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
self.pay_mode = None
self.pay_buttons = {}
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:
pay_form.addRow("Как рассчитались", self._pay_mode_row())
self.paid = w.MoneySpin(ctx.currency)
# Переплату разбираем, когда человек закончил вводить сумму,
# а не на каждом нажатии: иначе поле дёргалось бы посреди набора.
self.paid.editingFinished.connect(self._on_paid_edited)
# Без этого поле можно было обнулить, а подсказка продолжала
# уверять, что оплачено полностью.
self.paid.valueChanged.connect(lambda _: self._update_payment_hint())
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 _pay_mode_row(self) -> QWidget:
self.pay_mode = PAY_FULL
self.pay_group = QButtonGroup(self)
buttons = []
for mode in (PAY_FULL, PAY_DEBT, PAY_PART):
button = QRadioButton(PAY_LABELS[mode])
button.setChecked(mode == PAY_FULL)
button.toggled.connect(
lambda checked, value=mode: checked and self._set_pay_mode(value)
)
self.pay_group.addButton(button)
self.pay_buttons[mode] = button
buttons.append(button)
return w.row(*buttons, None)
def _set_pay_mode(self, mode: str) -> None:
"""Переключили режим руками — подставить сумму под него."""
self.pay_mode = mode
if self.paid is None:
return
if mode == PAY_FULL:
self.paid.set_decimal(self.lines.total())
elif mode == PAY_DEBT:
self.paid.set_decimal(0)
else:
self.paid.setFocus()
self.paid.selectAll()
self._update_payment_hint()
def _show_pay_mode(self, mode: str) -> None:
"""Отразить режим в переключателе, не трогая суммы."""
self.pay_mode = mode
button = self.pay_buttons.get(mode)
if button is None or button.isChecked():
return
button.blockSignals(True)
button.setChecked(True)
button.blockSignals(False)
def _on_paid_edited(self) -> None:
"""Сумму вписали руками: разобрать переплату и показать, что вышло."""
overpaid = self._absorb_overpayment()
if self.paid is not None:
self._show_pay_mode(pay_mode_for(self.lines.total(), self.paid.value_decimal()))
self._update_payment_hint(overpaid)
def problem(self) -> str | None:
"""Что мешает записать продажу. None — всё в порядке.
Отдельным методом, а не прямо в accept: проверка — это правило, и его
должно быть видно тесту, а не только человеку в модальном окне.
"""
if self.current_debt() > 0 and not self.counterparty.currentData():
return (
f"В долг уходит {m.fmt_money(self.current_debt(), self.ctx.currency)}, "
"но не указано кому.\n\nВыбери человека в поле «Кому» или заведи "
"нового кнопкой рядом — иначе долг будет не за кем."
)
return None
def accept(self) -> None:
"""Не выпускать долг без имени.
Такая запись невосстановима: на экране долгов она попадает в кучу
«без контрагента», и с кого спрашивать деньги — уже не узнать.
"""
problem = self.problem()
if problem:
QMessageBox.warning(self, "Кому в долг?", problem)
self.counterparty.setFocus()
return
super().accept()
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._show_pay_mode(PAY_FULL)
self.paid.set_decimal(0)
for button in self.pay_buttons.values():
button.setEnabled(not consumption)
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.pay_mode == PAY_FULL
and self.current_kind() not in CONSUMPTION_KINDS
# Пока поле в фокусе, не перебиваем то, что человек набирает.
and not self.paid.hasFocus()
):
self.paid.set_decimal(self.lines.total())
self._update_payment_hint()
def _absorb_overpayment(self):
"""Дали больше суммы — разница уходит в чаевые.
Булка стоит 70, дали 100, сдачи не надо. Оставлять 100 в «получено»
нельзя: столько за товар никто не должен, и продажа выглядела бы
переплаченной вместо того, чтобы показать чаевые.
"""
if self.paid is None or self.tip is None:
return m.ZERO
total = self.lines.total()
extra = m.money(self.paid.value_decimal() - total)
if extra <= 0:
return m.ZERO
self.paid.set_decimal(total)
self.tip.set_decimal(self.tip.value_decimal() + extra)
return extra
def current_debt(self):
if self.paid is None:
return m.ZERO
return max(m.ZERO, m.money(self.lines.total() - self.paid.value_decimal()))
def _update_payment_hint(self, overpaid=None) -> None:
if self.paid_hint is None:
return
if self.current_kind() in CONSUMPTION_KINDS:
self._say("Денег нет по определению, но себестоимость пекарне вернуть надо.")
return
currency = self.ctx.currency
tip = self.tip.value_decimal() if self.tip is not None else m.ZERO
debt = self.current_debt()
nameless = debt > 0 and not self.counterparty.currentData()
parts = []
if overpaid:
parts.append(f"Переплата {m.fmt_money(overpaid, currency)} записана в чаевые.")
if self.paid is not None:
if debt > 0:
# Долг без имени взыскать не с кого: на экране долгов он
# окажется в куче «без контрагента» и там и останется.
parts.append(
f"В долг уйдёт {m.fmt_money(debt, currency)}"
"укажи, кому, иначе будет непонятно, с кого спрашивать."
if nameless
else f"В долг уйдёт {m.fmt_money(debt, currency)}. "
"Долг появится на экране «Долги»."
)
else:
parts.append("Оплачено полностью.")
if tip > 0:
parts.append(f"Чаевые {m.fmt_money(tip, currency)} идут в покрытие партии и в прибыль.")
self._say(" ".join(parts), theme.WARN if nameless else "")
def _say(self, text: str, color: str = "") -> None:
self.paid_hint.setText(text)
self.paid_hint.setStyleSheet(f"color: {color};" if color else "")
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):
COL_DATE, COL_KIND, COL_WHO, COL_WHAT, COL_TOTAL, COL_PAID, COL_TIP, COL_DEBT = range(8)
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("Продажи"))
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,
)
)
layout.addWidget(self._filter_bar())
self.table = w.table(
["Дата", "Тип", "Кому", "Что", "Сумма", "Оплачено", "Чаевые", "Долг"],
stretch_column=3,
)
self.table.doubleClicked.connect(self.edit)
# Свежие продажи сверху. Индикатор в заголовке при этом соответствует
# тому, что видно: без него сортировка «по умолчанию» ничем не помечена,
# и первый клик по «Дате» кажется ничего не делающим.
self.table.sortByColumn(self.COL_DATE, Qt.DescendingOrder)
layout.addWidget(self.table, 1)
self.summary = w.label("", "dim")
layout.addWidget(self.summary)
# --- фильтры ---
def _filter_bar(self) -> QWidget:
"""Панель отбора. Всё, что её меняет, ведёт в один refresh."""
self.search = QLineEdit()
self.search.setPlaceholderText("Поиск: товар, имя, заметка")
self.search.setClearButtonEnabled(True)
self.search.textChanged.connect(self.refresh)
self.period = QComboBox()
for key in sf.PERIODS:
self.period.addItem(sf.PERIOD_LABELS[key], key)
self.period.currentIndexChanged.connect(self._on_period_changed)
# Свои даты нужны редко, а места занимают много — показываем их только
# когда выбран собственный диапазон.
self.since = w.DateInput()
self.until = w.DateInput()
for field in (self.since, self.until):
field.dateChanged.connect(lambda _: self.refresh())
field.setVisible(False)
self.kind = QComboBox()
self.kind.addItem("Все типы", None)
for kind in SALE_KINDS:
self.kind.addItem(SALE_KIND_LABELS[kind], kind)
self.kind.currentIndexChanged.connect(self.refresh)
self.who = QComboBox()
self.who.currentIndexChanged.connect(self.refresh)
self._fill_counterparties()
self.money = QComboBox()
for state in sf.MONEY_STATES:
self.money.addItem(sf.MONEY_LABELS[state], state)
self.money.currentIndexChanged.connect(self.refresh)
self.reset_button = w.button("Сбросить", on_click=self.reset_filters)
return w.row(
self.search,
self.period,
self.since,
self.until,
self.kind,
self.who,
self.money,
self.reset_button,
stretch_at=0,
)
def _fill_counterparties(self) -> None:
"""Пересобрать список людей, сохранив выбранного.
Контрагенты заводятся прямо из формы продажи, поэтому список живой.
Восстанавливаем по идентификатору: под тем же номером после сортировки
по алфавиту оказывается уже другой человек.
"""
previously = self.who.currentData()
self.who.blockSignals(True)
self.who.clear()
self.who.addItem("Все люди", None)
self.who.addItem("Без контрагента", sf.NO_COUNTERPARTY)
for cp in sorted(self.ctx.vault.doc.counterparties, key=lambda c: c.name.lower()):
self.who.addItem(cp.name, cp.id)
index = self.who.findData(previously) if previously else 0
self.who.setCurrentIndex(max(0, index))
self.who.blockSignals(False)
def _on_period_changed(self) -> None:
custom = self.period.currentData() == sf.PERIOD_CUSTOM
self.since.setVisible(custom)
self.until.setVisible(custom)
self.refresh()
def current_filter(self) -> sf.SalesFilter:
return sf.SalesFilter(
text=self.search.text(),
kind=self.kind.currentData(),
counterparty_id=self.who.currentData(),
period=self.period.currentData() or sf.PERIOD_ALL,
since=self.since.get_date(),
until=self.until.get_date(),
money=self.money.currentData() or sf.MONEY_ANY,
)
def reset_filters(self) -> None:
for widget in (self.period, self.kind, self.who, self.money):
widget.blockSignals(True)
widget.setCurrentIndex(0)
widget.blockSignals(False)
self.search.blockSignals(True)
self.search.clear()
self.search.blockSignals(False)
self._on_period_changed()
# --- отрисовка ---
def refresh(self) -> None:
doc = self.ctx.vault.doc
currency = self.ctx.currency
self._fill_counterparties()
active = self.current_filter()
shown = sf.apply(
doc,
sorted(doc.sales, key=lambda s: s.date, reverse=True),
active,
self.ctx.report.today,
)
rows, keys = [], []
for sale in shown:
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.date_item(sale.date),
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)
self.summary.setText(
sf.summary_text(sf.totals(shown), len(doc.sales), currency)
)
self.reset_button.setEnabled(not active.is_empty)
# --- действия ---
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()