food-records/app/ui/page_batches.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

364 lines
13 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 timedelta
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog,
QFormLayout,
QHBoxLayout,
QLineEdit,
QMessageBox,
QProgressBar,
QSplitter,
QVBoxLayout,
QWidget,
)
from .. import journal, ledger
from .. import money as m
from . import theme
from . import widgets as w
class BatchDialog(QDialog):
"""Форма партии: что взял, почём и до какого числа надо рассчитаться."""
def __init__(self, ctx, batch=None, parent=None):
super().__init__(parent)
self.ctx = ctx
self.batch = batch
self.setWindowTitle(f"Закупка №{batch.number}" if batch else "Новая закупка")
self.setMinimumSize(640, 480)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 16, 20, 16)
layout.setSpacing(10)
form = QFormLayout()
form.setSpacing(8)
self.date = w.DateInput(batch.date if batch else None)
self.date.dateChanged.connect(self._shift_due_date)
form.addRow("Дата закупки", self.date)
self.due = w.DateInput(
batch.due_date
if batch
else self.date.get_date() + timedelta(days=ctx.vault.doc.settings.default_period_days)
)
form.addRow("Покрыть до", self.due)
self.note = QLineEdit(batch.note if batch else "")
self.note.setPlaceholderText("договорились по телефону")
form.addRow("Заметка", self.note)
layout.addLayout(form)
layout.addWidget(w.label("Что взял", "h2"))
products = [p for p in ctx.vault.doc.products if not p.archived]
self.lines = w.LinesEditor(
products, "Себестоимость", lambda p: p.cost_price, ctx.currency
)
if batch:
self.lines.set_lines(batch.lines, "unit_cost")
elif products:
self.lines.add_line()
layout.addWidget(self.lines, 1)
if not products:
layout.addWidget(
w.label("Сначала заведи товары на вкладке «Товары».", "dim", theme.WARN)
)
layout.addWidget(
w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сохранить", "primary", self.accept))
)
def _shift_due_date(self) -> None:
"""При смене даты закупки двигаем дедлайн, сохраняя длину периода."""
if self.batch is not None:
return
days = self.ctx.vault.doc.settings.default_period_days
self.due.set_date(self.date.get_date() + timedelta(days=days))
class PaymentDialog(QDialog):
"""Возврат денег пекарне."""
def __init__(self, ctx, batch=None, suggested=None, parent=None):
super().__init__(parent)
self.setWindowTitle("Платёж пекарне")
self.setMinimumWidth(380)
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(ctx.currency)
if suggested is not None:
self.amount.set_decimal(suggested)
form.addRow("Сумма", self.amount)
self.note = QLineEdit()
form.addRow("Заметка", self.note)
layout.addLayout(form)
if batch is not None:
layout.addWidget(w.label(f"Зачтётся в партию №{batch.number}.", "dim"))
else:
layout.addWidget(
w.label("Без выбранной партии платёж закроет самые старые долги.", "dim")
)
layout.addWidget(
w.row(None, w.button("Отмена", on_click=self.reject), w.button("Записать", "primary", self.accept))
)
class BatchesPage(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("Закупки"))
layout.addWidget(
w.row(
w.button("+ Закупка", "primary", self.create),
w.button("Изменить", on_click=self.edit),
w.button("Платёж пекарне", on_click=self.pay),
w.button("Закрыть / открыть", on_click=self.toggle_closed),
w.button("Удалить", "danger", self.delete),
)
)
splitter = QSplitter(Qt.Vertical)
self.table = w.table(
["", "Дата", "Покрыть до", "Осталось", "Долг", "Отдано", "Собрано", "Покрытие", "Статус"],
stretch_column=8,
)
self.table.itemSelectionChanged.connect(self._show_details)
self.table.doubleClicked.connect(self.edit)
splitter.addWidget(self.table)
self.details = self._build_details()
splitter.addWidget(self.details)
splitter.setSizes([420, 260])
layout.addWidget(splitter, 1)
def _build_details(self) -> QWidget:
holder = QWidget()
layout = QVBoxLayout(holder)
layout.setContentsMargins(0, 8, 0, 0)
layout.setSpacing(8)
self.details_title = w.label("Выбери партию", "h2")
layout.addWidget(self.details_title)
self.progress = QProgressBar()
self.progress.setRange(0, 100)
layout.addWidget(self.progress)
self.details_line = w.label("", "dim")
self.details_line.setWordWrap(True)
layout.addWidget(self.details_line)
self.contents = w.table(["Товар", "Взято", "Осталось", "Себестоимость", "Сумма"])
layout.addWidget(self.contents, 1)
return holder
# --- отрисовка ---
def refresh(self) -> None:
currency = self.ctx.currency
rows, keys = [], []
# Свежие партии сверху: они и есть текущая работа.
for report in sorted(
self.ctx.report.batches, key=lambda r: (r.batch.date, r.batch.number), reverse=True
):
batch = report.batch
color = theme.STATUS_COLORS.get(report.status, "")
days = (
""
if report.status == ledger.STATUS_SETTLED
else f"{report.days_left} дн."
if report.days_left >= 0
else f"просрочено на {-report.days_left} дн."
)
rows.append(
[
w.sortable_num_item(str(batch.number), batch.number),
w.text_item(batch.date.strftime("%d.%m.%Y")),
w.text_item(batch.due_date.strftime("%d.%m.%Y")),
w.text_item(days, color),
w.sortable_num_item(m.fmt_money(report.cost_total, currency), report.cost_total),
w.sortable_num_item(m.fmt_money(report.paid_to_bakery, currency), report.paid_to_bakery),
w.sortable_num_item(m.fmt_money(report.cash_collected, currency), report.cash_collected),
w.sortable_num_item(f"{report.coverage_pct}%", report.coverage_pct, color),
w.text_item(ledger.STATUS_LABELS.get(report.status, ""), color),
]
)
keys.append(batch.id)
w.fill(self.table, rows, keys)
self._show_details()
def _show_details(self) -> None:
report = self._selected_report(quiet=True)
if report is None:
self.details_title.setText("Выбери партию")
self.details_line.setText("")
self.progress.setValue(0)
self.contents.setRowCount(0)
return
currency = self.ctx.currency
color = theme.STATUS_COLORS.get(report.status, theme.ACCENT)
self.details_title.setText(f"Партия {report.label}")
self.progress.setValue(int(min(100, max(0, report.coverage_pct))))
self.progress.setFormat(f"собрано {report.coverage_pct}% себестоимости")
self.progress.setStyleSheet(f"QProgressBar::chunk {{ background: {color}; }}")
parts = [
f"Долг пекарне: {m.fmt_money(report.remaining_to_bakery, currency)} "
f"из {m.fmt_money(report.cost_total, currency)}",
f"собрано деньгами {m.fmt_money(report.cash_collected, currency)}",
f"осталось собрать {m.fmt_money(report.still_to_collect, currency)}",
]
if report.consumed_cost > 0:
parts.append(f"съедено и подарено на {m.fmt_money(report.consumed_cost, currency)}")
self.details_line.setText("".join(parts))
doc = self.ctx.vault.doc
rows = []
for line in report.batch.lines:
left = report.stock.get(line.product_id, m.ZERO)
rows.append(
[
w.text_item(doc.product_name(line.product_id)),
w.sortable_num_item(m.fmt_qty(line.qty), line.qty),
w.sortable_num_item(
m.fmt_qty(left), left, theme.MUTED if left <= 0 else ""
),
w.sortable_num_item(m.fmt_money(line.unit_cost, currency), line.unit_cost),
w.sortable_num_item(m.fmt_money(line.total, currency), line.total),
]
)
w.fill(self.contents, rows)
# --- действия ---
def _selected_report(self, quiet: bool = False):
batch_id = w.selected_key(self.table)
report = self.ctx.report.batch_report(batch_id) if batch_id else None
if report is None and not quiet:
QMessageBox.information(self, "Не выбрано", "Выбери партию в списке.")
return report
def create(self) -> None:
if not [p for p in self.ctx.vault.doc.products if not p.archived]:
QMessageBox.information(
self, "Нет товаров", "Сначала заведи булки на вкладке «Товары»."
)
return
dialog = BatchDialog(self.ctx, parent=self)
if dialog.exec() != QDialog.Accepted:
return
try:
journal.create_batch(
self.ctx.vault,
dialog.date.get_date(),
dialog.due.get_date(),
dialog.lines.lines("unit_cost"),
dialog.note.text(),
)
except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc))
return
self.ctx.changed()
def edit(self) -> None:
report = self._selected_report()
if report is None:
return
dialog = BatchDialog(self.ctx, report.batch, parent=self)
if dialog.exec() != QDialog.Accepted:
return
try:
journal.update_batch(
self.ctx.vault,
report.batch.id,
dialog.date.get_date(),
dialog.due.get_date(),
dialog.lines.lines("unit_cost"),
dialog.note.text(),
)
except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc))
return
self.ctx.changed()
def pay(self) -> None:
report = self._selected_report(quiet=True)
batch = report.batch if report else None
dialog = PaymentDialog(
self.ctx, batch, report.remaining_to_bakery if report else None, parent=self
)
if dialog.exec() != QDialog.Accepted:
return
try:
journal.add_bakery_payment(
self.ctx.vault,
dialog.amount.value_decimal(),
dialog.date.get_date(),
batch.id if batch else None,
dialog.note.text(),
)
except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc))
return
self.ctx.changed()
def toggle_closed(self) -> None:
report = self._selected_report()
if report is None:
return
journal.set_batch_closed(self.ctx.vault, report.batch.id, not report.batch.closed)
self.ctx.changed()
def delete(self) -> None:
report = self._selected_report()
if report is None:
return
confirm = QMessageBox.question(
self,
"Удалить партию?",
f"Удалить партию {report.label}?\n\n"
"Платежи пекарне останутся в учёте — они просто потеряют привязку.",
)
if confirm != QMessageBox.Yes:
return
try:
journal.delete_batch(self.ctx.vault, report.batch.id)
except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc))
return
self.ctx.changed()