"""Быстрый ввод продаж за период.""" 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"]