food-market/app/ui/widgets.py
Claude efce552e2b Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.

1. База и код разъехались по разным репозиториям. Автокоммит и раньше
   трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
   поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
   клон food-records со своим .git, а исходники его игнорируют целиком.
   При первом запуске приложение встаёт на уже существующую историю
   сервера, а не заводит параллельную.

2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
   за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
   считаются в базовых единицах, поэтому поштучные продажи вычитаются из
   купленных пачек. Размер фасовки хранится в документе слепком.
   Миграция схемы 1→2 не меняет поведение уже заведённых данных.

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

Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.

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

503 lines
18 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
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 MoneySpin(QDoubleSpinBox):
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(QDoubleSpinBox):
def __init__(self, parent=None):
super().__init__(parent)
self.setDecimals(3)
self.setRange(0, 1_000_000)
self.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
# Дробные количества нужны для веса, но булки штучные — прячем нули.
self.setStepType(QDoubleSpinBox.DefaultStepType)
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)