Количества и раньше хранились в Decimal с тремя знаками, но до документа они не доходили: поля ввода отвергали точку. Интерфейс русский, локаль ждёт запятую, а на цифровой клавиатуре клавиша даёт точку — символ молча не появлялся, и выглядело это так, будто дробные вводить нельзя вообще. Теперь оба разделителя принимаются, пробелы-разделители разрядов тоже. Заодно исправлена ошибка, которую дробные вскрыли: в сводке складывалось общее количество остатка по всем товарам, то есть литры с килограммами и штуками. Теперь остаток выражен деньгами и числом позиций, а разбивка по единицам осталась в таблице «Остатки». Фасовки тоже могут быть дробными: бутылка 1,5 л. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
527 lines
19 KiB
Python
527 lines
19 KiB
Python
"""Общие элементы интерфейса.
|
||
|
||
Здесь всё, что иначе копировалось бы по экранам: поля денег и количеств,
|
||
настроенные таблицы и редактор строк документа.
|
||
"""
|
||
|
||
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
|
||
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):
|
||
# Пробелы — это разделители разрядов; при вводе они только мешают.
|
||
fixed = text.replace(".", ",").replace(" ", "").replace(" ", "")
|
||
if fixed != text:
|
||
pos = min(pos, len(fixed))
|
||
return super().validate(fixed, pos)
|
||
|
||
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 fill(widget: QTableWidget, rows: list[list[QTableWidgetItem]], keys: list = None) -> None:
|
||
"""Заполнить таблицу, сохранив идентификатор строки в UserRole нулевой ячейки."""
|
||
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)
|
||
|
||
|
||
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 _, rr=r: self._on_product_changed(rr))
|
||
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 _, rr=r: self._on_uom_changed(rr))
|
||
|
||
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:
|
||
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:
|
||
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:
|
||
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)
|