food-market/app/ui/page_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

341 lines
13 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 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)
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)
# Оплату задаём только при создании: у существующей продажи платежи
# живут своей жизнью и правятся на экране долгов.
self.paid = None
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()