Долг было непонятно как записать, и не зря. Оплата была просто денежным полем с полной суммой: долг получался, если догадаться стереть число и вписать ноль. Дальше — хуже. Подсказка при этом продолжала уверять, что оплачено полностью, а любая правка строк подставляла полную сумму обратно и молча стирала выставленный долг. Теперь выбор явный: заплатил полностью / взял в долг целиком / заплатил часть. Пересчёт работает только в первом режиме. Поле суммы осталось живым — вписанное руками число само зажигает подходящий переключатель. Долг без имени записать нельзя: на экране долгов такая запись попадает в кучу «без контрагента», и с кого спрашивать — уже не узнать. В быстром вводе это предупреждение, а не запрет: внося историю, можно и правда не помнить, кто это был. На экране долгов человека можно выбрать целиком и рассчитаться сразу за всё: деньги приходят одной суммой, а гасятся долги по очереди, со старых. Цены фасовок переехали в «Цены и история». Они меняются одним решением поставщика вместе с ценой товара, а история была только у товара — у коробки цена уезжала молча. Заодно закрыт второй вход: в карточке товара цены фасовок теперь только показываются, правится там состав. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256 lines
9.2 KiB
Python
256 lines
9.2 KiB
Python
"""Долги: кто и сколько должен за взятые булки."""
|
||
|
||
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()
|