food-market/tests/test_quick_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

298 lines
11 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_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"]