From 26b401e507c94c73e0fb6fdddd02bac4895594df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 17:50:42 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A6=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=84=D0=B0=D1=81=D0=BE=D0=B2=D0=BA=D0=B5=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D1=81=D1=82=D0=B0=D0=B2=D0=BB=D1=8F=D0=BB?= =?UTF-8?q?=D0=B0=D1=81=D1=8C=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: self.metric = QComboBox() на QWidget затеняет метод QWidget.metric(), который Qt зовёт при смене стиля. Падало это в чужом месте и с невнятным «object is not callable». Такая же история была с self.layout в сводке. Оба переименованы, добавлен тест, который обходит все виджеты и проверяет, что ни один атрибут не затеняет метод Qt. И подсказка в карточке товара: себестоимость фасовки нигде не вводится, она выводится из цены базовой единицы. Теперь под таблицей фасовок прямо написано, какая сумма подставится в закупку. Co-Authored-By: Claude Opus 5 --- README.md | 6 +++ app/ui/page_dashboard.py | 29 +++++------ app/ui/page_products.py | 45 ++++++++++++++++- app/ui/page_stats.py | 17 ++++--- app/ui/quick_sales.py | 36 +++++++++++--- app/ui/widgets.py | 31 +++++++++++- tests/test_dialogs.py | 48 ++++++++++++++++++ tests/test_quick_sales.py | 37 ++++++++++++++ tests/test_ui_smoke.py | 101 +++++++++++++++++++++++++++++++++++++- 9 files changed, 318 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 9c21dff..996af22 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,12 @@ и остаётся в документе. Размер фасовки сохраняется слепком — переопределишь пачку с 10 на 12 штук, и уже записанные документы не поедут. +**Себестоимость указывается за базовую единицу**, а не за фасовку. Пачка +печенья 10 шт за 200 ₽ — значит себестоимость 20 ₽ за штуку; в закупке +программа сама подставит 200 ₽, когда выберешь пачку. Чтобы это не приходилось +держать в голове, карточка товара показывает результат прямо под таблицей +фасовок: «В закупку подставится: пачка = 10 шт, себестоимость 200,00 ₽». + ### Ввод истории Кнопка «Быстрый ввод за период» на экране продаж: задаёшь диапазон дат, diff --git a/app/ui/page_dashboard.py b/app/ui/page_dashboard.py index fd10d47..3c163f9 100644 --- a/app/ui/page_dashboard.py +++ b/app/ui/page_dashboard.py @@ -39,16 +39,17 @@ class DashboardPage(QWidget): outer.addWidget(scroll) self.body = QWidget() - self.layout = QVBoxLayout(self.body) - self.layout.setContentsMargins(20, 18, 20, 18) - self.layout.setSpacing(14) + # Не self.layout: имя занято методом QWidget.layout(). + self.body_layout = QVBoxLayout(self.body) + self.body_layout.setContentsMargins(20, 18, 20, 18) + self.body_layout.setSpacing(14) scroll.setWidget(self.body) def refresh(self) -> None: # Сводка целиком собирается заново: цифр немного, а следить за тем, # какая карточка устарела, вышло бы дороже, чем перерисовать всё. - while self.layout.count(): - item = self.layout.takeAt(0) + while self.body_layout.count(): + item = self.body_layout.takeAt(0) widget = item.widget() if widget is not None: # setParent(None) обязателен: deleteLater откладывает удаление @@ -61,22 +62,22 @@ class DashboardPage(QWidget): summary = report.summary currency = self.ctx.currency - self.layout.addWidget(w.heading("Сводка")) + self.body_layout.addWidget(w.heading("Сводка")) if report.warnings: - self.layout.addWidget(w.warning_banner(report.warnings)) + self.body_layout.addWidget(w.warning_banner(report.warnings)) - self.layout.addWidget(self._metrics(summary, currency)) + self.body_layout.addWidget(self._metrics(summary, currency)) if summary.next_due is not None: - self.layout.addWidget(self._deadline_card(summary.next_due, currency)) + self.body_layout.addWidget(self._deadline_card(summary.next_due, currency)) - self.layout.addWidget(w.label("Открытые партии", "h2")) - self.layout.addWidget(self._open_batches(report, currency)) + self.body_layout.addWidget(w.label("Открытые партии", "h2")) + self.body_layout.addWidget(self._open_batches(report, currency)) - self.layout.addWidget(w.label("Остатки", "h2")) - self.layout.addWidget(self._stock(report, currency)) - self.layout.addStretch(1) + self.body_layout.addWidget(w.label("Остатки", "h2")) + self.body_layout.addWidget(self._stock(report, currency)) + self.body_layout.addStretch(1) def _metrics(self, summary, currency: str) -> QWidget: holder = QWidget() diff --git a/app/ui/page_products.py b/app/ui/page_products.py index 7f0c877..9812507 100644 --- a/app/ui/page_products.py +++ b/app/ui/page_products.py @@ -32,10 +32,12 @@ class PacksEditor(QWidget): COL_NAME, COL_SIZE, COL_PRICE = range(3) - def __init__(self, currency: str, base_unit: str, packs=(), parent=None): + def __init__(self, currency: str, base_unit: str, packs=(), cost_source=None, parent=None): super().__init__(parent) self.currency = currency self.base_unit = base_unit + # Откуда брать себестоимость базовой единицы для подсказки. + self.cost_source = cost_source or (lambda: m.ZERO) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -60,14 +62,42 @@ class PacksEditor(QWidget): None, ) ) + + # Себестоимость фасовки нигде не вводится — она выводится из цены + # базовой единицы. Показываем результат, иначе непонятно, откуда + # берётся сумма, которую программа подставит в закупку. + self.derived = w.label("", "dim") + self.derived.setWordWrap(True) + layout.addWidget(self.derived) + for pack in packs: self.add_pack(pack.name, pack.size, pack.retail_price) + self.refresh_hint() + + def refresh_hint(self) -> None: + cost = m.money(self.cost_source()) + parts = [] + for pack in self.packs(): + parts.append( + f"{pack['name']} = {m.fmt_qty(pack['size'])} {self.base_unit}, " + f"себестоимость {m.fmt_money(cost * pack['size'], self.currency)}" + ) + + if not parts: + self.derived.setText("") + return + self.derived.setText( + "В закупку подставится: " + "; ".join(parts) + + ". Себестоимость фасовки считается от цены за одну " + f"{self.base_unit} — её и указывай выше." + ) def set_base_unit(self, unit: str) -> None: self.base_unit = unit or "шт" self.table.setHorizontalHeaderItem( self.COL_SIZE, QTableWidgetItem(f"Сколько {self.base_unit}") ) + self.refresh_hint() def add_pack(self, name: str = "", size=1, price=0) -> None: r = self.table.rowCount() @@ -75,20 +105,24 @@ class PacksEditor(QWidget): name_edit = QLineEdit(name) name_edit.setPlaceholderText("пачка") + name_edit.textChanged.connect(self.refresh_hint) self.table.setCellWidget(r, self.COL_NAME, name_edit) size_spin = w.QtySpin() size_spin.set_decimal(size) + size_spin.valueChanged.connect(self.refresh_hint) self.table.setCellWidget(r, self.COL_SIZE, size_spin) price_spin = w.MoneySpin(self.currency) price_spin.set_decimal(price) self.table.setCellWidget(r, self.COL_PRICE, price_spin) + self.refresh_hint() def remove_current(self) -> None: r = self.table.currentRow() if r >= 0: self.table.removeRow(r) + self.refresh_hint() def packs(self) -> list[dict]: result = [] @@ -152,9 +186,16 @@ class ProductDialog(QDialog): layout.itemAt(layout.count() - 1).widget().setWordWrap(True) self.packs = PacksEditor( - currency, self.unit.text() or "шт", product.packs if product else () + currency, + self.unit.text() or "шт", + product.packs if product else (), + # У существующего товара цена правится отдельным диалогом, у нового + # берём её прямо из поля, чтобы подсказка считалась на лету. + cost_source=(lambda: product.cost_price) if product else self.cost.value_decimal, ) self.unit.textChanged.connect(self.packs.set_base_unit) + if product is None: + self.cost.valueChanged.connect(lambda _: self.packs.refresh_hint()) layout.addWidget(self.packs) if product is not None: diff --git a/app/ui/page_stats.py b/app/ui/page_stats.py index 65418c3..2e9163b 100644 --- a/app/ui/page_stats.py +++ b/app/ui/page_stats.py @@ -69,13 +69,16 @@ class BucketTab(QWidget): layout.setContentsMargins(14, 12, 14, 12) layout.setSpacing(10) - self.metric = QComboBox() + # Не self.metric: у QWidget есть метод metric(), который Qt зовёт + # при смене стиля. Атрибут его затеняет, и приложение падает + # с невнятным «object is not callable». + self.metric_box = QComboBox() for title, attribute, color in METRICS: - self.metric.addItem(title, (attribute, color)) - self.metric.currentIndexChanged.connect(self._draw_chart) + self.metric_box.addItem(title, (attribute, color)) + self.metric_box.currentIndexChanged.connect(self._draw_chart) self.headline = w.label("", "h2") - layout.addWidget(w.row(w.label("На графике:"), self.metric, None, self.headline)) + layout.addWidget(w.row(w.label("На графике:"), self.metric_box, None, self.headline)) self.chart = BarChart() layout.addWidget(self.chart) @@ -125,7 +128,7 @@ class BucketTab(QWidget): ] def _draw_chart(self) -> None: - attribute, color = self.metric.currentData() + attribute, color = self.metric_box.currentData() shown = self.buckets[-CHART_LIMIT:] currency = self.ctx.currency @@ -134,7 +137,7 @@ class BucketTab(QWidget): Bar( label=self._short(bucket), value=getattr(bucket, attribute), - tooltip=f"{bucket.label}\n{self.metric.currentText()}: " + tooltip=f"{bucket.label}\n{self.metric_box.currentText()}: " f"{m.fmt_money(getattr(bucket, attribute), currency)}", ) for bucket in shown @@ -148,7 +151,7 @@ class BucketTab(QWidget): # Про срез говорим вслух: молча показанная часть выглядела бы как всё. tail = f" · на графике последние {len(shown)} из {len(self.buckets)}" if hidden else "" self.headline.setText( - f"{self.metric.currentText()} за всё время: {m.fmt_money(total, currency)}{tail}" + f"{self.metric_box.currentText()} за всё время: {m.fmt_money(total, currency)}{tail}" ) def _short(self, bucket: stats.Bucket) -> str: diff --git a/app/ui/quick_sales.py b/app/ui/quick_sales.py index 08be1b9..44b31ab 100644 --- a/app/ui/quick_sales.py +++ b/app/ui/quick_sales.py @@ -206,7 +206,9 @@ class QuickSalesDialog(QDialog): for value in SALE_KINDS: kind.addItem(SALE_KIND_LABELS[value], value) kind.setCurrentIndex(SALE_KINDS.index(state["kind"])) - kind.currentIndexChanged.connect(lambda _, rr=r: self._on_kind_changed(rr)) + kind.currentIndexChanged.connect( + lambda _, wid=kind: self._on_kind_changed(w.row_of(self.table, wid)) + ) self.table.setCellWidget(r, COL_KIND, kind) who = QComboBox() @@ -222,26 +224,36 @@ class QuickSalesDialog(QDialog): product = w.ProductCombo(self.products) if state["product_id"]: product.select_product(state["product_id"]) - product.currentIndexChanged.connect(lambda _, rr=r: self._on_product_changed(rr)) + product.currentIndexChanged.connect( + lambda _, wid=product: self._on_product_changed(w.row_of(self.table, wid)) + ) self.table.setCellWidget(r, COL_PRODUCT, product) qty = w.QtySpin() qty.set_decimal(state["qty"]) - qty.valueChanged.connect(lambda _, rr=r: self._on_amount_changed(rr)) + qty.valueChanged.connect( + lambda _, wid=qty: self._on_amount_changed(w.row_of(self.table, wid)) + ) self.table.setCellWidget(r, COL_QTY, qty) uom = QComboBox() self.table.setCellWidget(r, COL_UOM, uom) self._fill_uoms(r, product.current_product_id(), state["uom"]) - uom.currentIndexChanged.connect(lambda _, rr=r: self._on_uom_changed(rr)) + uom.currentIndexChanged.connect( + lambda _, wid=uom: self._on_uom_changed(w.row_of(self.table, wid)) + ) price = w.MoneySpin(self.ctx.currency) self.table.setCellWidget(r, COL_PRICE, price) - price.valueChanged.connect(lambda _, rr=r: self._on_amount_changed(rr)) + price.valueChanged.connect( + lambda _, wid=price: self._on_amount_changed(w.row_of(self.table, wid)) + ) paid = w.MoneySpin(self.ctx.currency) paid.setProperty(TOUCHED, False) - paid.valueChanged.connect(lambda _, rr=r: self._on_paid_edited(rr)) + paid.valueChanged.connect( + lambda _, wid=paid: self._on_paid_edited(w.row_of(self.table, wid)) + ) self.table.setCellWidget(r, COL_PAID, paid) tip = w.MoneySpin(self.ctx.currency) @@ -299,6 +311,8 @@ class QuickSalesDialog(QDialog): combo.setEnabled(len(names) > 1) def _apply_price(self, r: int) -> None: + if r < 0: + return product = self._product(self.table.cellWidget(r, COL_PRODUCT).current_product_id()) if product is None: return @@ -308,15 +322,21 @@ class QuickSalesDialog(QDialog): self.table.cellWidget(r, COL_PRICE).set_decimal(price_source_for(kind)(product, uom)) def _on_product_changed(self, r: int) -> None: + if r < 0: + return self._fill_uoms(r, self.table.cellWidget(r, COL_PRODUCT).current_product_id()) self._apply_price(r) self._on_amount_changed(r) def _on_uom_changed(self, r: int) -> None: + if r < 0: + return self._apply_price(r) self._on_amount_changed(r) def _on_kind_changed(self, r: int) -> None: + if r < 0: + return kind = self.table.cellWidget(r, COL_KIND).currentData() consumption = kind in CONSUMPTION_KINDS @@ -332,6 +352,8 @@ class QuickSalesDialog(QDialog): self._on_amount_changed(r) def _on_amount_changed(self, r: int) -> None: + if r < 0: + return paid = self.table.cellWidget(r, COL_PAID) # Полная оплата — самый частый случай. Если её правили руками, больше # не трогаем: значит, там долг. @@ -342,6 +364,8 @@ class QuickSalesDialog(QDialog): self._recalc() def _on_paid_edited(self, r: int) -> None: + if r < 0: + return self.table.cellWidget(r, COL_PAID).setProperty(TOUCHED, True) self._recalc() diff --git a/app/ui/widgets.py b/app/ui/widgets.py index 7f0d59d..723b81f 100644 --- a/app/ui/widgets.py +++ b/app/ui/widgets.py @@ -331,6 +331,21 @@ def select_by_key(widget: QTableWidget, key) -> bool: 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: @@ -408,7 +423,11 @@ class LinesEditor(QWidget): combo = ProductCombo(self.products) if product_id: combo.select_product(product_id) - combo.currentIndexChanged.connect(lambda _, rr=r: self._on_product_changed(rr)) + # Привязываемся к виджету, а не к номеру строки: номер устаревает + # после удаления строки, виджет — нет. + 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() @@ -419,7 +438,9 @@ class LinesEditor(QWidget): 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)) + 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( @@ -506,6 +527,8 @@ class LinesEditor(QWidget): 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: @@ -514,6 +537,8 @@ class LinesEditor(QWidget): ) 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) @@ -521,6 +546,8 @@ class LinesEditor(QWidget): self._recalc() def _on_uom_changed(self, r: int) -> None: + if r < 0: + return self._apply_suggested_price(r) self._recalc() diff --git a/tests/test_dialogs.py b/tests/test_dialogs.py index 31e2eb5..7a83573 100644 --- a/tests/test_dialogs.py +++ b/tests/test_dialogs.py @@ -169,6 +169,54 @@ def test_payment_dialog_is_quiet(ctx, silent): dialog.deleteLater() +def test_no_attribute_shadows_a_qt_method(): + """Атрибут с именем метода Qt — мина замедленного действия. + + `self.metric = QComboBox()` на QWidget затеняет QWidget.metric(), который + Qt зовёт при смене стиля и в некоторых путях отрисовки. Падает это не + там, где написано, и с невнятным «object is not callable». + """ + import ast + import pathlib + + from PySide6.QtWidgets import QDialog, QWidget + + reserved = {n for n in dir(QWidget) if not n.startswith("_")} + reserved |= {n for n in dir(QDialog) if not n.startswith("_")} + + offenders = [] + for path in sorted(pathlib.Path("app/ui").rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if ( + isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id == "self" + and target.attr in reserved + ): + offenders.append(f"{path}:{node.lineno} self.{target.attr}") + + assert offenders == [], "Атрибуты затеняют методы Qt: " + "; ".join(offenders) + + +def test_switching_style_does_not_break_pages(ctx, silent, qapp): + """Смена стиля перебирает все виджеты и ловит затенённые методы Qt.""" + from app.ui.page_dashboard import DashboardPage + from app.ui.page_stats import StatsPage + + pages = [DashboardPage(ctx), StatsPage(ctx)] + for page in pages: + page.refresh() + + qapp.setStyle("Fusion") + assert silent == [] + for page in pages: + page.deleteLater() + + def test_every_page_refreshes_quietly(ctx, silent): from app.ui.page_batches import BatchesPage from app.ui.page_dashboard import DashboardPage diff --git a/tests/test_quick_sales.py b/tests/test_quick_sales.py index 8048059..b5ad0db 100644 --- a/tests/test_quick_sales.py +++ b/tests/test_quick_sales.py @@ -266,6 +266,43 @@ def test_fractional_quantities_in_bulk_entry(ctx, qapp): d.deleteLater() +def test_row_keeps_working_after_an_earlier_row_is_deleted(ctx, qapp): + """Строки не должны держаться за свой номер — он устаревает при удалении.""" + from app.ui.quick_sales import COL_PRICE, COL_UOM + + cookies = journal.create_product(ctx.vault, "Печенье", unit="шт", cost_price=20, retail_price=30) + journal.set_packs(ctx.vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}]) + + d = QuickSalesDialog(ctx) + d.add_row() + fill(d, 1, cookies.id, 1) + + d.table.setCurrentCell(0, 0) + d.remove_row() + assert d.table.rowCount() == 1 + + d.table.cellWidget(0, COL_UOM).setCurrentText("пачка") + assert d.table.cellWidget(0, COL_PRICE).value_decimal() == Decimal("200.00") + assert d.collect()[0]["line"]["uom"] == "пачка" + d.deleteLater() + + +def test_kind_switch_still_works_after_deletion(ctx, qapp, buns): + from app.models import SALE_KINDS + from app.ui.quick_sales import COL_KIND, COL_PRICE + + d = QuickSalesDialog(ctx) + d.add_row() + fill(d, 1, buns["мак"].id, 1) + + d.table.setCurrentCell(0, 0) + d.remove_row() + + d.table.cellWidget(0, COL_KIND).setCurrentIndex(SALE_KINDS.index(KIND_FRIEND)) + assert d.table.cellWidget(0, COL_PRICE).value_decimal() == Decimal("20.00") + d.deleteLater() + + def test_tips_in_bulk_entry(dialog, ctx, buns): from app.ui.quick_sales import COL_TIP diff --git a/tests/test_ui_smoke.py b/tests/test_ui_smoke.py index d767ab2..4a40e2f 100644 --- a/tests/test_ui_smoke.py +++ b/tests/test_ui_smoke.py @@ -422,6 +422,74 @@ def test_lines_editor_switches_price_with_the_pack(window): dialog.deleteLater() +def test_pack_price_still_applies_after_deleting_a_row(window, buns): + """Обработчики строк не должны держаться за номер строки. + + После удаления строки всё, что было ниже, съезжает вверх. Запомненный + номер начинал указывать на соседа или за пределы таблицы, и строка + молча переставала работать: выбираешь фасовку, а цена не меняется. + """ + from app import journal as j + + cookies = j.create_product(window.vault, "Печенье", unit="шт", cost_price=20, retail_price=30) + j.set_packs(window.vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}]) + window.changed() + + dialog = BatchDialog(window) + editor = dialog.lines + editor.add_line() + editor.table.cellWidget(1, editor.COL_PRODUCT).select_product(cookies.id) + + # Удаляем первую строку — печенье переезжает на её место. + editor.table.setCurrentCell(0, 0) + editor.remove_current() + assert editor.table.rowCount() == 1 + + editor.table.cellWidget(0, editor.COL_UOM).setCurrentText("пачка") + line = editor.lines("unit_cost")[0] + assert line["uom"] == "пачка" + assert line["unit_cost"] == Decimal("200.00") # 20 за штуку × 10 + dialog.deleteLater() + + +def test_product_change_still_refills_packs_after_deleting_a_row(window, buns): + from app import journal as j + + cookies = j.create_product(window.vault, "Печенье", unit="шт", cost_price=20, retail_price=30) + j.set_packs(window.vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}]) + window.changed() + + dialog = SaleDialog(window) + editor = dialog.lines + editor.add_line() + editor.table.setCurrentCell(0, 0) + editor.remove_current() + + # Строка переехала наверх — выбор товара обязан пересобрать её фасовки. + editor.table.cellWidget(0, editor.COL_PRODUCT).select_product(cookies.id) + uom = editor.table.cellWidget(0, editor.COL_UOM) + assert [uom.itemText(i) for i in range(uom.count())] == ["шт", "пачка"] + assert editor.lines("unit_price")[0]["unit_price"] == Decimal("30.00") + dialog.deleteLater() + + +def test_row_of_finds_the_current_position(qapp): + from PySide6.QtWidgets import QLineEdit, QTableWidget + + from app.ui import widgets as wd + + table = QTableWidget(3, 1) + fields = [QLineEdit() for _ in range(3)] + for index, field in enumerate(fields): + table.setCellWidget(index, 0, field) + + assert wd.row_of(table, fields[2]) == 2 + table.removeRow(0) + assert wd.row_of(table, fields[2]) == 1 # съехал вверх + assert wd.row_of(table, fields[0]) == -1 # удалён + table.deleteLater() + + def test_pack_column_is_disabled_without_packs(window, buns): dialog = SaleDialog(window) editor = dialog.lines @@ -433,6 +501,37 @@ def test_pack_column_is_disabled_without_packs(window, buns): dialog.deleteLater() +def test_packs_editor_shows_the_derived_cost(window): + """Себестоимость фасовки нигде не вводится — надо показать, откуда она. + + Иначе легко указать в карточке цену за пачку вместо цены за штуку, + и подставляемая в закупку сумма окажется больше в размер фасовки. + """ + from app import journal as j + from app.ui.page_products import ProductDialog as PD + + cookies = j.create_product(window.vault, "Печенье", unit="шт", cost_price=20, retail_price=30) + j.set_packs(window.vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}]) + + dialog = PD("₽", window.vault.doc.product(cookies.id)) + assert "пачка = 10 шт" in dialog.packs.derived.text() + assert "200,00 ₽" in dialog.packs.derived.text() # 20 за штуку × 10 + dialog.deleteLater() + + +def test_derived_cost_follows_the_price_field(window): + from app.ui.page_products import ProductDialog as PD + + dialog = PD("₽") + dialog.cost.set_decimal(20) + dialog.packs.add_pack("пачка", 10, 200) + assert "200,00 ₽" in dialog.packs.derived.text() + + dialog.cost.set_decimal(25) + assert "250,00 ₽" in dialog.packs.derived.text() + dialog.deleteLater() + + def test_packs_editor_round_trip(window): from app import journal as j from app.ui.page_products import ProductDialog as PD @@ -507,7 +606,7 @@ def test_stats_metric_switch_redraws_the_chart(window): page.refresh() tab = page.sections[2][1] - tab.metric.setCurrentIndex(1) # Выручка + tab.metric_box.setCurrentIndex(1) # Выручка assert "Выручка за всё время" in tab.headline.text() assert len(tab.chart.bars) == len(tab.buckets)