food-market/tests/test_quick_sales.py
Claude 26b401e507 Цена по фасовке не подставлялась после удаления строки
Обработчики строк запоминали номер строки в момент создания. После
удаления любой строки всё, что было ниже, съезжает вверх, и запомненный
номер начинает указывать на соседа или за пределы таблицы. Строка молча
переставала работать целиком: выбираешь фасовку — цена не меняется,
меняешь товар — не пересобирается список фасовок. Приходилось править
цену руками.

Теперь обработчики привязаны к самому виджету, а строка ищется по нему
в момент вызова. То же исправлено в быстром вводе, где строк больше
и удаляют их чаще.

Заодно найдена мина, которую посадил я сам в разделе статистики:
self.metric = QComboBox() на QWidget затеняет метод QWidget.metric(),
который Qt зовёт при смене стиля. Падало это в чужом месте и с невнятным
«object is not callable». Такая же история была с self.layout в сводке.
Оба переименованы, добавлен тест, который обходит все виджеты и проверяет,
что ни один атрибут не затеняет метод Qt.

И подсказка в карточке товара: себестоимость фасовки нигде не вводится,
она выводится из цены базовой единицы. Теперь под таблицей фасовок прямо
написано, какая сумма подставится в закупку.

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

391 lines
14 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_row_keeps_working_after_an_earlier_row_is_deleted(ctx, qapp):
"""Строки не должны держаться за свой номер — он устаревает при удалении."""
from app.ui.quick_sales import COL_PRICE, COL_UOM
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)
d.add_row()
fill(d, 1, cookies.id, 1)
d.table.setCurrentCell(0, 0)
d.remove_row()
assert d.table.rowCount() == 1
d.table.cellWidget(0, COL_UOM).setCurrentText("пачка")
assert d.table.cellWidget(0, COL_PRICE).value_decimal() == Decimal("200.00")
assert d.collect()[0]["line"]["uom"] == "пачка"
d.deleteLater()
def test_kind_switch_still_works_after_deletion(ctx, qapp, buns):
from app.models import SALE_KINDS
from app.ui.quick_sales import COL_KIND, COL_PRICE
d = QuickSalesDialog(ctx)
d.add_row()
fill(d, 1, buns["мак"].id, 1)
d.table.setCurrentCell(0, 0)
d.remove_row()
d.table.cellWidget(0, COL_KIND).setCurrentIndex(SALE_KINDS.index(KIND_FRIEND))
assert d.table.cellWidget(0, COL_PRICE).value_decimal() == Decimal("20.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"]