food-market/app/ui/page_debts.py
Claude c8b9f5048b Учёт закупки и перепродажи булок
Десктопное приложение на PySide6. Вся база — один JSON-документ,
зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит
в data/vault.fmdb и раз в час уезжает в этот репозиторий.

Основное:
- партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж
  по партиям и прогресс покрытия к сроку;
- типы выбытия: розница, другу по себестоимости, съел сам, подарок,
  списание — съеденное вычитается из прибыли, за него платить всё равно;
- долги контрагентов с частичными оплатами;
- номенклатура с историей изменения цен;
- журнал изменений внутри базы: git хранит непрозрачные снимки,
  поэтому настоящая история ведётся здесь.

Синхронизация коммитит только путь базы и схлопывает часовые пуши
в один коммит на день через amend + force-with-lease.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:29:53 +03:00

192 lines
6.4 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 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 . import theme
from . import widgets as w
ROLE_SALE_ID = Qt.UserRole + 2
class PaymentDialog(QDialog):
def __init__(self, suggested: Decimal, currency: str, parent=None):
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)
layout.addWidget(w.label(f"Долг по этой продаже: {m.fmt_money(suggested, currency)}", "dim"))
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) -> Decimal | None:
"""Спросить сумму оплаты. Возвращает None, если отменили."""
dialog = PaymentDialog(suggested, currency, parent)
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,
)
)
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)
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)}"
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_sale(self):
item = self.tree.currentItem()
if item is None:
QMessageBox.information(self, "Не выбрано", "Выбери конкретную продажу.")
return None
sale_id = item.data(0, ROLE_SALE_ID)
if sale_id is None:
QMessageBox.information(
self, "Выбери продажу", "Раскрой человека и выбери, за что он платит."
)
return None
return self.ctx.vault.doc.sale(sale_id)
def take_payment(self) -> None:
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 settle_full(self) -> None:
sale = self._selected_sale()
if sale is None:
return
if sale.debt <= 0:
return
journal.add_sale_payment(self.ctx.vault, sale.id, sale.debt)
self.ctx.changed()