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

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

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

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

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

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

256 lines
9.2 KiB
Python
Raw Permalink 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 decimal import Decimal
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QDialog,
QFormLayout,
QHeaderView,
QLineEdit,
QMessageBox,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from .. import journal
from .. import money as m
from ..models import SALE_KIND_LABELS
from ..salesfilter import plural
from . import theme
from . import widgets as w
ROLE_SALE_ID = Qt.UserRole + 2
ROLE_COUNTERPARTY = Qt.UserRole + 3
class PaymentDialog(QDialog):
def __init__(self, suggested: Decimal, currency: str, parent=None, what: str = "этой продаже"):
super().__init__(parent)
self.setWindowTitle("Принять оплату")
self.setMinimumWidth(340)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 16, 20, 16)
layout.setSpacing(10)
form = QFormLayout()
form.setSpacing(8)
self.date = w.DateInput()
form.addRow("Дата", self.date)
self.amount = w.MoneySpin(currency)
self.amount.set_decimal(suggested)
self.amount.selectAll()
form.addRow("Сумма", self.amount)
self.note = QLineEdit()
form.addRow("Заметка", self.note)
layout.addLayout(form)
self.hint = w.label(f"Долг по {what}: {m.fmt_money(suggested, currency)}", "dim")
self.hint.setWordWrap(True)
layout.addWidget(self.hint)
layout.addWidget(
w.row(None, w.button("Отмена", on_click=self.reject), w.button("Принять", "primary", self.accept))
)
self.amount.setFocus()
def ask_payment(parent, suggested: Decimal, currency: str, what: str = "этой продаже") -> Decimal | None:
"""Спросить сумму оплаты. Возвращает None, если отменили."""
dialog = PaymentDialog(suggested, currency, parent, what)
if dialog.exec() != QDialog.Accepted:
return None
return dialog.amount.value_decimal()
class DebtsPage(QWidget):
"""Дерево: человек → его неоплаченные продажи."""
def __init__(self, ctx, parent=None):
super().__init__(parent)
self.ctx = ctx
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 18, 20, 18)
layout.setSpacing(12)
layout.addWidget(w.heading("Долги"))
self.total_label = w.label("", "h2")
layout.addWidget(
w.row(
w.button("Принять оплату", "primary", self.take_payment),
w.button("Погасить целиком", on_click=self.settle_full),
None,
self.total_label,
)
)
layout.addWidget(
w.label(
"Выбери человека — рассчитаешься сразу за все его долги, начиная "
"со старых. Выбери отдельную продажу — оплата пойдёт только за неё.",
"dim",
)
)
self.tree = QTreeWidget()
self.tree.setHeaderLabels(["Кто / что", "Дата", "Сумма", "Оплачено", "Долг"])
self.tree.setAlternatingRowColors(True)
self.tree.setRootIsDecorated(True)
self.tree.header().setStretchLastSection(False)
self.tree.header().setSectionResizeMode(0, QHeaderView.Stretch)
self.tree.itemDoubleClicked.connect(self.take_payment)
layout.addWidget(self.tree, 1)
self.empty = w.label("Никто ничего не должен.", "dim")
layout.addWidget(self.empty)
# --- отрисовка ---
def refresh(self) -> None:
doc = self.ctx.vault.doc
currency = self.ctx.currency
debtors = self.ctx.report.debtors
self.tree.clear()
for debtor in debtors:
parent = QTreeWidgetItem(
[debtor.name, "", "", "", m.fmt_money(debtor.debt, currency)]
)
font = parent.font(0)
font.setBold(True)
parent.setFont(0, font)
parent.setFont(4, font)
parent.setForeground(4, QColor(theme.WARN))
parent.setTextAlignment(4, Qt.AlignRight | Qt.AlignVCenter)
# Человека можно выбрать целиком и рассчитаться сразу за всё:
# деньги приходят одной суммой, а не по продаже за раз.
parent.setData(0, ROLE_COUNTERPARTY, debtor.counterparty_id)
for sale in sorted(debtor.sales, key=lambda s: s.date):
what = ", ".join(
f"{doc.product_name(line.product_id)} × {m.fmt_qty(line.qty)} "
f"{line.unit_name(doc.product(line.product_id))}"
for line in sale.lines
)
child = QTreeWidgetItem(
[
f"{SALE_KIND_LABELS.get(sale.kind, sale.kind)}: {what}",
sale.date.strftime("%d.%m.%Y"),
m.fmt_money(sale.total, currency),
m.fmt_money(sale.paid, currency),
m.fmt_money(sale.debt, currency),
]
)
child.setData(0, ROLE_SALE_ID, sale.id)
for column in (2, 3, 4):
child.setTextAlignment(column, Qt.AlignRight | Qt.AlignVCenter)
parent.addChild(child)
self.tree.addTopLevelItem(parent)
parent.setExpanded(True)
for column in range(1, 5):
self.tree.resizeColumnToContents(column)
total = self.ctx.report.summary.receivable
self.total_label.setText(f"Мне должны: {m.fmt_money(total, currency)}")
self.empty.setVisible(not debtors)
self.tree.setVisible(bool(debtors))
# --- действия ---
def _selected_debtor(self):
"""Выбранный человек и его общий долг — либо None, если выбрана продажа."""
item = self.tree.currentItem()
if item is None or item.parent() is not None:
return None
cp_id = item.data(0, ROLE_COUNTERPARTY)
return next(
(d for d in self.ctx.report.debtors if d.counterparty_id == cp_id), None
)
def _selected_sale(self):
item = self.tree.currentItem()
if item is None:
QMessageBox.information(self, "Не выбрано", "Выбери человека или продажу.")
return None
sale_id = item.data(0, ROLE_SALE_ID)
if not sale_id:
QMessageBox.information(
self, "Не выбрано", "Выбери человека или конкретную продажу."
)
return None
return self.ctx.vault.doc.sale(sale_id)
def take_payment(self) -> None:
debtor = self._selected_debtor()
if debtor is not None:
self._pay_debtor(debtor)
return
sale = self._selected_sale()
if sale is None:
return
amount = ask_payment(self, sale.debt, self.ctx.currency)
if amount is None:
return
try:
journal.add_sale_payment(self.ctx.vault, sale.id, amount)
except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc))
return
self.ctx.changed()
def _pay_debtor(self, debtor) -> None:
"""Принять деньги от человека сразу за всё, что за ним числится."""
amount = ask_payment(
self,
debtor.debt,
self.ctx.currency,
what=f"{debtor.name}{len(debtor.sales)} "
+ plural(len(debtor.sales), "продажа", "продажи", "продаж"),
)
if amount is None:
return
try:
applied = journal.pay_off_counterparty(
self.ctx.vault, debtor.counterparty_id, amount
)
except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc))
return
self.ctx.changed()
if len(applied) > 1:
QMessageBox.information(
self,
"Готово",
f"{m.fmt_money(amount, self.ctx.currency)} разнесено "
f"по продажам: {len(applied)}, начиная со старых.",
)
def settle_full(self) -> None:
debtor = self._selected_debtor()
if debtor is not None:
journal.pay_off_counterparty(
self.ctx.vault, debtor.counterparty_id, debtor.debt
)
self.ctx.changed()
return
sale = self._selected_sale()
if sale is None or sale.debt <= 0:
return
journal.add_sale_payment(self.ctx.vault, sale.id, sale.debt)
self.ctx.changed()