food-market/app/ui/quick_sales.py
Claude efce552e2b Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.

1. База и код разъехались по разным репозиториям. Автокоммит и раньше
   трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
   поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
   клон food-records со своим .git, а исходники его игнорируют целиком.
   При первом запуске приложение встаёт на уже существующую историю
   сервера, а не заводит параллельную.

2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
   за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
   считаются в базовых единицах, поэтому поштучные продажи вычитаются из
   купленных пачек. Размер фасовки хранится в документе слепком.
   Миграция схемы 1→2 не меняет поведение уже заведённых данных.

3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
   отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
   заводится как контрагент, повтор периода подсвечивается.

Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:25:39 +03:00

444 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Быстрый ввод продаж за период.
Обычная форма продажи хороша, когда записываешь одну сделку. Когда надо внести
историю за прошедший период, она превращается в пытку: на каждую продажу —
открыть, заполнить, закрыть.
Здесь каждая строка таблицы — отдельная продажа. Период задаётся сверху, дата
новой строки наследуется от предыдущей, всё вводится с клавиатуры, а запись
происходит одним действием в конце.
"""
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