"""Общие элементы интерфейса. Здесь всё, что иначе копировалось бы по экранам: поля денег и количеств, настроенные таблицы и редактор строк документа. """ 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 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)