Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
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-23 01:25:39 +07:00
|
|
|
|
"""Быстрый ввод продаж за период."""
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
Дробные количества: пол-литра сока, 1,125 кг сыра
Количества и раньше хранились в Decimal с тремя знаками, но до документа
они не доходили: поля ввода отвергали точку. Интерфейс русский, локаль
ждёт запятую, а на цифровой клавиатуре клавиша даёт точку — символ молча
не появлялся, и выглядело это так, будто дробные вводить нельзя вообще.
Теперь оба разделителя принимаются, пробелы-разделители разрядов тоже.
Заодно исправлена ошибка, которую дробные вскрыли: в сводке складывалось
общее количество остатка по всем товарам, то есть литры с килограммами
и штуками. Теперь остаток выражен деньгами и числом позиций, а разбивка
по единицам осталась в таблице «Остатки».
Фасовки тоже могут быть дробными: бутылка 1,5 л.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:20:29 +07:00
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
|
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
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-23 01:25:39 +07:00
|
|
|
|
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"]
|