food-market/tests/test_quick_sales.py
Claude 582f041dba Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела
бы закрытой, когда за булки ещё должны.

Ввёл оплату больше суммы — булка 70, дали 100 — разница уходит в чаевые
сама. Разбор делается по окончании ввода, а не на каждом нажатии, иначе
поле дёргалось бы посреди набора. То же при приёме оплаты по долгу:
заплатили больше, чем оставалось, — излишек становится чаевыми, а не
теряется, как было раньше.

В расчётах чаевые идут в покрытие партии (это живые деньги, ими так же
рассчитываются с пекарней) и целиком добавляются в прибыль, но не входят
в выручку и не создают долга. Продажа, списанная с нескольких партий,
делит чаевые между ними пропорционально выручке.

По съеденному и подаренному чаевых не бывает — поле отключается.

Миграция схемы 2→3 проставляет ноль: у кого чаевых нет, тот появления
этой фичи не заметит.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:56:26 +03:00

354 lines
12 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.

"""Быстрый ввод продаж за период."""
import os
from datetime import date, timedelta
from decimal import Decimal
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication # noqa: E402
from app import journal, ledger # noqa: E402
from app.models import KIND_FRIEND, KIND_RETAIL, KIND_SELF, SALE_KINDS # noqa: E402
from app.ui import theme # noqa: E402
from app.ui.quick_sales import ( # noqa: E402
COL_DATE,
COL_KIND,
COL_PAID,
COL_PRICE,
COL_PRODUCT,
COL_QTY,
COL_UOM,
COL_WHO,
QuickSalesDialog,
write_sales,
)
@pytest.fixture(scope="session")
def qapp():
app = QApplication.instance() or QApplication([])
theme.apply(app)
return app
class FakeCtx:
"""Минимальный контекст: диалогу нужны только база, валюта и уведомление."""
def __init__(self, vault):
self.vault = vault
self.report = ledger.build(vault.doc)
self.changed_calls = 0
@property
def currency(self):
return self.vault.doc.settings.currency
def changed(self):
self.report = ledger.build(self.vault.doc)
self.changed_calls += 1
@pytest.fixture
def ctx(qapp, vault, buns):
return FakeCtx(vault)
@pytest.fixture
def dialog(ctx):
d = QuickSalesDialog(ctx)
yield d
d.deleteLater()
def fill(dialog, row, product_id, qty, kind=None, who=None, on_date=None, uom=None, paid=None):
if on_date is not None:
dialog.table.cellWidget(row, COL_DATE).set_date(on_date)
if kind is not None:
dialog.table.cellWidget(row, COL_KIND).setCurrentIndex(SALE_KINDS.index(kind))
if who is not None:
dialog.table.cellWidget(row, COL_WHO).setCurrentText(who)
dialog.table.cellWidget(row, COL_PRODUCT).select_product(product_id)
if uom is not None:
dialog.table.cellWidget(row, COL_UOM).setCurrentText(uom)
dialog.table.cellWidget(row, COL_QTY).set_decimal(qty)
if paid is not None:
dialog.table.cellWidget(row, COL_PAID).set_decimal(paid)
# --- период ---------------------------------------------------------------
def test_period_defaults_to_the_last_month(dialog):
span = dialog.period_to.get_date() - dialog.period_from.get_date()
assert span == timedelta(days=30)
assert dialog.period_to.get_date() == date.today()
def test_period_is_freely_adjustable(dialog):
"""Период не обязан быть месяцем — его задаёт пользователь."""
dialog.period_from.set_date(date(2026, 3, 1))
dialog.period_to.set_date(date(2026, 3, 9))
assert (dialog.period_to.get_date() - dialog.period_from.get_date()).days == 8
def test_hint_warns_about_already_entered_sales(ctx, buns, qapp):
"""Страховка от того, чтобы не внести один и тот же период дважды."""
journal.create_sale(
ctx.vault, date.today() - timedelta(days=3), KIND_RETAIL,
[{"product_id": buns["мак"].id, "qty": 1, "unit_price": 35}],
)
d = QuickSalesDialog(ctx)
assert "уже записано продаж: 1" in d.hint.text()
d.deleteLater()
def test_first_row_starts_at_the_period_beginning(dialog):
assert dialog.table.cellWidget(0, COL_DATE).get_date() == dialog.period_from.get_date()
def test_dates_outside_the_period_are_counted(dialog, buns):
fill(dialog, 0, buns["мак"].id, 1, on_date=date(2020, 1, 1))
assert "вне периода: 1" in dialog.total_label.text()
# --- строки ---------------------------------------------------------------
def test_new_row_inherits_date_and_kind(dialog, buns):
when = date.today() - timedelta(days=5)
fill(dialog, 0, buns["мак"].id, 1, kind=KIND_FRIEND, on_date=when)
dialog.add_row()
assert dialog.table.cellWidget(1, COL_DATE).get_date() == when
assert dialog.table.cellWidget(1, COL_KIND).currentData() == KIND_FRIEND
# Товар не наследуется: следующая продажа почти всегда другая.
assert dialog.table.rowCount() == 2
def test_duplicate_copies_the_whole_row(dialog, buns):
fill(dialog, 0, buns["повидло"].id, 7, kind=KIND_RETAIL, who="Вася")
dialog.table.setCurrentCell(0, COL_PRODUCT)
dialog.duplicate_row()
assert dialog.table.cellWidget(1, COL_PRODUCT).current_product_id() == buns["повидло"].id
assert dialog.table.cellWidget(1, COL_QTY).value_decimal() == Decimal("7.000")
assert dialog.table.cellWidget(1, COL_WHO).currentText() == "Вася"
def test_remove_row(dialog, buns):
dialog.add_row()
dialog.table.setCurrentCell(1, COL_PRODUCT)
dialog.remove_row()
assert dialog.table.rowCount() == 1
# --- цены и оплата --------------------------------------------------------
def test_price_follows_the_kind(dialog, buns):
fill(dialog, 0, buns["мак"].id, 1, kind=KIND_RETAIL)
assert dialog.table.cellWidget(0, COL_PRICE).value_decimal() == Decimal("35.00")
fill(dialog, 0, buns["мак"].id, 1, kind=KIND_FRIEND)
assert dialog.table.cellWidget(0, COL_PRICE).value_decimal() == Decimal("20.00")
def test_consumption_disables_money(dialog, buns):
fill(dialog, 0, buns["мак"].id, 2, kind=KIND_SELF)
assert dialog.table.cellWidget(0, COL_PRICE).isEnabled() is False
assert dialog.table.cellWidget(0, COL_PAID).isEnabled() is False
assert dialog._row_total(0) == 0
def test_paid_follows_the_total_until_edited(dialog, buns):
fill(dialog, 0, buns["мак"].id, 3)
assert dialog.table.cellWidget(0, COL_PAID).value_decimal() == Decimal("105.00")
dialog.table.cellWidget(0, COL_PAID).set_decimal(50)
dialog.table.cellWidget(0, COL_QTY).set_decimal(4)
# Оплату правили руками — значит, там долг, и подставлять её больше нельзя.
assert dialog.table.cellWidget(0, COL_PAID).value_decimal() == Decimal("50.00")
def test_running_total(dialog, buns):
fill(dialog, 0, buns["мак"].id, 2) # 70
dialog.add_row()
fill(dialog, 1, buns["повидло"].id, 1) # 50
assert "120,00 ₽" in dialog.total_label.text()
def test_pack_selection_available(ctx, qapp):
cookies = journal.create_product(ctx.vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
journal.set_packs(ctx.vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
d = QuickSalesDialog(ctx)
fill(d, 0, cookies.id, 2, uom="пачка")
assert d.table.cellWidget(0, COL_PRICE).value_decimal() == Decimal("200.00")
assert d._row_total(0) == Decimal("400.00")
d.deleteLater()
# --- запись ---------------------------------------------------------------
def test_writes_every_row_as_its_own_sale(dialog, ctx, buns):
day = date.today() - timedelta(days=10)
fill(dialog, 0, buns["мак"].id, 3, on_date=day)
dialog.add_row()
fill(dialog, 1, buns["повидло"].id, 2, on_date=day + timedelta(days=1))
created = write_sales(ctx.vault, dialog.collect())
assert created == 2
sales = sorted(ctx.vault.doc.sales, key=lambda s: s.date)
assert [s.date for s in sales] == [day, day + timedelta(days=1)]
assert sales[0].total == Decimal("105.00")
assert sales[1].total == Decimal("100.00")
def test_empty_rows_are_skipped(dialog, ctx, buns):
fill(dialog, 0, buns["мак"].id, 1)
dialog.add_row()
dialog.table.cellWidget(1, COL_QTY).set_decimal(0)
assert len(dialog.collect()) == 1
def test_unknown_person_becomes_a_counterparty(dialog, ctx, buns):
"""При вводе истории останавливаться и заводить людей вручную — морока."""
fill(dialog, 0, buns["мак"].id, 5, kind=KIND_FRIEND, who="Николай", paid=0)
write_sales(ctx.vault, dialog.collect())
names = [c.name for c in ctx.vault.doc.counterparties]
assert names == ["Николай"]
assert ctx.vault.doc.sales[0].counterparty_id == ctx.vault.doc.counterparties[0].id
def test_existing_person_is_reused_case_insensitively(dialog, ctx, buns):
vasya = journal.create_counterparty(ctx.vault, "Вася")
fill(dialog, 0, buns["мак"].id, 1, who="вася")
write_sales(ctx.vault, dialog.collect())
assert len(ctx.vault.doc.counterparties) == 1
assert ctx.vault.doc.sales[0].counterparty_id == vasya.id
def test_same_person_twice_creates_one_counterparty(dialog, ctx, buns):
fill(dialog, 0, buns["мак"].id, 1, who="Петя")
dialog.add_row()
fill(dialog, 1, buns["повидло"].id, 1, who="Петя")
write_sales(ctx.vault, dialog.collect())
assert len(ctx.vault.doc.counterparties) == 1
def test_fractional_quantities_in_bulk_entry(ctx, qapp):
"""Розлив вносится историей так же, как штучный товар."""
juice = journal.create_product(ctx.vault, "Сок", unit="л", cost_price=80, retail_price=120)
d = QuickSalesDialog(ctx)
for row, qty in enumerate(["0.5", "1.5", "0.25"]):
if row:
d.add_row()
fill(d, row, juice.id, qty)
rows = d.collect()
assert [r["line"]["qty"] for r in rows] == [
Decimal("0.500"), Decimal("1.500"), Decimal("0.250")
]
assert "270,00 ₽" in d.total_label.text() # (0,5 + 1,5 + 0,25) × 120
write_sales(ctx.vault, rows)
assert sum(s.total for s in ctx.vault.doc.sales) == Decimal("270.00")
d.deleteLater()
def test_tips_in_bulk_entry(dialog, ctx, buns):
from app.ui.quick_sales import COL_TIP
fill(dialog, 0, buns["мак"].id, 2) # 70
dialog.table.cellWidget(0, COL_TIP).set_decimal(30)
assert "чаевых 30,00 ₽" in dialog.total_label.text()
write_sales(ctx.vault, dialog.collect())
sale = ctx.vault.doc.sales[0]
assert sale.total == Decimal("70.00")
assert sale.tip == Decimal("30.00")
assert sale.debt == 0
def test_tips_are_not_inherited_by_the_next_row(dialog, buns):
"""Чаевые — разовое событие, тянуть их в следующую строку нельзя."""
from app.ui.quick_sales import COL_TIP
fill(dialog, 0, buns["мак"].id, 1)
dialog.table.cellWidget(0, COL_TIP).set_decimal(50)
dialog.add_row()
assert dialog.table.cellWidget(1, COL_TIP).value_decimal() == 0
def test_consumption_row_clears_the_tip(dialog, buns):
from app.ui.quick_sales import COL_TIP
fill(dialog, 0, buns["мак"].id, 1)
dialog.table.cellWidget(0, COL_TIP).set_decimal(50)
fill(dialog, 0, buns["мак"].id, 1, kind=KIND_SELF)
assert dialog.table.cellWidget(0, COL_TIP).isEnabled() is False
assert dialog.collect()[0]["tip"] == 0
def test_partial_payment_becomes_a_debt(dialog, ctx, buns):
fill(dialog, 0, buns["мак"].id, 4, paid=100) # всего 140
write_sales(ctx.vault, dialog.collect())
assert ctx.vault.doc.sales[0].debt == Decimal("40.00")
def test_backdated_sales_land_in_the_right_batch(dialog, ctx, buns):
"""Ради этого экрана всё и делалось: история должна ложиться на партии."""
today = date.today()
batch = journal.create_batch(
ctx.vault,
today - timedelta(days=20),
today + timedelta(days=5),
[{"product_id": buns["мак"].id, "qty": 30, "unit_cost": 20}],
)
for row in range(3):
if row:
dialog.add_row()
fill(dialog, row, buns["мак"].id, 4, on_date=today - timedelta(days=15 - row))
write_sales(ctx.vault, dialog.collect())
report = ledger.build(ctx.vault.doc, today).batch_report(batch.id)
assert report.qty_sold == Decimal("12.000")
assert report.cash_collected == Decimal("420.00") # 12 × 35
assert report.qty_left == Decimal("18.000")
def test_writing_history_produces_no_shortfall_warnings(dialog, ctx, buns):
today = date.today()
journal.create_batch(
ctx.vault, today - timedelta(days=20), today + timedelta(days=5),
[{"product_id": buns["мак"].id, "qty": 30, "unit_cost": 20}],
)
fill(dialog, 0, buns["мак"].id, 10, on_date=today - timedelta(days=15))
write_sales(ctx.vault, dialog.collect())
assert ledger.build(ctx.vault.doc, today).warnings == []
def test_every_written_sale_is_journalled(dialog, ctx, buns):
fill(dialog, 0, buns["мак"].id, 1)
dialog.add_row()
fill(dialog, 1, buns["повидло"].id, 1)
before = len(ctx.vault.doc.journal)
write_sales(ctx.vault, dialog.collect())
actions = [e.action for e in ctx.vault.doc.journal[before:]]
assert actions == ["sale.create", "sale.create"]