food-market/app/ui/quick_sales.py
Claude 26b401e507 Цена по фасовке не подставлялась после удаления строки
Обработчики строк запоминали номер строки в момент создания. После
удаления любой строки всё, что было ниже, съезжает вверх, и запомненный
номер начинает указывать на соседа или за пределы таблицы. Строка молча
переставала работать целиком: выбираешь фасовку — цена не меняется,
меняешь товар — не пересобирается список фасовок. Приходилось править
цену руками.

Теперь обработчики привязаны к самому виджету, а строка ищется по нему
в момент вызова. То же исправлено в быстром вводе, где строк больше
и удаляют их чаще.

Заодно найдена мина, которую посадил я сам в разделе статистики:
self.metric = QComboBox() на QWidget затеняет метод QWidget.metric(),
который Qt зовёт при смене стиля. Падало это в чужом месте и с невнятным
«object is not callable». Такая же история была с self.layout в сводке.
Оба переименованы, добавлен тест, который обходит все виджеты и проверяет,
что ни один атрибут не затеняет метод Qt.

И подсказка в карточке товара: себестоимость фасовки нигде не вводится,
она выводится из цены базовой единицы. Теперь под таблицей фасовок прямо
написано, какая сумма подставится в закупку.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:50:42 +03:00

502 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Быстрый ввод продаж за период.
Обычная форма продажи хороша, когда записываешь одну сделку. Когда надо внести
историю за прошедший период, она превращается в пытку: на каждую продажу —
открыть, заполнить, закрыть.
Здесь каждая строка таблицы — отдельная продажа. Период задаётся сверху, дата
новой строки наследуется от предыдущей, всё вводится с клавиатуры, а запись
происходит одним действием в конце.
"""
from __future__ import annotations
from datetime import date, timedelta
from decimal import Decimal
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QKeySequence, QShortcut
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QHeaderView,
QMessageBox,
QPushButton,
QTableWidget,
QVBoxLayout,
)
from .. import journal
from .. import money as m
from ..models import CONSUMPTION_KINDS, SALE_KIND_LABELS, SALE_KINDS
from . import theme
from . import widgets as w
from .page_sales import price_source_for
DEFAULT_PERIOD_DAYS = 30
(
COL_DATE, COL_KIND, COL_WHO, COL_PRODUCT, COL_QTY,
COL_UOM, COL_PRICE, COL_PAID, COL_TIP, COL_TOTAL,
) = range(10)
HEADERS = [
"Дата", "Тип", "Кому", "Товар", "Кол-во",
"Фасовка", "Цена", "Оплачено", "Чаевые", "Сумма",
]
# Пользователь поправил оплату руками — больше не подставляем её автоматически.
TOUCHED = "paid_touched"
class QuickSalesDialog(QDialog):
def __init__(self, ctx, parent=None):
super().__init__(parent)
self.ctx = ctx
self.products = [p for p in ctx.vault.doc.products if not p.archived]
self.setWindowTitle("Быстрый ввод продаж")
self.setMinimumSize(1120, 620)
self.resize(1320, 700)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 16, 20, 16)
layout.setSpacing(10)
layout.addWidget(w.heading("Быстрый ввод продаж"))
layout.addLayout(self._period_row())
self.hint = w.label("", "dim")
self.hint.setWordWrap(True)
layout.addWidget(self.hint)
self.table = QTableWidget(0, len(HEADERS))
self.table.setHorizontalHeaderLabels(HEADERS)
self.table.verticalHeader().setVisible(False)
# Строки целиком из полей ввода: по содержимому Qt делает их слишком
# низкими, и текст обрезается.
self.table.verticalHeader().setDefaultSectionSize(36)
header = self.table.horizontalHeader()
header.setSectionResizeMode(COL_PRODUCT, QHeaderView.Stretch)
# Ширины заданы руками: ResizeToContents схлопывает пустой выпадающий
# список до нечитаемого огрызка.
for column, width in (
(COL_DATE, 110), (COL_KIND, 195), (COL_WHO, 130), (COL_QTY, 80),
(COL_UOM, 95), (COL_PRICE, 110), (COL_PAID, 110), (COL_TIP, 105),
(COL_TOTAL, 100),
):
header.setSectionResizeMode(column, QHeaderView.Interactive)
self.table.setColumnWidth(column, width)
layout.addWidget(self.table, 1)
self.total_label = w.label("", "h2")
layout.addWidget(
w.row(
w.button("+ Строка (Enter)", on_click=self.add_row),
w.button("Дублировать (Ctrl+D)", on_click=self.duplicate_row),
w.button("Удалить строку", role="danger", on_click=self.remove_row),
None,
self.total_label,
)
)
layout.addWidget(
w.row(
w.label("Записать всё — Ctrl+Enter", "dim"),
None,
w.button("Отмена", on_click=self.reject),
w.button("Записать всё", "primary", self.accept),
)
)
# Enter в диалоге по умолчанию нажимает кнопку — здесь он нужен для
# добавления строки, поэтому автонажатие отключаем у всех кнопок.
for widget in self.findChildren(QPushButton):
widget.setAutoDefault(False)
widget.setDefault(False)
QShortcut(QKeySequence(Qt.Key_Return), self, activated=self.add_row)
QShortcut(QKeySequence(Qt.Key_Enter), self, activated=self.add_row)
QShortcut(QKeySequence("Ctrl+D"), self, activated=self.duplicate_row)
QShortcut(QKeySequence("Ctrl+Return"), self, activated=self.accept)
QShortcut(QKeySequence("Ctrl+Enter"), self, activated=self.accept)
self._update_hint()
if self.products:
self.add_row()
# --- период ---
def _period_row(self):
today = date.today()
self.period_from = w.DateInput(today - timedelta(days=DEFAULT_PERIOD_DAYS))
self.period_to = w.DateInput(today)
self.period_from.dateChanged.connect(self._update_hint)
self.period_to.dateChanged.connect(self._update_hint)
holder = QVBoxLayout()
holder.addWidget(
w.row(
w.label("Вношу данные за период:"),
self.period_from,
w.label(""),
self.period_to,
None,
)
)
return holder
def _update_hint(self) -> None:
start, end = self.period_from.get_date(), self.period_to.get_date()
if end < start:
self.hint.setText("Конец периода раньше начала — поправь даты.")
return
# Показываем, что за период уже записано: дешёвая страховка от того,
# чтобы не внести один и тот же месяц дважды.
existing = [s for s in self.ctx.vault.doc.sales if start <= s.date <= end]
already = (
f"За этот период уже записано продаж: {len(existing)}."
if existing
else "За этот период продаж ещё нет."
)
self.hint.setText(
f"{already} Дата новой строки наследуется от предыдущей — "
"меняй её, когда переходишь к следующему дню."
)
self._recalc()
# --- строки ---
def _last_row_state(self) -> dict:
r = self.table.rowCount() - 1
if r < 0:
return {
"date": self.period_from.get_date(),
"kind": SALE_KINDS[0],
"who": "",
"product_id": None,
"qty": 1,
"uom": "",
"price": None,
}
return {
"date": self.table.cellWidget(r, COL_DATE).get_date(),
"kind": self.table.cellWidget(r, COL_KIND).currentData(),
"who": self.table.cellWidget(r, COL_WHO).currentText(),
"product_id": None,
"qty": 1,
"uom": "",
"price": None,
}
def add_row(self, state: dict | None = None) -> None:
if not self.products:
return
state = state or self._last_row_state()
r = self.table.rowCount()
self.table.insertRow(r)
date_widget = w.DateInput(state["date"])
date_widget.dateChanged.connect(self._recalc)
self.table.setCellWidget(r, COL_DATE, date_widget)
kind = QComboBox()
for value in SALE_KINDS:
kind.addItem(SALE_KIND_LABELS[value], value)
kind.setCurrentIndex(SALE_KINDS.index(state["kind"]))
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()
who.setEditable(True)
who.setInsertPolicy(QComboBox.NoInsert)
who.addItem("")
for cp in sorted(self.ctx.vault.doc.counterparties, key=lambda c: c.name.lower()):
who.addItem(cp.name)
who.setCurrentText(state["who"])
who.setToolTip("Незнакомое имя будет заведено как новый контрагент.")
self.table.setCellWidget(r, COL_WHO, who)
product = w.ProductCombo(self.products)
if state["product_id"]:
product.select_product(state["product_id"])
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 _, 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 _, 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 _, 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 _, 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)
tip.set_decimal(state.get("tip") or 0)
tip.valueChanged.connect(self._recalc)
self.table.setCellWidget(r, COL_TIP, tip)
self.table.setItem(r, COL_TOTAL, w.text_item(""))
self._apply_price(r)
self._on_kind_changed(r)
self.table.setCurrentCell(r, COL_PRODUCT)
product.setFocus()
def duplicate_row(self) -> None:
r = self.table.currentRow()
if r < 0:
return
self.add_row(
{
"date": self.table.cellWidget(r, COL_DATE).get_date(),
"kind": self.table.cellWidget(r, COL_KIND).currentData(),
"who": self.table.cellWidget(r, COL_WHO).currentText(),
"product_id": self.table.cellWidget(r, COL_PRODUCT).current_product_id(),
"qty": self.table.cellWidget(r, COL_QTY).value_decimal(),
"uom": self.table.cellWidget(r, COL_UOM).currentText(),
"price": self.table.cellWidget(r, COL_PRICE).value_decimal(),
"tip": self.table.cellWidget(r, COL_TIP).value_decimal(),
}
)
def remove_row(self) -> None:
r = self.table.currentRow()
if r >= 0:
self.table.removeRow(r)
self._recalc()
# --- реакции ---
def _product(self, product_id):
return next((p for p in self.products if p.id == product_id), None)
def _fill_uoms(self, r: int, product_id, selected: str = "") -> None:
combo = self.table.cellWidget(r, COL_UOM)
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 _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
kind = self.table.cellWidget(r, COL_KIND).currentData()
uom = self.table.cellWidget(r, COL_UOM).currentText()
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
self._apply_price(r)
paid = self.table.cellWidget(r, COL_PAID)
tip = self.table.cellWidget(r, COL_TIP)
paid.setEnabled(not consumption)
tip.setEnabled(not consumption)
self.table.cellWidget(r, COL_PRICE).setEnabled(not consumption)
if consumption:
paid.setProperty(TOUCHED, False)
tip.set_decimal(0)
self._on_amount_changed(r)
def _on_amount_changed(self, r: int) -> None:
if r < 0:
return
paid = self.table.cellWidget(r, COL_PAID)
# Полная оплата — самый частый случай. Если её правили руками, больше
# не трогаем: значит, там долг.
if not paid.property(TOUCHED):
paid.blockSignals(True)
paid.set_decimal(self._row_total(r))
paid.blockSignals(False)
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()
def _row_total(self, r: int) -> Decimal:
kind = self.table.cellWidget(r, COL_KIND).currentData()
if kind in CONSUMPTION_KINDS:
return m.ZERO
qty = self.table.cellWidget(r, COL_QTY).value_decimal()
price = self.table.cellWidget(r, COL_PRICE).value_decimal()
return m.money(qty * price)
def _recalc(self) -> None:
start, end = self.period_from.get_date(), self.period_to.get_date()
total = m.ZERO
outside = 0
for r in range(self.table.rowCount()):
row_total = self._row_total(r)
total += row_total
item = self.table.item(r, COL_TOTAL)
if item is not None:
item.setText(m.fmt_money(row_total, self.ctx.currency))
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
# Дата вне периода — не ошибка, но стоит показать: скорее всего опечатка.
row_date = self.table.cellWidget(r, COL_DATE).get_date()
off = not (start <= row_date <= end)
outside += off
self.table.cellWidget(r, COL_DATE).setStyleSheet(
f"color: {theme.WARN};" if off else ""
)
tips = m.money(
sum(
(
self.table.cellWidget(r, COL_TIP).value_decimal()
for r in range(self.table.rowCount())
if self.table.cellWidget(r, COL_TIP) is not None
),
m.ZERO,
)
)
parts = [
f"Строк: {self.table.rowCount()}",
f"на {m.fmt_money(total, self.ctx.currency)}",
]
if tips > 0:
parts.append(f"чаевых {m.fmt_money(tips, self.ctx.currency)}")
if outside:
parts.append(f"вне периода: {outside}")
self.total_label.setText("".join(parts))
# --- запись ---
def collect(self) -> list[dict]:
"""Собрать строки в описания продаж. Пустые строки просто пропускаются."""
result = []
for r in range(self.table.rowCount()):
product_id = self.table.cellWidget(r, COL_PRODUCT).current_product_id()
qty = self.table.cellWidget(r, COL_QTY).value_decimal()
if not product_id or qty <= 0:
continue
kind = self.table.cellWidget(r, COL_KIND).currentData()
consumption = kind in CONSUMPTION_KINDS
price = m.ZERO if consumption else self.table.cellWidget(r, COL_PRICE).value_decimal()
paid = m.ZERO if consumption else self.table.cellWidget(r, COL_PAID).value_decimal()
tip = m.ZERO if consumption else self.table.cellWidget(r, COL_TIP).value_decimal()
result.append(
{
"date": self.table.cellWidget(r, COL_DATE).get_date(),
"kind": kind,
"who": self.table.cellWidget(r, COL_WHO).currentText().strip(),
"line": {
"product_id": product_id,
"qty": qty,
"uom": self.table.cellWidget(r, COL_UOM).currentText(),
"unit_price": price,
},
"paid": paid,
"tip": tip,
}
)
return result
def accept(self) -> None:
rows = self.collect()
if not rows:
QMessageBox.information(self, "Пусто", "Заполни хотя бы одну строку.")
return
try:
created = write_sales(self.ctx.vault, rows)
except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc))
return
self.created = created
super().accept()
def write_sales(vault, rows: list[dict]) -> int:
"""Записать собранные строки как отдельные продажи.
Незнакомое имя в колонке «Кому» заводится как новый контрагент: при вводе
истории останавливаться и создавать людей вручную — лишняя морока.
"""
known = {c.name.casefold(): c.id for c in vault.doc.counterparties}
created = 0
for row in rows:
counterparty_id = None
name = row["who"]
if name:
key = name.casefold()
if key not in known:
known[key] = journal.create_counterparty(vault, name).id
counterparty_id = known[key]
journal.create_sale(
vault,
row["date"],
row["kind"],
[row["line"]],
counterparty_id=counterparty_id,
paid_amount=row["paid"],
tip=row.get("tip"),
)
created += 1
return created