food-market/app/ui/widgets.py

553 lines
20 KiB
Python
Raw Normal View History

"""Общие элементы интерфейса.
Здесь всё, что иначе копировалось бы по экранам: поля денег и количеств,
настроенные таблицы и редактор строк документа.
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from PySide6.QtCore import QDate, Qt, Signal
Починен ввод цены и выделение в таблицах Цену было невозможно набрать. В прошлой правке я стал вырезать пробелы из вводимого текста, чтобы принимались разделители разрядов, — но у поля цены суффикс « ₽» начинается с пробела. Каждое нажатие ломало суффикс, Qt отвергало символ, и в поле оставалась только первая цифра: набираешь «25,50», получаешь 2. Теперь текст на каждом нажатии не трогается вообще: сначала пробуем как есть, и только если Qt отверг именно из-за точки — повторяем с запятой. Второе: форма продажи подключала сигнал редактора строк раньше, чем создавала поле оплаты. Qt глотает исключения в слотах, поэтому вместо падения оплата просто молча не подставлялась. Третье: выделение в таблицах держалось за номер строки. Таблицы отсортированы, и после перерисовки под тем же номером оказывался другой объект — можно было удалить не тот товар. Теперь выделение восстанавливается по идентификатору. Тесты: настоящие нажатия клавиш через QTest вместо проверок validate(), которые этот баг пропустили, и перехват sys.excepthook, чтобы проглоченные Qt исключения считались провалом. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:13:45 +07:00
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):
Починен ввод цены и выделение в таблицах Цену было невозможно набрать. В прошлой правке я стал вырезать пробелы из вводимого текста, чтобы принимались разделители разрядов, — но у поля цены суффикс « ₽» начинается с пробела. Каждое нажатие ломало суффикс, Qt отвергало символ, и в поле оставалась только первая цифра: набираешь «25,50», получаешь 2. Теперь текст на каждом нажатии не трогается вообще: сначала пробуем как есть, и только если Qt отверг именно из-за точки — повторяем с запятой. Второе: форма продажи подключала сигнал редактора строк раньше, чем создавала поле оплаты. Qt глотает исключения в слотах, поэтому вместо падения оплата просто молча не подставлялась. Третье: выделение в таблицах держалось за номер строки. Таблицы отсортированы, и после перерисовки под тем же номером оказывался другой объект — можно было удалить не тот товар. Теперь выделение восстанавливается по идентификатору. Тесты: настоящие нажатия клавиш через QTest вместо проверок validate(), которые этот баг пропустили, и перехват sys.excepthook, чтобы проглоченные Qt исключения считались провалом. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:13:45 +07:00
# Сначала пробуем как есть. Трогать текст на каждом нажатии нельзя:
# у поля цены суффикс « ₽» начинается с пробела, и любая «очистка»
# ломает его — тогда принимается только первый символ, а набрать
# цену становится невозможно.
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)
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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-23 01:25:39 +07:00
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)
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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-23 01:25:39 +07:00
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:
Починен ввод цены и выделение в таблицах Цену было невозможно набрать. В прошлой правке я стал вырезать пробелы из вводимого текста, чтобы принимались разделители разрядов, — но у поля цены суффикс « ₽» начинается с пробела. Каждое нажатие ломало суффикс, Qt отвергало символ, и в поле оставалась только первая цифра: набираешь «25,50», получаешь 2. Теперь текст на каждом нажатии не трогается вообще: сначала пробуем как есть, и только если Qt отверг именно из-за точки — повторяем с запятой. Второе: форма продажи подключала сигнал редактора строк раньше, чем создавала поле оплаты. Qt глотает исключения в слотах, поэтому вместо падения оплата просто молча не подставлялась. Третье: выделение в таблицах держалось за номер строки. Таблицы отсортированы, и после перерисовки под тем же номером оказывался другой объект — можно было удалить не тот товар. Теперь выделение восстанавливается по идентификатору. Тесты: настоящие нажатия клавиш через QTest вместо проверок validate(), которые этот баг пропустили, и перехват sys.excepthook, чтобы проглоченные Qt исключения считались провалом. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:13:45 +07:00
"""Заполнить таблицу, сохранив идентификатор строки в 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)
Починен ввод цены и выделение в таблицах Цену было невозможно набрать. В прошлой правке я стал вырезать пробелы из вводимого текста, чтобы принимались разделители разрядов, — но у поля цены суффикс « ₽» начинается с пробела. Каждое нажатие ломало суффикс, Qt отвергало символ, и в поле оставалась только первая цифра: набираешь «25,50», получаешь 2. Теперь текст на каждом нажатии не трогается вообще: сначала пробуем как есть, и только если Qt отверг именно из-за точки — повторяем с запятой. Второе: форма продажи подключала сигнал редактора строк раньше, чем создавала поле оплаты. Qt глотает исключения в слотах, поэтому вместо падения оплата просто молча не подставлялась. Третье: выделение в таблицах держалось за номер строки. Таблицы отсортированы, и после перерисовки под тем же номером оказывался другой объект — можно было удалить не тот товар. Теперь выделение восстанавливается по идентификатору. Тесты: настоящие нажатия клавиш через QTest вместо проверок validate(), которые этот баг пропустили, и перехват sys.excepthook, чтобы проглоченные Qt исключения считались провалом. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:13:45 +07:00
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 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):
"""Таблица строк закупки или продажи.
Один виджет на оба случая: отличается только подпись колонки цены и то,
откуда берётся подстановка себестоимость или цена продажи.
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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-23 01:25:39 +07:00
Колонка фасовки позволяет в одной строке взять пачку, а в другой штуку
того же товара. Количество и цена вводятся в выбранной фасовке; в базовые
единицы всё пересчитывается уже в расчётах.
"""
changed = Signal()
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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-23 01:25:39 +07:00
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)
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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-23 01:25:39 +07:00
self.table = QTableWidget(0, 5)
self.table.setHorizontalHeaderLabels(
["Товар", "Кол-во", "Фасовка", price_title, "Сумма"]
)
self.table.verticalHeader().setVisible(False)
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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-23 01:25:39 +07:00
# Строки состоят из полей ввода — по содержимому они выходят слишком
# низкими и текст обрезается.
self.table.verticalHeader().setDefaultSectionSize(36)
header = self.table.horizontalHeader()
header.setSectionResizeMode(self.COL_PRODUCT, QHeaderView.Stretch)
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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-23 01:25:39 +07:00
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,
)
)
# --- содержимое ---
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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-23 01:25:39 +07:00
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)
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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-23 01:25:39 +07:00
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(
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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-23 01:25:39 +07:00
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:
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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-23 01:25:39 +07:00
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(),
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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-23 01:25:39 +07:00
"uom": self._current_uom(r),
value_key: self.table.cellWidget(r, self.COL_PRICE).value_decimal(),
}
)
return result
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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-23 01:25:39 +07:00
# --- фасовки ---
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()):
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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-23 01:25:39 +07:00
self._apply_suggested_price(r)
self._recalc()
# --- внутреннее ---
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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-23 01:25:39 +07:00
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
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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-23 01:25:39 +07:00
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:
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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-23 01:25:39 +07:00
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)