Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: self.metric = QComboBox() на QWidget затеняет метод QWidget.metric(), который Qt зовёт при смене стиля. Падало это в чужом месте и с невнятным «object is not callable». Такая же история была с self.layout в сводке. Оба переименованы, добавлен тест, который обходит все виджеты и проверяет, что ни один атрибут не затеняет метод Qt. И подсказка в карточке товара: себестоимость фасовки нигде не вводится, она выводится из цены базовой единицы. Теперь под таблицей фасовок прямо написано, какая сумма подставится в закупку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
502 lines
19 KiB
Python
502 lines
19 KiB
Python
"""Быстрый ввод продаж за период.
|
||
|
||
Обычная форма продажи хороша, когда записываешь одну сделку. Когда надо внести
|
||
историю за прошедший период, она превращается в пытку: на каждую продажу —
|
||
открыть, заполнить, закрыть.
|
||
|
||
Здесь каждая строка таблицы — отдельная продажа. Период задаётся сверху, дата
|
||
новой строки наследуется от предыдущей, всё вводится с клавиатуры, а запись
|
||
происходит одним действием в конце.
|
||
"""
|
||
|
||
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
|