"""Продажи и прочее выбытие: розница, друзья по себестоимости, съел сам.""" from __future__ import annotations from PySide6.QtWidgets import ( QComboBox, QDialog, QFormLayout, QLineEdit, QMessageBox, QVBoxLayout, QWidget, ) from .. import journal from .. import money as m from ..models import ( CONSUMPTION_KINDS, KIND_FRIEND, KIND_RETAIL, SALE_KIND_LABELS, SALE_KINDS, ) from . import theme from . import widgets as w def price_source_for(kind: str): """Откуда берётся цена по умолчанию для этого типа выбытия и фасовки. Ради этого типы и заведены: друг платит себестоимость, съеденное не стоит ничего, розница идёт по цене продажи. Цена всегда за одну штуку выбранной фасовки — пачка стоит своих денег, а не десяти поштучных цен. """ if kind == KIND_FRIEND: return lambda product, uom: product.cost_for(uom) if kind in CONSUMPTION_KINDS: return lambda product, uom: m.ZERO return lambda product, uom: product.retail_for(uom) class SaleDialog(QDialog): def __init__(self, ctx, sale=None, parent=None): super().__init__(parent) self.ctx = ctx self.sale = sale self.setWindowTitle("Продажа" if sale else "Новая продажа") self.setMinimumSize(640, 520) layout = QVBoxLayout(self) layout.setContentsMargins(20, 16, 20, 16) layout.setSpacing(10) form = QFormLayout() form.setSpacing(8) self.date = w.DateInput(sale.date if sale else None) form.addRow("Дата", self.date) self.kind = QComboBox() for kind in SALE_KINDS: self.kind.addItem(SALE_KIND_LABELS[kind], kind) self.kind.setCurrentIndex(SALE_KINDS.index(sale.kind) if sale else 0) self.kind.currentIndexChanged.connect(self._on_kind_changed) form.addRow("Тип", self.kind) self.counterparty = QComboBox() self.counterparty.addItem("— не указан —", None) for cp in sorted(ctx.vault.doc.counterparties, key=lambda c: c.name.lower()): self.counterparty.addItem(cp.name, cp.id) if sale and sale.counterparty_id: index = self.counterparty.findData(sale.counterparty_id) if index >= 0: self.counterparty.setCurrentIndex(index) form.addRow("Кому", w.row(self.counterparty, w.button("+ Новый", on_click=self._add_counterparty), stretch_at=0)) self.note = QLineEdit(sale.note if sale else "") form.addRow("Заметка", self.note) layout.addLayout(form) # Поля оплаты создаются ниже, но редактор строк начинает слать сигналы # сразу при заполнении. Без этих заглушек первый же сигнал приходит # в обработчик, для которого self.paid ещё не существует, — Qt съедает # исключение, и подстановка оплаты молча не работает. self.paid = None self.paid_hint = None layout.addWidget(w.label("Что ушло", "h2")) products = [p for p in ctx.vault.doc.products if not p.archived] self.lines = w.LinesEditor( products, "Цена", price_source_for(self.current_kind()), ctx.currency ) self.lines.changed.connect(self._sync_payment_default) if sale: self.lines.set_lines(sale.lines, "unit_price") elif products: self.lines.add_line() layout.addWidget(self.lines, 1) # Оплату задаём только при создании: у существующей продажи платежи # живут своей жизнью и правятся на экране долгов. if sale is None: pay_form = QFormLayout() self.paid = w.MoneySpin(ctx.currency) pay_form.addRow("Оплачено сразу", self.paid) layout.addLayout(pay_form) self.paid_hint = w.label("", "dim") layout.addWidget(self.paid_hint) layout.addWidget( w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сохранить", "primary", self.accept)) ) self._on_kind_changed() def current_kind(self) -> str: return self.kind.currentData() def _on_kind_changed(self) -> None: kind = self.current_kind() self.lines.price_source = price_source_for(kind) self.lines.refresh_prices() consumption = kind in CONSUMPTION_KINDS if self.paid is not None: self.paid.setEnabled(not consumption) if consumption: self.paid.set_decimal(0) self._sync_payment_default() def _sync_payment_default(self) -> None: if self.paid is None: return kind = self.current_kind() if kind in CONSUMPTION_KINDS: self.paid_hint.setText("Денег нет по определению, но себестоимость пекарне вернуть надо.") return total = self.lines.total() # Полная оплата — самый частый случай, поэтому она и подставляется. # Уменьшил сумму — получил долг, который поедет на экран «Долги». if not self.paid.hasFocus(): self.paid.set_decimal(total) debt = m.money(total - self.paid.value_decimal()) if debt > 0: self.paid_hint.setText(f"В долг уйдёт {m.fmt_money(debt, self.ctx.currency)}.") else: self.paid_hint.setText("Оплачено полностью.") def _add_counterparty(self) -> None: from PySide6.QtWidgets import QInputDialog name, ok = QInputDialog.getText(self, "Новый контрагент", "Имя") if not ok or not name.strip(): return try: cp = journal.create_counterparty(self.ctx.vault, name) except journal.ValidationError as exc: QMessageBox.warning(self, "Не получилось", str(exc)) return self.counterparty.addItem(cp.name, cp.id) self.counterparty.setCurrentIndex(self.counterparty.count() - 1) class SalesPage(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.filter = QComboBox() self.filter.addItem("Все типы", None) for kind in SALE_KINDS: self.filter.addItem(SALE_KIND_LABELS[kind], kind) self.filter.currentIndexChanged.connect(self.refresh) layout.addWidget( w.row( w.button("+ Продажа", "primary", self.create), w.button("Быстрый ввод за период", on_click=self.quick_entry), w.button("Изменить", on_click=self.edit), w.button("Принять оплату", on_click=self.take_payment), w.button("Удалить", "danger", self.delete), None, self.filter, ) ) self.table = w.table( ["Дата", "Тип", "Кому", "Что", "Сумма", "Оплачено", "Долг"], stretch_column=3 ) self.table.doubleClicked.connect(self.edit) layout.addWidget(self.table, 1) # --- отрисовка --- def refresh(self) -> None: doc = self.ctx.vault.doc currency = self.ctx.currency wanted = self.filter.currentData() rows, keys = [], [] for sale in sorted(doc.sales, key=lambda s: s.date, reverse=True): if wanted and sale.kind != wanted: continue what = ", ".join( f"{doc.product_name(line.product_id)} × {m.fmt_qty(line.qty)} " f"{line.unit_name(doc.product(line.product_id))}" for line in sale.lines ) debt = sale.debt rows.append( [ w.text_item(sale.date.strftime("%d.%m.%Y")), w.text_item( SALE_KIND_LABELS.get(sale.kind, sale.kind), theme.MUTED if sale.is_consumption else "", ), w.text_item(doc.counterparty_name(sale.counterparty_id)), w.text_item(what), w.sortable_num_item(m.fmt_money(sale.total, currency), sale.total), w.sortable_num_item(m.fmt_money(sale.paid, currency), sale.paid), w.sortable_num_item( m.fmt_money(debt, currency) if debt > 0 else "", debt, theme.WARN if debt > 0 else "", ), ] ) keys.append(sale.id) w.fill(self.table, rows, keys) # --- действия --- def _selected(self): sale_id = w.selected_key(self.table) sale = self.ctx.vault.doc.sale(sale_id) if sale_id else None if sale is None: QMessageBox.information(self, "Не выбрано", "Выбери продажу в списке.") return sale def create(self) -> None: if not [p for p in self.ctx.vault.doc.products if not p.archived]: QMessageBox.information(self, "Нет товаров", "Сначала заведи булки на вкладке «Товары».") return dialog = SaleDialog(self.ctx, parent=self) if dialog.exec() != QDialog.Accepted: return try: journal.create_sale( self.ctx.vault, dialog.date.get_date(), dialog.current_kind(), dialog.lines.lines("unit_price"), dialog.counterparty.currentData(), dialog.note.text(), dialog.paid.value_decimal(), ) except journal.ValidationError as exc: QMessageBox.warning(self, "Не получилось", str(exc)) return self.ctx.changed() def quick_entry(self) -> None: """Внести пачку продаж за прошедший период.""" if not [p for p in self.ctx.vault.doc.products if not p.archived]: QMessageBox.information(self, "Нет товаров", "Сначала заведи булки на вкладке «Товары».") return from .quick_sales import QuickSalesDialog dialog = QuickSalesDialog(self.ctx, parent=self) if dialog.exec() != QDialog.Accepted: return self.ctx.changed() QMessageBox.information(self, "Готово", f"Записано продаж: {dialog.created}.") def edit(self) -> None: sale = self._selected() if sale is None: return dialog = SaleDialog(self.ctx, sale, parent=self) if dialog.exec() != QDialog.Accepted: return try: journal.update_sale( self.ctx.vault, sale.id, dialog.date.get_date(), dialog.current_kind(), dialog.lines.lines("unit_price"), dialog.counterparty.currentData() or "", dialog.note.text(), ) except journal.ValidationError as exc: QMessageBox.warning(self, "Не получилось", str(exc)) return self.ctx.changed() def take_payment(self) -> None: sale = self._selected() if sale is None: return if sale.debt <= 0: QMessageBox.information(self, "Долга нет", "Эта продажа уже оплачена.") return from .page_debts import ask_payment 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 delete(self) -> None: sale = self._selected() if sale is None: return confirm = QMessageBox.question( self, "Удалить продажу?", "Товар вернётся в остатки, деньги — исчезнут из учёта." ) if confirm != QMessageBox.Yes: return try: journal.delete_sale(self.ctx.vault, sale.id) except journal.ValidationError as exc: QMessageBox.warning(self, "Не получилось", str(exc)) return self.ctx.changed()