food-market/app/ui/widgets.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

599 lines
22 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 datetime import date
from decimal import Decimal
from PySide6.QtCore import QDate, Qt, Signal
from PySide6.QtGui import QColor, QFont, QValidator
from PySide6.QtWidgets import (
QComboBox,
QCompleter,
QDateEdit,
QDoubleSpinBox,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QPushButton,
QSizePolicy,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from .. import money as m
from . import theme
MAX_MONEY = 99_999_999.0
# --- поля ввода -----------------------------------------------------------
class DecimalSpin(QDoubleSpinBox):
"""Числовое поле, принимающее и точку, и запятую.
Интерфейс русский, поэтому разделитель целой и дробной части — запятая.
Но на цифровой клавиатуре клавиша даёт точку, и стандартный валидатор
Qt её молча отвергает: символ просто не появляется в поле, и человек
решает, что дробные значения вводить нельзя.
"""
def validate(self, text: str, pos: int):
# Сначала пробуем как есть. Трогать текст на каждом нажатии нельзя:
# у поля цены суффикс « ₽» начинается с пробела, и любая «очистка»
# ломает его — тогда принимается только первый символ, а набрать
# цену становится невозможно.
state, fixed, new_pos = super().validate(text, pos)
if state != QValidator.Invalid or "." not in text:
return state, fixed, new_pos
# Не приняли из-за точки — повторяем с запятой.
candidate = text.replace(".", ",")
return super().validate(candidate, min(pos, len(candidate)))
def fixup(self, text: str) -> str:
return super().fixup(text.replace(".", ","))
class MoneySpin(DecimalSpin):
def __init__(self, currency: str = "", parent=None):
super().__init__(parent)
self.setDecimals(2)
self.setRange(0, MAX_MONEY)
self.setGroupSeparatorShown(True)
self.setSuffix(f" {currency}" if currency else "")
self.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
def value_decimal(self) -> Decimal:
return m.money(self.value())
def set_decimal(self, value) -> None:
self.setValue(float(m.money(value)))
class QtySpin(DecimalSpin):
"""Количество.
Три знака после запятой: товар может быть и штучным, и на розлив —
пол-литра сока это 0,5.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.setDecimals(3)
self.setRange(0, 1_000_000)
self.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
def textFromValue(self, value: float) -> str:
return m.fmt_qty(value)
def valueFromText(self, text: str) -> float:
return float(m.qty(text))
def value_decimal(self) -> Decimal:
return m.qty(self.value())
def set_decimal(self, value) -> None:
self.setValue(float(m.qty(value)))
class DateInput(QDateEdit):
def __init__(self, value: date | None = None, parent=None):
super().__init__(parent)
self.setCalendarPopup(True)
self.setDisplayFormat("dd.MM.yyyy")
self.set_date(value or date.today())
def set_date(self, value: date) -> None:
self.setDate(QDate(value.year, value.month, value.day))
def get_date(self) -> date:
qd = self.date()
return date(qd.year(), qd.month(), qd.day())
class ProductCombo(QComboBox):
"""Выпадающий список товаров с поиском по набранному тексту."""
def __init__(self, products, parent=None):
super().__init__(parent)
self.setEditable(True)
self.setInsertPolicy(QComboBox.NoInsert)
# Поиск по подстроке, а не только по началу: «мак» должен находить
# «Булка с маком».
completer = self.completer()
if completer is not None:
completer.setFilterMode(Qt.MatchContains)
completer.setCompletionMode(QCompleter.PopupCompletion)
for product in products:
self.addItem(product.name, product.id)
self.currentIndexChanged.connect(lambda _: self._show_beginning())
self._show_beginning()
def _show_beginning(self) -> None:
"""Показывать начало названия, а не хвост.
В редактируемом списке курсор встаёт в конец строки, и в узкой колонке
«Печенье овсяное» превращается в «е овсяное».
"""
edit = self.lineEdit()
if edit is not None:
edit.setCursorPosition(0)
def current_product_id(self) -> str | None:
return self.currentData()
def select_product(self, product_id: str) -> None:
index = self.findData(product_id)
if index >= 0:
self.setCurrentIndex(index)
self._show_beginning()
# --- мелкие сборки --------------------------------------------------------
def label(text: str, role: str = "", color: str = "") -> QLabel:
widget = QLabel(text)
if role:
widget.setProperty("role", role)
if color:
widget.setStyleSheet(f"color: {color};")
return widget
def heading(text: str) -> QLabel:
return label(text, "h1")
def card() -> QFrame:
frame = QFrame()
frame.setProperty("role", "card")
return frame
def button(text: str, role: str = "", on_click=None) -> QPushButton:
widget = QPushButton(text)
if role:
widget.setProperty("role", role)
if on_click is not None:
widget.clicked.connect(on_click)
return widget
def row(*widgets, stretch_at: int | None = None) -> QWidget:
holder = QWidget()
layout = QHBoxLayout(holder)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
for index, widget in enumerate(widgets):
if widget is None:
layout.addStretch(1)
continue
layout.addWidget(widget)
if index == stretch_at:
layout.setStretch(index, 1)
return holder
def metric_card(title: str, value: str, hint: str = "", color: str = "") -> QFrame:
"""Крупная цифра для сводки."""
frame = card()
layout = QVBoxLayout(frame)
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(2)
layout.addWidget(label(title, "dim"))
layout.addWidget(label(value, "metric", color))
if hint:
layout.addWidget(label(hint, "dim"))
frame.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
return frame
def warning_banner(messages: list[str]) -> QFrame:
frame = QFrame()
frame.setProperty("role", "banner")
layout = QVBoxLayout(frame)
layout.setContentsMargins(12, 10, 12, 10)
layout.setSpacing(3)
for text in messages:
item = QLabel(f"{text}")
item.setWordWrap(True)
layout.addWidget(item)
return frame
# --- таблицы --------------------------------------------------------------
def table(headers: list[str], stretch_column: int = 0) -> QTableWidget:
widget = QTableWidget(0, len(headers))
widget.setHorizontalHeaderLabels(headers)
widget.verticalHeader().setVisible(False)
widget.setAlternatingRowColors(True)
widget.setSelectionBehavior(QTableWidget.SelectRows)
widget.setSelectionMode(QTableWidget.SingleSelection)
widget.setEditTriggers(QTableWidget.NoEditTriggers)
widget.setSortingEnabled(True)
widget.setWordWrap(False)
header = widget.horizontalHeader()
header.setSectionResizeMode(QHeaderView.ResizeToContents)
header.setSectionResizeMode(stretch_column, QHeaderView.Stretch)
header.setHighlightSections(False)
return widget
def text_item(text: str, color: str = "", bold: bool = False) -> QTableWidgetItem:
item = QTableWidgetItem(text)
if color:
item.setForeground(QColor(color))
if bold:
font = item.font()
font.setBold(True)
item.setFont(font)
return item
def num_item(text: str, value, color: str = "", bold: bool = False) -> QTableWidgetItem:
"""Ячейка с числом.
Отображаемый текст отформатирован, а сортировка идёт по настоящему
значению — иначе «1 200 ₽» сортировалось бы как строка и оказалось бы
меньше «900 ₽».
"""
item = text_item(text, color, bold)
item.setData(Qt.UserRole + 1, float(value))
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
return item
class SortableItem(QTableWidgetItem):
def __lt__(self, other):
mine = self.data(Qt.UserRole + 1)
theirs = other.data(Qt.UserRole + 1)
if mine is not None and theirs is not None:
return mine < theirs
return super().__lt__(other)
def sortable_num_item(text: str, value, color: str = "", bold: bool = False) -> SortableItem:
item = SortableItem(text)
if color:
item.setForeground(QColor(color))
if bold:
font = item.font()
font.setBold(True)
item.setFont(font)
item.setData(Qt.UserRole + 1, float(value))
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
return item
def date_item(value: date, color: str = "", bold: bool = False) -> SortableItem:
"""Ячейка с датой: показывается по-русски, сортируется по времени.
Отформатированная дата — обычная строка, и по алфавиту она сортируется по
дню месяца: 01.12.2025 оказывается раньше 02.01.2020. Сортировка по такой
колонке выглядит рабочей, просто выдаёт бессмыслицу — поэтому настоящее
значение кладём рядом с текстом.
"""
item = SortableItem(value.strftime("%d.%m.%Y"))
if color:
item.setForeground(QColor(color))
if bold:
font = item.font()
font.setBold(True)
item.setFont(font)
item.setData(Qt.UserRole + 1, float(value.toordinal()))
return item
def fill(widget: QTableWidget, rows: list[list[QTableWidgetItem]], keys: list = None) -> None:
"""Заполнить таблицу, сохранив идентификатор строки в UserRole нулевой ячейки.
Выделение восстанавливается по идентификатору, а не по номеру строки.
Таблицы отсортированы, и после перерисовки под тем же номером оказывается
уже другой объект: правишь цену одного товара, а «Удалить» нажимается
на соседнем.
"""
previously = selected_key(widget)
was_sorting = widget.isSortingEnabled()
widget.setSortingEnabled(False)
widget.setRowCount(len(rows))
for r, cells in enumerate(rows):
for c, cell in enumerate(cells):
widget.setItem(r, c, cell)
if keys is not None and cells:
cells[0].setData(Qt.UserRole, keys[r])
widget.setSortingEnabled(was_sorting)
if keys is not None and previously is not None:
select_by_key(widget, previously)
def select_by_key(widget: QTableWidget, key) -> bool:
for r in range(widget.rowCount()):
item = widget.item(r, 0)
if item is not None and item.data(Qt.UserRole) == key:
widget.selectRow(r)
return True
return False
def row_of(table: QTableWidget, widget) -> int:
"""Найти строку, в которой виджет находится **сейчас**.
Обработчики строк нельзя привязывать к номеру, запомненному при создании:
после удаления строки всё, что было ниже, съезжает вверх, и запомненный
номер начинает указывать на соседа или вообще за пределы таблицы.
Молча ломается вся строка — от подстановки цены до пересчёта суммы.
"""
for r in range(table.rowCount()):
for c in range(table.columnCount()):
if table.cellWidget(r, c) is widget:
return r
return -1
def selected_key(widget: QTableWidget):
row_index = widget.currentRow()
if row_index < 0:
return None
item = widget.item(row_index, 0)
return item.data(Qt.UserRole) if item else None
# --- редактор строк документа ---------------------------------------------
class LinesEditor(QWidget):
"""Таблица строк закупки или продажи.
Один виджет на оба случая: отличается только подпись колонки цены и то,
откуда берётся подстановка — себестоимость или цена продажи.
Колонка фасовки позволяет в одной строке взять пачку, а в другой — штуку
того же товара. Количество и цена вводятся в выбранной фасовке; в базовые
единицы всё пересчитывается уже в расчётах.
"""
changed = Signal()
COL_PRODUCT, COL_QTY, COL_UOM, COL_PRICE, COL_TOTAL = range(5)
def __init__(self, products, price_title: str, price_source, currency: str = "", parent=None):
super().__init__(parent)
self.products = list(products)
self.price_source = price_source
self.currency = currency
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
self.table = QTableWidget(0, 5)
self.table.setHorizontalHeaderLabels(
["Товар", "Кол-во", "Фасовка", price_title, "Сумма"]
)
self.table.verticalHeader().setVisible(False)
# Строки состоят из полей ввода — по содержимому они выходят слишком
# низкими и текст обрезается.
self.table.verticalHeader().setDefaultSectionSize(36)
header = self.table.horizontalHeader()
header.setSectionResizeMode(self.COL_PRODUCT, QHeaderView.Stretch)
for column, width in (
(self.COL_QTY, 95), (self.COL_UOM, 115), (self.COL_PRICE, 130), (self.COL_TOTAL, 120)
):
header.setSectionResizeMode(column, QHeaderView.Interactive)
self.table.setColumnWidth(column, width)
self.table.setMinimumHeight(190)
layout.addWidget(self.table)
self.total_label = label("", "h2")
layout.addWidget(
row(
button("+ Строка", on_click=self.add_line),
button("Удалить строку", role="danger", on_click=self.remove_current),
None,
self.total_label,
)
)
# --- содержимое ---
def add_line(self, product_id: str | None = None, quantity=1, price=None, uom: str = "") -> None:
if not self.products:
return
r = self.table.rowCount()
self.table.insertRow(r)
combo = ProductCombo(self.products)
if product_id:
combo.select_product(product_id)
# Привязываемся к виджету, а не к номеру строки: номер устаревает
# после удаления строки, виджет — нет.
combo.currentIndexChanged.connect(
lambda _, wid=combo: self._on_product_changed(row_of(self.table, wid))
)
self.table.setCellWidget(r, self.COL_PRODUCT, combo)
qty_spin = QtySpin()
qty_spin.set_decimal(quantity)
qty_spin.valueChanged.connect(self._recalc)
self.table.setCellWidget(r, self.COL_QTY, qty_spin)
uom_combo = QComboBox()
self.table.setCellWidget(r, self.COL_UOM, uom_combo)
self._fill_uoms(r, combo.current_product_id(), uom)
uom_combo.currentIndexChanged.connect(
lambda _, wid=uom_combo: self._on_uom_changed(row_of(self.table, wid))
)
price_spin = MoneySpin(self.currency)
price_spin.set_decimal(
price if price is not None else self._suggested_price(combo.current_product_id(), uom)
)
price_spin.valueChanged.connect(self._recalc)
self.table.setCellWidget(r, self.COL_PRICE, price_spin)
self.table.setItem(r, self.COL_TOTAL, text_item(""))
self._recalc()
def remove_current(self) -> None:
r = self.table.currentRow()
if r >= 0:
self.table.removeRow(r)
self._recalc()
def set_lines(self, lines: list, value_attr: str) -> None:
self.table.setRowCount(0)
for line in lines:
self.add_line(line.product_id, line.qty, getattr(line, value_attr), line.uom)
def lines(self, value_key: str) -> list[dict]:
result = []
for r in range(self.table.rowCount()):
combo = self.table.cellWidget(r, self.COL_PRODUCT)
product_id = combo.current_product_id() if combo else None
if not product_id:
continue
result.append(
{
"product_id": product_id,
"qty": self.table.cellWidget(r, self.COL_QTY).value_decimal(),
"uom": self._current_uom(r),
value_key: self.table.cellWidget(r, self.COL_PRICE).value_decimal(),
}
)
return result
# --- фасовки ---
def _product(self, product_id: str | None):
return next((p for p in self.products if p.id == product_id), None)
def _current_uom(self, r: int) -> str:
combo = self.table.cellWidget(r, self.COL_UOM)
return combo.currentText() if combo else ""
def _fill_uoms(self, r: int, product_id: str | None, selected: str = "") -> None:
combo = self.table.cellWidget(r, self.COL_UOM)
if combo is None:
return
product = self._product(product_id)
names = product.uom_names() if product else []
combo.blockSignals(True)
combo.clear()
combo.addItems(names)
if selected and selected in names:
combo.setCurrentText(selected)
combo.blockSignals(False)
# Выбирать не из чего — не отвлекаем внимание.
combo.setEnabled(len(names) > 1)
def total(self) -> Decimal:
total = m.ZERO
for r in range(self.table.rowCount()):
qty_widget = self.table.cellWidget(r, self.COL_QTY)
price_widget = self.table.cellWidget(r, self.COL_PRICE)
if qty_widget and price_widget:
total += qty_widget.value_decimal() * price_widget.value_decimal()
return m.money(total)
def refresh_prices(self) -> None:
"""Пересобрать подстановку цен — например, после смены типа продажи."""
for r in range(self.table.rowCount()):
self._apply_suggested_price(r)
self._recalc()
# --- внутреннее ---
def _suggested_price(self, product_id: str | None, uom: str) -> Decimal:
product = self._product(product_id)
return self.price_source(product, uom) if product else m.ZERO
def _apply_suggested_price(self, r: int) -> None:
if r < 0:
return
combo = self.table.cellWidget(r, self.COL_PRODUCT)
price_widget = self.table.cellWidget(r, self.COL_PRICE)
if combo and price_widget:
price_widget.set_decimal(
self._suggested_price(combo.current_product_id(), self._current_uom(r))
)
def _on_product_changed(self, r: int) -> None:
if r < 0:
return
combo = self.table.cellWidget(r, self.COL_PRODUCT)
# У нового товара свои фасовки, поэтому список пересобирается целиком.
self._fill_uoms(r, combo.current_product_id() if combo else None)
self._apply_suggested_price(r)
self._recalc()
def _on_uom_changed(self, r: int) -> None:
if r < 0:
return
self._apply_suggested_price(r)
self._recalc()
def _recalc(self) -> None:
for r in range(self.table.rowCount()):
qty_widget = self.table.cellWidget(r, self.COL_QTY)
price_widget = self.table.cellWidget(r, self.COL_PRICE)
if not qty_widget or not price_widget:
continue
total = m.money(qty_widget.value_decimal() * price_widget.value_decimal())
item = self.table.item(r, self.COL_TOTAL)
if item is None:
item = text_item("")
self.table.setItem(r, self.COL_TOTAL, item)
item.setText(m.fmt_money(total, self.currency))
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.total_label.setText(f"Итого: {m.fmt_money(self.total(), self.currency)}")
self.changed.emit()
def progress_color(status: str) -> str:
return theme.STATUS_COLORS.get(status, theme.ACCENT)
def monospace(widget) -> None:
font = QFont("Consolas")
font.setStyleHint(QFont.Monospace)
widget.setFont(font)