"""Быстрый ввод продаж за период. Обычная форма продажи хороша, когда записываешь одну сделку. Когда надо внести историю за прошедший период, она превращается в пытку: на каждую продажу — открыть, заполнить, закрыть. Здесь каждая строка таблицы — отдельная продажа. Период задаётся сверху, дата новой строки наследуется от предыдущей, всё вводится с клавиатуры, а запись происходит одним действием в конце. """ from __future__ import annotations from datetime import date, timedelta from decimal import Decimal from PySide6.QtCore import Qt from PySide6.QtGui import QColor, QKeySequence, QShortcut from PySide6.QtWidgets import ( QComboBox, QDialog, QHeaderView, QMessageBox, QPushButton, QTableWidget, QVBoxLayout, ) from .. import journal from .. import money as m from ..models import CONSUMPTION_KINDS, SALE_KIND_LABELS, SALE_KINDS from . import theme from . import widgets as w from .page_sales import price_source_for DEFAULT_PERIOD_DAYS = 30 COL_DATE, COL_KIND, COL_WHO, COL_PRODUCT, COL_QTY, COL_UOM, COL_PRICE, COL_PAID, COL_TOTAL = range(9) HEADERS = ["Дата", "Тип", "Кому", "Товар", "Кол-во", "Фасовка", "Цена", "Оплачено", "Сумма"] # Пользователь поправил оплату руками — больше не подставляем её автоматически. TOUCHED = "paid_touched" class QuickSalesDialog(QDialog): def __init__(self, ctx, parent=None): super().__init__(parent) self.ctx = ctx self.products = [p for p in ctx.vault.doc.products if not p.archived] self.setWindowTitle("Быстрый ввод продаж") self.setMinimumSize(1120, 620) self.resize(1320, 700) layout = QVBoxLayout(self) layout.setContentsMargins(20, 16, 20, 16) layout.setSpacing(10) layout.addWidget(w.heading("Быстрый ввод продаж")) layout.addLayout(self._period_row()) self.hint = w.label("", "dim") self.hint.setWordWrap(True) layout.addWidget(self.hint) self.table = QTableWidget(0, len(HEADERS)) self.table.setHorizontalHeaderLabels(HEADERS) self.table.verticalHeader().setVisible(False) # Строки целиком из полей ввода: по содержимому Qt делает их слишком # низкими, и текст обрезается. self.table.verticalHeader().setDefaultSectionSize(36) header = self.table.horizontalHeader() header.setSectionResizeMode(COL_PRODUCT, QHeaderView.Stretch) # Ширины заданы руками: ResizeToContents схлопывает пустой выпадающий # список до нечитаемого огрызка. for column, width in ( (COL_DATE, 110), (COL_KIND, 200), (COL_WHO, 135), (COL_QTY, 85), (COL_UOM, 100), (COL_PRICE, 115), (COL_PAID, 115), (COL_TOTAL, 105), ): header.setSectionResizeMode(column, QHeaderView.Interactive) self.table.setColumnWidth(column, width) layout.addWidget(self.table, 1) self.total_label = w.label("", "h2") layout.addWidget( w.row( w.button("+ Строка (Enter)", on_click=self.add_row), w.button("Дублировать (Ctrl+D)", on_click=self.duplicate_row), w.button("Удалить строку", role="danger", on_click=self.remove_row), None, self.total_label, ) ) layout.addWidget( w.row( w.label("Записать всё — Ctrl+Enter", "dim"), None, w.button("Отмена", on_click=self.reject), w.button("Записать всё", "primary", self.accept), ) ) # Enter в диалоге по умолчанию нажимает кнопку — здесь он нужен для # добавления строки, поэтому автонажатие отключаем у всех кнопок. for widget in self.findChildren(QPushButton): widget.setAutoDefault(False) widget.setDefault(False) QShortcut(QKeySequence(Qt.Key_Return), self, activated=self.add_row) QShortcut(QKeySequence(Qt.Key_Enter), self, activated=self.add_row) QShortcut(QKeySequence("Ctrl+D"), self, activated=self.duplicate_row) QShortcut(QKeySequence("Ctrl+Return"), self, activated=self.accept) QShortcut(QKeySequence("Ctrl+Enter"), self, activated=self.accept) self._update_hint() if self.products: self.add_row() # --- период --- def _period_row(self): today = date.today() self.period_from = w.DateInput(today - timedelta(days=DEFAULT_PERIOD_DAYS)) self.period_to = w.DateInput(today) self.period_from.dateChanged.connect(self._update_hint) self.period_to.dateChanged.connect(self._update_hint) holder = QVBoxLayout() holder.addWidget( w.row( w.label("Вношу данные за период:"), self.period_from, w.label("—"), self.period_to, None, ) ) return holder def _update_hint(self) -> None: start, end = self.period_from.get_date(), self.period_to.get_date() if end < start: self.hint.setText("Конец периода раньше начала — поправь даты.") return # Показываем, что за период уже записано: дешёвая страховка от того, # чтобы не внести один и тот же месяц дважды. existing = [s for s in self.ctx.vault.doc.sales if start <= s.date <= end] already = ( f"За этот период уже записано продаж: {len(existing)}." if existing else "За этот период продаж ещё нет." ) self.hint.setText( f"{already} Дата новой строки наследуется от предыдущей — " "меняй её, когда переходишь к следующему дню." ) self._recalc() # --- строки --- def _last_row_state(self) -> dict: r = self.table.rowCount() - 1 if r < 0: return { "date": self.period_from.get_date(), "kind": SALE_KINDS[0], "who": "", "product_id": None, "qty": 1, "uom": "", "price": None, } return { "date": self.table.cellWidget(r, COL_DATE).get_date(), "kind": self.table.cellWidget(r, COL_KIND).currentData(), "who": self.table.cellWidget(r, COL_WHO).currentText(), "product_id": None, "qty": 1, "uom": "", "price": None, } def add_row(self, state: dict | None = None) -> None: if not self.products: return state = state or self._last_row_state() r = self.table.rowCount() self.table.insertRow(r) date_widget = w.DateInput(state["date"]) date_widget.dateChanged.connect(self._recalc) self.table.setCellWidget(r, COL_DATE, date_widget) kind = QComboBox() for value in SALE_KINDS: kind.addItem(SALE_KIND_LABELS[value], value) kind.setCurrentIndex(SALE_KINDS.index(state["kind"])) kind.currentIndexChanged.connect(lambda _, rr=r: self._on_kind_changed(rr)) self.table.setCellWidget(r, COL_KIND, kind) who = QComboBox() who.setEditable(True) who.setInsertPolicy(QComboBox.NoInsert) who.addItem("") for cp in sorted(self.ctx.vault.doc.counterparties, key=lambda c: c.name.lower()): who.addItem(cp.name) who.setCurrentText(state["who"]) who.setToolTip("Незнакомое имя будет заведено как новый контрагент.") self.table.setCellWidget(r, COL_WHO, who) product = w.ProductCombo(self.products) if state["product_id"]: product.select_product(state["product_id"]) product.currentIndexChanged.connect(lambda _, rr=r: self._on_product_changed(rr)) self.table.setCellWidget(r, COL_PRODUCT, product) qty = w.QtySpin() qty.set_decimal(state["qty"]) qty.valueChanged.connect(lambda _, rr=r: self._on_amount_changed(rr)) self.table.setCellWidget(r, COL_QTY, qty) uom = QComboBox() self.table.setCellWidget(r, COL_UOM, uom) self._fill_uoms(r, product.current_product_id(), state["uom"]) uom.currentIndexChanged.connect(lambda _, rr=r: self._on_uom_changed(rr)) price = w.MoneySpin(self.ctx.currency) self.table.setCellWidget(r, COL_PRICE, price) price.valueChanged.connect(lambda _, rr=r: self._on_amount_changed(rr)) paid = w.MoneySpin(self.ctx.currency) paid.setProperty(TOUCHED, False) paid.valueChanged.connect(lambda _, rr=r: self._on_paid_edited(rr)) self.table.setCellWidget(r, COL_PAID, paid) self.table.setItem(r, COL_TOTAL, w.text_item("")) self._apply_price(r) self._on_kind_changed(r) self.table.setCurrentCell(r, COL_PRODUCT) product.setFocus() def duplicate_row(self) -> None: r = self.table.currentRow() if r < 0: return self.add_row( { "date": self.table.cellWidget(r, COL_DATE).get_date(), "kind": self.table.cellWidget(r, COL_KIND).currentData(), "who": self.table.cellWidget(r, COL_WHO).currentText(), "product_id": self.table.cellWidget(r, COL_PRODUCT).current_product_id(), "qty": self.table.cellWidget(r, COL_QTY).value_decimal(), "uom": self.table.cellWidget(r, COL_UOM).currentText(), "price": self.table.cellWidget(r, COL_PRICE).value_decimal(), } ) def remove_row(self) -> None: r = self.table.currentRow() if r >= 0: self.table.removeRow(r) self._recalc() # --- реакции --- def _product(self, product_id): return next((p for p in self.products if p.id == product_id), None) def _fill_uoms(self, r: int, product_id, selected: str = "") -> None: combo = self.table.cellWidget(r, COL_UOM) product = self._product(product_id) names = product.uom_names() if product else [] combo.blockSignals(True) combo.clear() combo.addItems(names) if selected and selected in names: combo.setCurrentText(selected) combo.blockSignals(False) combo.setEnabled(len(names) > 1) def _apply_price(self, r: int) -> None: product = self._product(self.table.cellWidget(r, COL_PRODUCT).current_product_id()) if product is None: return kind = self.table.cellWidget(r, COL_KIND).currentData() uom = self.table.cellWidget(r, COL_UOM).currentText() self.table.cellWidget(r, COL_PRICE).set_decimal(price_source_for(kind)(product, uom)) def _on_product_changed(self, r: int) -> None: self._fill_uoms(r, self.table.cellWidget(r, COL_PRODUCT).current_product_id()) self._apply_price(r) self._on_amount_changed(r) def _on_uom_changed(self, r: int) -> None: self._apply_price(r) self._on_amount_changed(r) def _on_kind_changed(self, r: int) -> None: kind = self.table.cellWidget(r, COL_KIND).currentData() consumption = kind in CONSUMPTION_KINDS self._apply_price(r) paid = self.table.cellWidget(r, COL_PAID) paid.setEnabled(not consumption) self.table.cellWidget(r, COL_PRICE).setEnabled(not consumption) if consumption: paid.setProperty(TOUCHED, False) self._on_amount_changed(r) def _on_amount_changed(self, r: int) -> None: paid = self.table.cellWidget(r, COL_PAID) # Полная оплата — самый частый случай. Если её правили руками, больше # не трогаем: значит, там долг. if not paid.property(TOUCHED): paid.blockSignals(True) paid.set_decimal(self._row_total(r)) paid.blockSignals(False) self._recalc() def _on_paid_edited(self, r: int) -> None: self.table.cellWidget(r, COL_PAID).setProperty(TOUCHED, True) self._recalc() def _row_total(self, r: int) -> Decimal: kind = self.table.cellWidget(r, COL_KIND).currentData() if kind in CONSUMPTION_KINDS: return m.ZERO qty = self.table.cellWidget(r, COL_QTY).value_decimal() price = self.table.cellWidget(r, COL_PRICE).value_decimal() return m.money(qty * price) def _recalc(self) -> None: start, end = self.period_from.get_date(), self.period_to.get_date() total = m.ZERO outside = 0 for r in range(self.table.rowCount()): row_total = self._row_total(r) total += row_total item = self.table.item(r, COL_TOTAL) if item is not None: item.setText(m.fmt_money(row_total, self.ctx.currency)) item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) # Дата вне периода — не ошибка, но стоит показать: скорее всего опечатка. row_date = self.table.cellWidget(r, COL_DATE).get_date() off = not (start <= row_date <= end) outside += off self.table.cellWidget(r, COL_DATE).setStyleSheet( f"color: {theme.WARN};" if off else "" ) suffix = f" • вне периода: {outside}" if outside else "" self.total_label.setText( f"Строк: {self.table.rowCount()} • на {m.fmt_money(total, self.ctx.currency)}{suffix}" ) # --- запись --- def collect(self) -> list[dict]: """Собрать строки в описания продаж. Пустые строки просто пропускаются.""" result = [] for r in range(self.table.rowCount()): product_id = self.table.cellWidget(r, COL_PRODUCT).current_product_id() qty = self.table.cellWidget(r, COL_QTY).value_decimal() if not product_id or qty <= 0: continue kind = self.table.cellWidget(r, COL_KIND).currentData() consumption = kind in CONSUMPTION_KINDS price = m.ZERO if consumption else self.table.cellWidget(r, COL_PRICE).value_decimal() paid = m.ZERO if consumption else self.table.cellWidget(r, COL_PAID).value_decimal() result.append( { "date": self.table.cellWidget(r, COL_DATE).get_date(), "kind": kind, "who": self.table.cellWidget(r, COL_WHO).currentText().strip(), "line": { "product_id": product_id, "qty": qty, "uom": self.table.cellWidget(r, COL_UOM).currentText(), "unit_price": price, }, "paid": paid, } ) return result def accept(self) -> None: rows = self.collect() if not rows: QMessageBox.information(self, "Пусто", "Заполни хотя бы одну строку.") return try: created = write_sales(self.ctx.vault, rows) except journal.ValidationError as exc: QMessageBox.warning(self, "Не получилось", str(exc)) return self.created = created super().accept() def write_sales(vault, rows: list[dict]) -> int: """Записать собранные строки как отдельные продажи. Незнакомое имя в колонке «Кому» заводится как новый контрагент: при вводе истории останавливаться и создавать людей вручную — лишняя морока. """ known = {c.name.casefold(): c.id for c in vault.doc.counterparties} created = 0 for row in rows: counterparty_id = None name = row["who"] if name: key = name.casefold() if key not in known: known[key] = journal.create_counterparty(vault, name).id counterparty_id = known[key] journal.create_sale( vault, row["date"], row["kind"], [row["line"]], counterparty_id=counterparty_id, paid_amount=row["paid"], ) created += 1 return created