food-market/app/ui/quick_sales.py
food-market d08d2421f7 Долг как явный выбор, история цен у фасовок
Долг было непонятно как записать, и не зря. Оплата была просто денежным
полем с полной суммой: долг получался, если догадаться стереть число
и вписать ноль. Дальше — хуже. Подсказка при этом продолжала уверять, что
оплачено полностью, а любая правка строк подставляла полную сумму обратно
и молча стирала выставленный долг.

Теперь выбор явный: заплатил полностью / взял в долг целиком / заплатил
часть. Пересчёт работает только в первом режиме. Поле суммы осталось
живым — вписанное руками число само зажигает подходящий переключатель.

Долг без имени записать нельзя: на экране долгов такая запись попадает
в кучу «без контрагента», и с кого спрашивать — уже не узнать. В быстром
вводе это предупреждение, а не запрет: внося историю, можно и правда не
помнить, кто это был.

На экране долгов человека можно выбрать целиком и рассчитаться сразу за
всё: деньги приходят одной суммой, а гасятся долги по очереди, со старых.

Цены фасовок переехали в «Цены и история». Они меняются одним решением
поставщика вместе с ценой товара, а история была только у товара —
у коробки цена уезжала молча. Заодно закрыт второй вход: в карточке
товара цены фасовок теперь только показываются, правится там состав.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 00:25:49 +03:00

534 lines
21 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.setToolTip(
"Сколько получено сразу. Обнули — продажа уйдёт в долг целиком; "
"поставь часть — в долг уйдёт остаток. Не забудь заполнить «Кому»."
)
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
# Долг без имени взыскать не с кого. Здесь это предупреждение, а не
# запрет: при вводе истории можно и правда не помнить, кто это был.
nameless = nameless_debts(rows)
if nameless:
confirm = QMessageBox.question(
self,
"Долг без имени",
f"Строк с долгом, но без указания «Кому»: {len(nameless)}.\n\n"
"Такой долг попадёт в кучу «без контрагента», и с кого "
"спрашивать — будет не видно. Всё равно записать?",
)
if confirm != QMessageBox.Yes:
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 _row_sum(row: dict) -> Decimal:
line = row["line"]
return m.money(line["qty"] * line["unit_price"])
def nameless_debts(rows: list[dict]) -> list[dict]:
"""Строки, где остался долг, но не указано, за кем он.
Отдельной функцией, а не внутри accept: правило должно быть видно тесту,
а не только человеку в модальном окне.
"""
return [row for row in rows if not row["who"] and row["paid"] < _row_sum(row)]
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