"""Закупки: партии булок, взятых в пекарне в долг, и их покрытие.""" from __future__ import annotations from datetime import timedelta from PySide6.QtCore import Qt from decimal import Decimal from PySide6.QtWidgets import ( QCheckBox, 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, uom: p.cost_for(uom), 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.ctx = ctx self.batch = batch self.owed = m.money(suggested or 0) self.setWindowTitle("Платёж пекарне") self.setMinimumWidth(430) 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) self.amount.valueChanged.connect(lambda _: self._update_hint()) form.addRow("Отдал", self.amount) self.note = QLineEdit() form.addRow("Заметка", self.note) layout.addLayout(form) # Пекарня может взять меньше, чем причиталось: надо было 5 500, взяли # 5 000. Долг при этом закрыт, но денег никто не отдавал — поэтому # остаток списывается отдельной записью, а не подгонкой суммы. self.write_off = QCheckBox("Остаток простили — закрыть партию") self.write_off.toggled.connect(lambda _: self._update_hint()) if batch is not None: layout.addWidget(self.write_off) self.hint = w.label("", "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._update_hint() @property def remainder(self) -> Decimal: """Сколько останется непогашенным после этого платежа.""" return max(m.ZERO, m.money(self.owed - self.amount.value_decimal())) def wants_write_off(self) -> bool: return ( self.batch is not None and self.write_off.isChecked() and self.remainder > 0 ) def _update_hint(self) -> None: currency = self.ctx.currency if self.batch is None: self.hint.setText("Без выбранной партии платёж закроет самые старые долги.") return left = self.remainder if left <= 0: self.hint.setText(f"Партия №{self.batch.number} закроется полностью.") return if self.write_off.isChecked(): self.hint.setText( f"Останется {m.fmt_money(left, currency)} — спишем как скидку пекарни. " "Партия закроется, а эти деньги попадут в прибыль и в статистику." ) else: self.hint.setText( f"По партии №{self.batch.number} останется долг " f"{m.fmt_money(left, currency)}." ) 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=9, ) 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.date_item(batch.date), w.date_item(batch.due_date), 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.discount, currency) if report.discount else "", report.discount, theme.OK if report.discount else "", ), 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.discount > 0: parts.append(f"пекарня простила {m.fmt_money(report.discount, 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: if dialog.amount.value_decimal() > 0: journal.add_bakery_payment( self.ctx.vault, dialog.amount.value_decimal(), dialog.date.get_date(), batch.id if batch else None, dialog.note.text(), ) if dialog.wants_write_off(): journal.write_off_batch( self.ctx.vault, batch.id, dialog.remainder, dialog.date.get_date(), dialog.note.text() or "остаток простили", ) 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()