food-records/app/ui/page_batches.py

364 lines
13 KiB
Python
Raw Normal View History

"""Закупки: партии булок, взятых в пекарне в долг, и их покрытие."""
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()