323 lines
12 KiB
Python
323 lines
12 KiB
Python
|
|
"""Продажи и прочее выбытие: розница, друзья по себестоимости, съел сам."""
|
|||
|
|
|
|||
|
|
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: product.cost_price
|
|||
|
|
if kind in CONSUMPTION_KINDS:
|
|||
|
|
return lambda product: m.ZERO
|
|||
|
|
return lambda product: product.retail_price
|
|||
|
|
|
|||
|
|
|
|||
|
|
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.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)}"
|
|||
|
|
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 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()
|