food-market/app/ui/page_sales.py
food-market e2795d92f8 Фильтры и сортировка на экране продаж
Список продаж растёт быстрее всех остальных, а отбор был один — по типу.
Теперь панель: поиск, период, тип, человек, состояние оплаты. Всё
складывается, «за прошлый месяц Васе в долг» набирается тремя щелчками.

Правила отбора вынесены в app/salesfilter.py и живут без Qt: «прошлый
месяц» в январе уезжает в прошлый год, а поиск по двум словам ищет их по
отдельности — такое проверяется тестом, а не глазами по скриншоту.

Под таблицей — итоги ровно по показанному. Половина смысла фильтров
в этой строке: отобрал по человеку и сразу видишь, сколько он взял
и сколько за ним осталось.

Заодно починена сортировка по датам на продажах и закупках.
Отформатированная дата — обычная строка, и по алфавиту она сортируется
по дню месяца: 01.12.2025 оказывалась раньше 02.01.2020. Выглядело
рабочим, просто выдавало бессмыслицу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:58:52 +03:00

513 lines
20 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.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QFormLayout,
QLineEdit,
QMessageBox,
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
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):
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()