Цена по фасовке не подставлялась после удаления строки
Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: self.metric = QComboBox() на QWidget затеняет метод QWidget.metric(), который Qt зовёт при смене стиля. Падало это в чужом месте и с невнятным «object is not callable». Такая же история была с self.layout в сводке. Оба переименованы, добавлен тест, который обходит все виджеты и проверяет, что ни один атрибут не затеняет метод Qt. И подсказка в карточке товара: себестоимость фасовки нигде не вводится, она выводится из цены базовой единицы. Теперь под таблицей фасовок прямо написано, какая сумма подставится в закупку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
38e9614a25
commit
26b401e507
@ -118,6 +118,12 @@
|
||||
и остаётся в документе. Размер фасовки сохраняется слепком — переопределишь
|
||||
пачку с 10 на 12 штук, и уже записанные документы не поедут.
|
||||
|
||||
**Себестоимость указывается за базовую единицу**, а не за фасовку. Пачка
|
||||
печенья 10 шт за 200 ₽ — значит себестоимость 20 ₽ за штуку; в закупке
|
||||
программа сама подставит 200 ₽, когда выберешь пачку. Чтобы это не приходилось
|
||||
держать в голове, карточка товара показывает результат прямо под таблицей
|
||||
фасовок: «В закупку подставится: пачка = 10 шт, себестоимость 200,00 ₽».
|
||||
|
||||
### Ввод истории
|
||||
|
||||
Кнопка «Быстрый ввод за период» на экране продаж: задаёшь диапазон дат,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user