Цена по фасовке не подставлялась после удаления строки
Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: 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 на 12 штук, и уже записанные документы не поедут.
|
||||||
|
|
||||||
|
**Себестоимость указывается за базовую единицу**, а не за фасовку. Пачка
|
||||||
|
печенья 10 шт за 200 ₽ — значит себестоимость 20 ₽ за штуку; в закупке
|
||||||
|
программа сама подставит 200 ₽, когда выберешь пачку. Чтобы это не приходилось
|
||||||
|
держать в голове, карточка товара показывает результат прямо под таблицей
|
||||||
|
фасовок: «В закупку подставится: пачка = 10 шт, себестоимость 200,00 ₽».
|
||||||
|
|
||||||
### Ввод истории
|
### Ввод истории
|
||||||
|
|
||||||
Кнопка «Быстрый ввод за период» на экране продаж: задаёшь диапазон дат,
|
Кнопка «Быстрый ввод за период» на экране продаж: задаёшь диапазон дат,
|
||||||
|
|||||||
@ -39,16 +39,17 @@ class DashboardPage(QWidget):
|
|||||||
outer.addWidget(scroll)
|
outer.addWidget(scroll)
|
||||||
|
|
||||||
self.body = QWidget()
|
self.body = QWidget()
|
||||||
self.layout = QVBoxLayout(self.body)
|
# Не self.layout: имя занято методом QWidget.layout().
|
||||||
self.layout.setContentsMargins(20, 18, 20, 18)
|
self.body_layout = QVBoxLayout(self.body)
|
||||||
self.layout.setSpacing(14)
|
self.body_layout.setContentsMargins(20, 18, 20, 18)
|
||||||
|
self.body_layout.setSpacing(14)
|
||||||
scroll.setWidget(self.body)
|
scroll.setWidget(self.body)
|
||||||
|
|
||||||
def refresh(self) -> None:
|
def refresh(self) -> None:
|
||||||
# Сводка целиком собирается заново: цифр немного, а следить за тем,
|
# Сводка целиком собирается заново: цифр немного, а следить за тем,
|
||||||
# какая карточка устарела, вышло бы дороже, чем перерисовать всё.
|
# какая карточка устарела, вышло бы дороже, чем перерисовать всё.
|
||||||
while self.layout.count():
|
while self.body_layout.count():
|
||||||
item = self.layout.takeAt(0)
|
item = self.body_layout.takeAt(0)
|
||||||
widget = item.widget()
|
widget = item.widget()
|
||||||
if widget is not None:
|
if widget is not None:
|
||||||
# setParent(None) обязателен: deleteLater откладывает удаление
|
# setParent(None) обязателен: deleteLater откладывает удаление
|
||||||
@ -61,22 +62,22 @@ class DashboardPage(QWidget):
|
|||||||
summary = report.summary
|
summary = report.summary
|
||||||
currency = self.ctx.currency
|
currency = self.ctx.currency
|
||||||
|
|
||||||
self.layout.addWidget(w.heading("Сводка"))
|
self.body_layout.addWidget(w.heading("Сводка"))
|
||||||
|
|
||||||
if report.warnings:
|
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:
|
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.body_layout.addWidget(w.label("Открытые партии", "h2"))
|
||||||
self.layout.addWidget(self._open_batches(report, currency))
|
self.body_layout.addWidget(self._open_batches(report, currency))
|
||||||
|
|
||||||
self.layout.addWidget(w.label("Остатки", "h2"))
|
self.body_layout.addWidget(w.label("Остатки", "h2"))
|
||||||
self.layout.addWidget(self._stock(report, currency))
|
self.body_layout.addWidget(self._stock(report, currency))
|
||||||
self.layout.addStretch(1)
|
self.body_layout.addStretch(1)
|
||||||
|
|
||||||
def _metrics(self, summary, currency: str) -> QWidget:
|
def _metrics(self, summary, currency: str) -> QWidget:
|
||||||
holder = QWidget()
|
holder = QWidget()
|
||||||
|
|||||||
@ -32,10 +32,12 @@ class PacksEditor(QWidget):
|
|||||||
|
|
||||||
COL_NAME, COL_SIZE, COL_PRICE = range(3)
|
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)
|
super().__init__(parent)
|
||||||
self.currency = currency
|
self.currency = currency
|
||||||
self.base_unit = base_unit
|
self.base_unit = base_unit
|
||||||
|
# Откуда брать себестоимость базовой единицы для подсказки.
|
||||||
|
self.cost_source = cost_source or (lambda: m.ZERO)
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
@ -60,14 +62,42 @@ class PacksEditor(QWidget):
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Себестоимость фасовки нигде не вводится — она выводится из цены
|
||||||
|
# базовой единицы. Показываем результат, иначе непонятно, откуда
|
||||||
|
# берётся сумма, которую программа подставит в закупку.
|
||||||
|
self.derived = w.label("", "dim")
|
||||||
|
self.derived.setWordWrap(True)
|
||||||
|
layout.addWidget(self.derived)
|
||||||
|
|
||||||
for pack in packs:
|
for pack in packs:
|
||||||
self.add_pack(pack.name, pack.size, pack.retail_price)
|
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:
|
def set_base_unit(self, unit: str) -> None:
|
||||||
self.base_unit = unit or "шт"
|
self.base_unit = unit or "шт"
|
||||||
self.table.setHorizontalHeaderItem(
|
self.table.setHorizontalHeaderItem(
|
||||||
self.COL_SIZE, QTableWidgetItem(f"Сколько {self.base_unit}")
|
self.COL_SIZE, QTableWidgetItem(f"Сколько {self.base_unit}")
|
||||||
)
|
)
|
||||||
|
self.refresh_hint()
|
||||||
|
|
||||||
def add_pack(self, name: str = "", size=1, price=0) -> None:
|
def add_pack(self, name: str = "", size=1, price=0) -> None:
|
||||||
r = self.table.rowCount()
|
r = self.table.rowCount()
|
||||||
@ -75,20 +105,24 @@ class PacksEditor(QWidget):
|
|||||||
|
|
||||||
name_edit = QLineEdit(name)
|
name_edit = QLineEdit(name)
|
||||||
name_edit.setPlaceholderText("пачка")
|
name_edit.setPlaceholderText("пачка")
|
||||||
|
name_edit.textChanged.connect(self.refresh_hint)
|
||||||
self.table.setCellWidget(r, self.COL_NAME, name_edit)
|
self.table.setCellWidget(r, self.COL_NAME, name_edit)
|
||||||
|
|
||||||
size_spin = w.QtySpin()
|
size_spin = w.QtySpin()
|
||||||
size_spin.set_decimal(size)
|
size_spin.set_decimal(size)
|
||||||
|
size_spin.valueChanged.connect(self.refresh_hint)
|
||||||
self.table.setCellWidget(r, self.COL_SIZE, size_spin)
|
self.table.setCellWidget(r, self.COL_SIZE, size_spin)
|
||||||
|
|
||||||
price_spin = w.MoneySpin(self.currency)
|
price_spin = w.MoneySpin(self.currency)
|
||||||
price_spin.set_decimal(price)
|
price_spin.set_decimal(price)
|
||||||
self.table.setCellWidget(r, self.COL_PRICE, price_spin)
|
self.table.setCellWidget(r, self.COL_PRICE, price_spin)
|
||||||
|
self.refresh_hint()
|
||||||
|
|
||||||
def remove_current(self) -> None:
|
def remove_current(self) -> None:
|
||||||
r = self.table.currentRow()
|
r = self.table.currentRow()
|
||||||
if r >= 0:
|
if r >= 0:
|
||||||
self.table.removeRow(r)
|
self.table.removeRow(r)
|
||||||
|
self.refresh_hint()
|
||||||
|
|
||||||
def packs(self) -> list[dict]:
|
def packs(self) -> list[dict]:
|
||||||
result = []
|
result = []
|
||||||
@ -152,9 +186,16 @@ class ProductDialog(QDialog):
|
|||||||
layout.itemAt(layout.count() - 1).widget().setWordWrap(True)
|
layout.itemAt(layout.count() - 1).widget().setWordWrap(True)
|
||||||
|
|
||||||
self.packs = PacksEditor(
|
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)
|
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)
|
layout.addWidget(self.packs)
|
||||||
|
|
||||||
if product is not None:
|
if product is not None:
|
||||||
|
|||||||
@ -69,13 +69,16 @@ class BucketTab(QWidget):
|
|||||||
layout.setContentsMargins(14, 12, 14, 12)
|
layout.setContentsMargins(14, 12, 14, 12)
|
||||||
layout.setSpacing(10)
|
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:
|
for title, attribute, color in METRICS:
|
||||||
self.metric.addItem(title, (attribute, color))
|
self.metric_box.addItem(title, (attribute, color))
|
||||||
self.metric.currentIndexChanged.connect(self._draw_chart)
|
self.metric_box.currentIndexChanged.connect(self._draw_chart)
|
||||||
|
|
||||||
self.headline = w.label("", "h2")
|
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()
|
self.chart = BarChart()
|
||||||
layout.addWidget(self.chart)
|
layout.addWidget(self.chart)
|
||||||
@ -125,7 +128,7 @@ class BucketTab(QWidget):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def _draw_chart(self) -> None:
|
def _draw_chart(self) -> None:
|
||||||
attribute, color = self.metric.currentData()
|
attribute, color = self.metric_box.currentData()
|
||||||
shown = self.buckets[-CHART_LIMIT:]
|
shown = self.buckets[-CHART_LIMIT:]
|
||||||
currency = self.ctx.currency
|
currency = self.ctx.currency
|
||||||
|
|
||||||
@ -134,7 +137,7 @@ class BucketTab(QWidget):
|
|||||||
Bar(
|
Bar(
|
||||||
label=self._short(bucket),
|
label=self._short(bucket),
|
||||||
value=getattr(bucket, attribute),
|
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)}",
|
f"{m.fmt_money(getattr(bucket, attribute), currency)}",
|
||||||
)
|
)
|
||||||
for bucket in shown
|
for bucket in shown
|
||||||
@ -148,7 +151,7 @@ class BucketTab(QWidget):
|
|||||||
# Про срез говорим вслух: молча показанная часть выглядела бы как всё.
|
# Про срез говорим вслух: молча показанная часть выглядела бы как всё.
|
||||||
tail = f" · на графике последние {len(shown)} из {len(self.buckets)}" if hidden else ""
|
tail = f" · на графике последние {len(shown)} из {len(self.buckets)}" if hidden else ""
|
||||||
self.headline.setText(
|
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:
|
def _short(self, bucket: stats.Bucket) -> str:
|
||||||
|
|||||||
@ -206,7 +206,9 @@ class QuickSalesDialog(QDialog):
|
|||||||
for value in SALE_KINDS:
|
for value in SALE_KINDS:
|
||||||
kind.addItem(SALE_KIND_LABELS[value], value)
|
kind.addItem(SALE_KIND_LABELS[value], value)
|
||||||
kind.setCurrentIndex(SALE_KINDS.index(state["kind"]))
|
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)
|
self.table.setCellWidget(r, COL_KIND, kind)
|
||||||
|
|
||||||
who = QComboBox()
|
who = QComboBox()
|
||||||
@ -222,26 +224,36 @@ class QuickSalesDialog(QDialog):
|
|||||||
product = w.ProductCombo(self.products)
|
product = w.ProductCombo(self.products)
|
||||||
if state["product_id"]:
|
if state["product_id"]:
|
||||||
product.select_product(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)
|
self.table.setCellWidget(r, COL_PRODUCT, product)
|
||||||
|
|
||||||
qty = w.QtySpin()
|
qty = w.QtySpin()
|
||||||
qty.set_decimal(state["qty"])
|
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)
|
self.table.setCellWidget(r, COL_QTY, qty)
|
||||||
|
|
||||||
uom = QComboBox()
|
uom = QComboBox()
|
||||||
self.table.setCellWidget(r, COL_UOM, uom)
|
self.table.setCellWidget(r, COL_UOM, uom)
|
||||||
self._fill_uoms(r, product.current_product_id(), state["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)
|
price = w.MoneySpin(self.ctx.currency)
|
||||||
self.table.setCellWidget(r, COL_PRICE, price)
|
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 = w.MoneySpin(self.ctx.currency)
|
||||||
paid.setProperty(TOUCHED, False)
|
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)
|
self.table.setCellWidget(r, COL_PAID, paid)
|
||||||
|
|
||||||
tip = w.MoneySpin(self.ctx.currency)
|
tip = w.MoneySpin(self.ctx.currency)
|
||||||
@ -299,6 +311,8 @@ class QuickSalesDialog(QDialog):
|
|||||||
combo.setEnabled(len(names) > 1)
|
combo.setEnabled(len(names) > 1)
|
||||||
|
|
||||||
def _apply_price(self, r: int) -> None:
|
def _apply_price(self, r: int) -> None:
|
||||||
|
if r < 0:
|
||||||
|
return
|
||||||
product = self._product(self.table.cellWidget(r, COL_PRODUCT).current_product_id())
|
product = self._product(self.table.cellWidget(r, COL_PRODUCT).current_product_id())
|
||||||
if product is None:
|
if product is None:
|
||||||
return
|
return
|
||||||
@ -308,15 +322,21 @@ class QuickSalesDialog(QDialog):
|
|||||||
self.table.cellWidget(r, COL_PRICE).set_decimal(price_source_for(kind)(product, uom))
|
self.table.cellWidget(r, COL_PRICE).set_decimal(price_source_for(kind)(product, uom))
|
||||||
|
|
||||||
def _on_product_changed(self, r: int) -> None:
|
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._fill_uoms(r, self.table.cellWidget(r, COL_PRODUCT).current_product_id())
|
||||||
self._apply_price(r)
|
self._apply_price(r)
|
||||||
self._on_amount_changed(r)
|
self._on_amount_changed(r)
|
||||||
|
|
||||||
def _on_uom_changed(self, r: int) -> None:
|
def _on_uom_changed(self, r: int) -> None:
|
||||||
|
if r < 0:
|
||||||
|
return
|
||||||
self._apply_price(r)
|
self._apply_price(r)
|
||||||
self._on_amount_changed(r)
|
self._on_amount_changed(r)
|
||||||
|
|
||||||
def _on_kind_changed(self, r: int) -> None:
|
def _on_kind_changed(self, r: int) -> None:
|
||||||
|
if r < 0:
|
||||||
|
return
|
||||||
kind = self.table.cellWidget(r, COL_KIND).currentData()
|
kind = self.table.cellWidget(r, COL_KIND).currentData()
|
||||||
consumption = kind in CONSUMPTION_KINDS
|
consumption = kind in CONSUMPTION_KINDS
|
||||||
|
|
||||||
@ -332,6 +352,8 @@ class QuickSalesDialog(QDialog):
|
|||||||
self._on_amount_changed(r)
|
self._on_amount_changed(r)
|
||||||
|
|
||||||
def _on_amount_changed(self, r: int) -> None:
|
def _on_amount_changed(self, r: int) -> None:
|
||||||
|
if r < 0:
|
||||||
|
return
|
||||||
paid = self.table.cellWidget(r, COL_PAID)
|
paid = self.table.cellWidget(r, COL_PAID)
|
||||||
# Полная оплата — самый частый случай. Если её правили руками, больше
|
# Полная оплата — самый частый случай. Если её правили руками, больше
|
||||||
# не трогаем: значит, там долг.
|
# не трогаем: значит, там долг.
|
||||||
@ -342,6 +364,8 @@ class QuickSalesDialog(QDialog):
|
|||||||
self._recalc()
|
self._recalc()
|
||||||
|
|
||||||
def _on_paid_edited(self, r: int) -> None:
|
def _on_paid_edited(self, r: int) -> None:
|
||||||
|
if r < 0:
|
||||||
|
return
|
||||||
self.table.cellWidget(r, COL_PAID).setProperty(TOUCHED, True)
|
self.table.cellWidget(r, COL_PAID).setProperty(TOUCHED, True)
|
||||||
self._recalc()
|
self._recalc()
|
||||||
|
|
||||||
|
|||||||
@ -331,6 +331,21 @@ def select_by_key(widget: QTableWidget, key) -> bool:
|
|||||||
return False
|
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):
|
def selected_key(widget: QTableWidget):
|
||||||
row_index = widget.currentRow()
|
row_index = widget.currentRow()
|
||||||
if row_index < 0:
|
if row_index < 0:
|
||||||
@ -408,7 +423,11 @@ class LinesEditor(QWidget):
|
|||||||
combo = ProductCombo(self.products)
|
combo = ProductCombo(self.products)
|
||||||
if product_id:
|
if product_id:
|
||||||
combo.select_product(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)
|
self.table.setCellWidget(r, self.COL_PRODUCT, combo)
|
||||||
|
|
||||||
qty_spin = QtySpin()
|
qty_spin = QtySpin()
|
||||||
@ -419,7 +438,9 @@ class LinesEditor(QWidget):
|
|||||||
uom_combo = QComboBox()
|
uom_combo = QComboBox()
|
||||||
self.table.setCellWidget(r, self.COL_UOM, uom_combo)
|
self.table.setCellWidget(r, self.COL_UOM, uom_combo)
|
||||||
self._fill_uoms(r, combo.current_product_id(), uom)
|
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 = MoneySpin(self.currency)
|
||||||
price_spin.set_decimal(
|
price_spin.set_decimal(
|
||||||
@ -506,6 +527,8 @@ class LinesEditor(QWidget):
|
|||||||
return self.price_source(product, uom) if product else m.ZERO
|
return self.price_source(product, uom) if product else m.ZERO
|
||||||
|
|
||||||
def _apply_suggested_price(self, r: int) -> None:
|
def _apply_suggested_price(self, r: int) -> None:
|
||||||
|
if r < 0:
|
||||||
|
return
|
||||||
combo = self.table.cellWidget(r, self.COL_PRODUCT)
|
combo = self.table.cellWidget(r, self.COL_PRODUCT)
|
||||||
price_widget = self.table.cellWidget(r, self.COL_PRICE)
|
price_widget = self.table.cellWidget(r, self.COL_PRICE)
|
||||||
if combo and price_widget:
|
if combo and price_widget:
|
||||||
@ -514,6 +537,8 @@ class LinesEditor(QWidget):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _on_product_changed(self, r: int) -> None:
|
def _on_product_changed(self, r: int) -> None:
|
||||||
|
if r < 0:
|
||||||
|
return
|
||||||
combo = self.table.cellWidget(r, self.COL_PRODUCT)
|
combo = self.table.cellWidget(r, self.COL_PRODUCT)
|
||||||
# У нового товара свои фасовки, поэтому список пересобирается целиком.
|
# У нового товара свои фасовки, поэтому список пересобирается целиком.
|
||||||
self._fill_uoms(r, combo.current_product_id() if combo else None)
|
self._fill_uoms(r, combo.current_product_id() if combo else None)
|
||||||
@ -521,6 +546,8 @@ class LinesEditor(QWidget):
|
|||||||
self._recalc()
|
self._recalc()
|
||||||
|
|
||||||
def _on_uom_changed(self, r: int) -> None:
|
def _on_uom_changed(self, r: int) -> None:
|
||||||
|
if r < 0:
|
||||||
|
return
|
||||||
self._apply_suggested_price(r)
|
self._apply_suggested_price(r)
|
||||||
self._recalc()
|
self._recalc()
|
||||||
|
|
||||||
|
|||||||
@ -169,6 +169,54 @@ def test_payment_dialog_is_quiet(ctx, silent):
|
|||||||
dialog.deleteLater()
|
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):
|
def test_every_page_refreshes_quietly(ctx, silent):
|
||||||
from app.ui.page_batches import BatchesPage
|
from app.ui.page_batches import BatchesPage
|
||||||
from app.ui.page_dashboard import DashboardPage
|
from app.ui.page_dashboard import DashboardPage
|
||||||
|
|||||||
@ -266,6 +266,43 @@ def test_fractional_quantities_in_bulk_entry(ctx, qapp):
|
|||||||
d.deleteLater()
|
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):
|
def test_tips_in_bulk_entry(dialog, ctx, buns):
|
||||||
from app.ui.quick_sales import COL_TIP
|
from app.ui.quick_sales import COL_TIP
|
||||||
|
|
||||||
|
|||||||
@ -422,6 +422,74 @@ def test_lines_editor_switches_price_with_the_pack(window):
|
|||||||
dialog.deleteLater()
|
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):
|
def test_pack_column_is_disabled_without_packs(window, buns):
|
||||||
dialog = SaleDialog(window)
|
dialog = SaleDialog(window)
|
||||||
editor = dialog.lines
|
editor = dialog.lines
|
||||||
@ -433,6 +501,37 @@ def test_pack_column_is_disabled_without_packs(window, buns):
|
|||||||
dialog.deleteLater()
|
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):
|
def test_packs_editor_round_trip(window):
|
||||||
from app import journal as j
|
from app import journal as j
|
||||||
from app.ui.page_products import ProductDialog as PD
|
from app.ui.page_products import ProductDialog as PD
|
||||||
@ -507,7 +606,7 @@ def test_stats_metric_switch_redraws_the_chart(window):
|
|||||||
page.refresh()
|
page.refresh()
|
||||||
|
|
||||||
tab = page.sections[2][1]
|
tab = page.sections[2][1]
|
||||||
tab.metric.setCurrentIndex(1) # Выручка
|
tab.metric_box.setCurrentIndex(1) # Выручка
|
||||||
assert "Выручка за всё время" in tab.headline.text()
|
assert "Выручка за всё время" in tab.headline.text()
|
||||||
assert len(tab.chart.bars) == len(tab.buckets)
|
assert len(tab.chart.bars) == len(tab.buckets)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user