"""Продажи и прочее выбытие: розница, друзья по себестоимости, съел сам.""" from __future__ import annotations from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QButtonGroup, QComboBox, QDialog, QFormLayout, QLineEdit, QMessageBox, QRadioButton, QVBoxLayout, QWidget, ) from .. import journal from .. import money as m from .. import salesfilter as sf from ..models import ( CONSUMPTION_KINDS, KIND_FRIEND, KIND_RETAIL, SALE_KIND_LABELS, SALE_KINDS, ) from . import theme from . import widgets as w # --- как рассчитались за продажу ----------------------------------------- # # Раньше это был просто денежный ввод, заполненный полной суммой. Долг из него # получался, если догадаться стереть число и вписать ноль, — а потом любая # правка строк молча возвращала полную оплату обратно. Явный выбор режима # делает «взял в долг» видимым вариантом, а не побочным эффектом. PAY_FULL = "full" PAY_DEBT = "debt" PAY_PART = "part" PAY_LABELS = { PAY_FULL: "Заплатил полностью", PAY_DEBT: "Взял в долг целиком", PAY_PART: "Заплатил часть", } def pay_mode_for(total, paid): """Каким режимом описывается эта пара сумм. Нужно, чтобы переключатель отражал набранное вручную: вписал ноль — загорается «взял в долг», вписал всю сумму — «заплатил полностью». """ if paid <= 0: return PAY_DEBT if paid >= total: return PAY_FULL return PAY_PART 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) # Выбор человека снимает предупреждение о долге без имени. self.counterparty.currentIndexChanged.connect(lambda _: self._update_payment_hint()) 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.tip = None self.paid_hint = None self.pay_mode = None self.pay_buttons = {} 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) # Оплату задаём только при создании: у существующей продажи платежи # живут своей жизнью и правятся на экране долгов. pay_form = QFormLayout() if sale is None: pay_form.addRow("Как рассчитались", self._pay_mode_row()) self.paid = w.MoneySpin(ctx.currency) # Переплату разбираем, когда человек закончил вводить сумму, # а не на каждом нажатии: иначе поле дёргалось бы посреди набора. self.paid.editingFinished.connect(self._on_paid_edited) # Без этого поле можно было обнулить, а подсказка продолжала # уверять, что оплачено полностью. self.paid.valueChanged.connect(lambda _: self._update_payment_hint()) pay_form.addRow("Получено сразу", self.paid) # Чаевые правятся и у записанной продажи: сдачу могли оставить # и после того, как продажу занесли. self.tip = w.MoneySpin(ctx.currency) if sale is not None: self.tip.set_decimal(sale.tip) self.tip.valueChanged.connect(lambda _: self._update_payment_hint()) pay_form.addRow("Чаевые", self.tip) layout.addLayout(pay_form) self.paid_hint = w.label("", "dim") self.paid_hint.setWordWrap(True) 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 _pay_mode_row(self) -> QWidget: self.pay_mode = PAY_FULL self.pay_group = QButtonGroup(self) buttons = [] for mode in (PAY_FULL, PAY_DEBT, PAY_PART): button = QRadioButton(PAY_LABELS[mode]) button.setChecked(mode == PAY_FULL) button.toggled.connect( lambda checked, value=mode: checked and self._set_pay_mode(value) ) self.pay_group.addButton(button) self.pay_buttons[mode] = button buttons.append(button) return w.row(*buttons, None) def _set_pay_mode(self, mode: str) -> None: """Переключили режим руками — подставить сумму под него.""" self.pay_mode = mode if self.paid is None: return if mode == PAY_FULL: self.paid.set_decimal(self.lines.total()) elif mode == PAY_DEBT: self.paid.set_decimal(0) else: self.paid.setFocus() self.paid.selectAll() self._update_payment_hint() def _show_pay_mode(self, mode: str) -> None: """Отразить режим в переключателе, не трогая суммы.""" self.pay_mode = mode button = self.pay_buttons.get(mode) if button is None or button.isChecked(): return button.blockSignals(True) button.setChecked(True) button.blockSignals(False) def _on_paid_edited(self) -> None: """Сумму вписали руками: разобрать переплату и показать, что вышло.""" overpaid = self._absorb_overpayment() if self.paid is not None: self._show_pay_mode(pay_mode_for(self.lines.total(), self.paid.value_decimal())) self._update_payment_hint(overpaid) def problem(self) -> str | None: """Что мешает записать продажу. None — всё в порядке. Отдельным методом, а не прямо в accept: проверка — это правило, и его должно быть видно тесту, а не только человеку в модальном окне. """ if self.current_debt() > 0 and not self.counterparty.currentData(): return ( f"В долг уходит {m.fmt_money(self.current_debt(), self.ctx.currency)}, " "но не указано кому.\n\nВыбери человека в поле «Кому» или заведи " "нового кнопкой рядом — иначе долг будет не за кем." ) return None def accept(self) -> None: """Не выпускать долг без имени. Такая запись невосстановима: на экране долгов она попадает в кучу «без контрагента», и с кого спрашивать деньги — уже не узнать. """ problem = self.problem() if problem: QMessageBox.warning(self, "Кому в долг?", problem) self.counterparty.setFocus() return super().accept() 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._show_pay_mode(PAY_FULL) self.paid.set_decimal(0) for button in self.pay_buttons.values(): button.setEnabled(not consumption) if self.tip is not None: self.tip.setEnabled(not consumption) if consumption: self.tip.set_decimal(0) self._sync_payment_default() def _sync_payment_default(self) -> None: """Пересчитать оплату вслед за строками. Только в режиме «заплатил полностью». Иначе правка количества затирала бы выставленный долг: вписал ноль, поправил строку — и продажа снова оплачена целиком, причём молча. """ if ( self.paid is not None and self.pay_mode == PAY_FULL and self.current_kind() not in CONSUMPTION_KINDS # Пока поле в фокусе, не перебиваем то, что человек набирает. and not self.paid.hasFocus() ): self.paid.set_decimal(self.lines.total()) self._update_payment_hint() def _absorb_overpayment(self): """Дали больше суммы — разница уходит в чаевые. Булка стоит 70, дали 100, сдачи не надо. Оставлять 100 в «получено» нельзя: столько за товар никто не должен, и продажа выглядела бы переплаченной вместо того, чтобы показать чаевые. """ if self.paid is None or self.tip is None: return m.ZERO total = self.lines.total() extra = m.money(self.paid.value_decimal() - total) if extra <= 0: return m.ZERO self.paid.set_decimal(total) self.tip.set_decimal(self.tip.value_decimal() + extra) return extra def current_debt(self): if self.paid is None: return m.ZERO return max(m.ZERO, m.money(self.lines.total() - self.paid.value_decimal())) def _update_payment_hint(self, overpaid=None) -> None: if self.paid_hint is None: return if self.current_kind() in CONSUMPTION_KINDS: self._say("Денег нет по определению, но себестоимость пекарне вернуть надо.") return currency = self.ctx.currency tip = self.tip.value_decimal() if self.tip is not None else m.ZERO debt = self.current_debt() nameless = debt > 0 and not self.counterparty.currentData() parts = [] if overpaid: parts.append(f"Переплата {m.fmt_money(overpaid, currency)} записана в чаевые.") if self.paid is not None: if debt > 0: # Долг без имени взыскать не с кого: на экране долгов он # окажется в куче «без контрагента» и там и останется. parts.append( f"В долг уйдёт {m.fmt_money(debt, currency)} — " "укажи, кому, иначе будет непонятно, с кого спрашивать." if nameless else f"В долг уйдёт {m.fmt_money(debt, currency)}. " "Долг появится на экране «Долги»." ) else: parts.append("Оплачено полностью.") if tip > 0: parts.append(f"Чаевые {m.fmt_money(tip, currency)} идут в покрытие партии и в прибыль.") self._say(" ".join(parts), theme.WARN if nameless else "") def _say(self, text: str, color: str = "") -> None: self.paid_hint.setText(text) self.paid_hint.setStyleSheet(f"color: {color};" if color else "") 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): COL_DATE, COL_KIND, COL_WHO, COL_WHAT, COL_TOTAL, COL_PAID, COL_TIP, COL_DEBT = range(8) 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.quick_entry), w.button("Изменить", on_click=self.edit), w.button("Принять оплату", on_click=self.take_payment), w.button("Удалить", "danger", self.delete), None, ) ) layout.addWidget(self._filter_bar()) self.table = w.table( ["Дата", "Тип", "Кому", "Что", "Сумма", "Оплачено", "Чаевые", "Долг"], stretch_column=3, ) self.table.doubleClicked.connect(self.edit) # Свежие продажи сверху. Индикатор в заголовке при этом соответствует # тому, что видно: без него сортировка «по умолчанию» ничем не помечена, # и первый клик по «Дате» кажется ничего не делающим. self.table.sortByColumn(self.COL_DATE, Qt.DescendingOrder) layout.addWidget(self.table, 1) self.summary = w.label("", "dim") layout.addWidget(self.summary) # --- фильтры --- def _filter_bar(self) -> QWidget: """Панель отбора. Всё, что её меняет, ведёт в один refresh.""" self.search = QLineEdit() self.search.setPlaceholderText("Поиск: товар, имя, заметка") self.search.setClearButtonEnabled(True) self.search.textChanged.connect(self.refresh) self.period = QComboBox() for key in sf.PERIODS: self.period.addItem(sf.PERIOD_LABELS[key], key) self.period.currentIndexChanged.connect(self._on_period_changed) # Свои даты нужны редко, а места занимают много — показываем их только # когда выбран собственный диапазон. self.since = w.DateInput() self.until = w.DateInput() for field in (self.since, self.until): field.dateChanged.connect(lambda _: self.refresh()) field.setVisible(False) self.kind = QComboBox() self.kind.addItem("Все типы", None) for kind in SALE_KINDS: self.kind.addItem(SALE_KIND_LABELS[kind], kind) self.kind.currentIndexChanged.connect(self.refresh) self.who = QComboBox() self.who.currentIndexChanged.connect(self.refresh) self._fill_counterparties() self.money = QComboBox() for state in sf.MONEY_STATES: self.money.addItem(sf.MONEY_LABELS[state], state) self.money.currentIndexChanged.connect(self.refresh) self.reset_button = w.button("Сбросить", on_click=self.reset_filters) return w.row( self.search, self.period, self.since, self.until, self.kind, self.who, self.money, self.reset_button, stretch_at=0, ) def _fill_counterparties(self) -> None: """Пересобрать список людей, сохранив выбранного. Контрагенты заводятся прямо из формы продажи, поэтому список живой. Восстанавливаем по идентификатору: под тем же номером после сортировки по алфавиту оказывается уже другой человек. """ previously = self.who.currentData() self.who.blockSignals(True) self.who.clear() self.who.addItem("Все люди", None) self.who.addItem("Без контрагента", sf.NO_COUNTERPARTY) for cp in sorted(self.ctx.vault.doc.counterparties, key=lambda c: c.name.lower()): self.who.addItem(cp.name, cp.id) index = self.who.findData(previously) if previously else 0 self.who.setCurrentIndex(max(0, index)) self.who.blockSignals(False) def _on_period_changed(self) -> None: custom = self.period.currentData() == sf.PERIOD_CUSTOM self.since.setVisible(custom) self.until.setVisible(custom) self.refresh() def current_filter(self) -> sf.SalesFilter: return sf.SalesFilter( text=self.search.text(), kind=self.kind.currentData(), counterparty_id=self.who.currentData(), period=self.period.currentData() or sf.PERIOD_ALL, since=self.since.get_date(), until=self.until.get_date(), money=self.money.currentData() or sf.MONEY_ANY, ) def reset_filters(self) -> None: for widget in (self.period, self.kind, self.who, self.money): widget.blockSignals(True) widget.setCurrentIndex(0) widget.blockSignals(False) self.search.blockSignals(True) self.search.clear() self.search.blockSignals(False) self._on_period_changed() # --- отрисовка --- def refresh(self) -> None: doc = self.ctx.vault.doc currency = self.ctx.currency self._fill_counterparties() active = self.current_filter() shown = sf.apply( doc, sorted(doc.sales, key=lambda s: s.date, reverse=True), active, self.ctx.report.today, ) rows, keys = [], [] for sale in shown: 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.date_item(sale.date), 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(sale.tip, currency) if sale.tip > 0 else "", sale.tip, theme.OK if sale.tip > 0 else "", ), 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) self.summary.setText( sf.summary_text(sf.totals(shown), len(doc.sales), currency) ) self.reset_button.setEnabled(not active.is_empty) # --- действия --- 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(), dialog.tip.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(), tip=dialog.tip.value_decimal(), ) 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()