Десктопное приложение на PySide6. Вся база — один JSON-документ, зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит в data/vault.fmdb и раз в час уезжает в этот репозиторий. Основное: - партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж по партиям и прогресс покрытия к сроку; - типы выбытия: розница, другу по себестоимости, съел сам, подарок, списание — съеденное вычитается из прибыли, за него платить всё равно; - долги контрагентов с частичными оплатами; - номенклатура с историей изменения цен; - журнал изменений внутри базы: git хранит непрозрачные снимки, поэтому настоящая история ведётся здесь. Синхронизация коммитит только путь базы и схлопывает часовые пуши в один коммит на день через amend + force-with-lease. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
6.4 KiB
Python
192 lines
6.4 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 . 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()
|