food-market/tests/test_ledger.py

437 lines
19 KiB
Python
Raw Normal View History

from datetime import date, timedelta
from decimal import Decimal
import pytest
from app import journal, ledger
from app.models import KIND_FRIEND, KIND_GIFT, KIND_RETAIL, KIND_SELF
def line(product, qty, price):
return {"product_id": product.id, "qty": qty, "unit_price": price}
def buy(product, qty, cost):
return {"product_id": product.id, "qty": qty, "unit_cost": cost}
# --- Сценарий из постановки задачи ----------------------------------------
@pytest.fixture
def scenario(vault, buns, today, in_two_weeks):
"""Ровно то, что описал пользователь.
Пришёл в пекарню, взял 5 булок с повидлом, 10 с маком, 20 с корицей.
Часть продал, часть отдал другу в долг по себестоимости, пару съел сам.
"""
batch = journal.create_batch(
vault,
today,
in_two_weeks,
[buy(buns["повидло"], 5, 30), buy(buns["мак"], 10, 20), buy(buns["корица"], 20, 25)],
)
vasya = journal.create_counterparty(vault, "Вася")
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["повидло"], 3, 50)])
journal.create_sale(
vault, today, KIND_FRIEND, [line(buns["мак"], 5, 20)],
counterparty_id=vasya.id, paid_amount=0,
)
journal.create_sale(vault, today, KIND_SELF, [line(buns["корица"], 2, 0)])
return {"vault": vault, "batch": batch, "vasya": vasya, "buns": buns}
def test_batch_debt_is_the_purchase_cost(scenario, today):
report = ledger.build(scenario["vault"].doc, today)
batch = report.batch_report(scenario["batch"].id)
# 5×30 + 10×20 + 20×25
assert batch.cost_total == Decimal("850.00")
assert batch.remaining_to_bakery == Decimal("850.00")
def test_only_actually_received_money_counts_as_coverage(scenario, today):
"""Долг Васи не покрывает партию: пекарне нужны деньги, а не начисления."""
report = ledger.build(scenario["vault"].doc, today)
batch = report.batch_report(scenario["batch"].id)
assert batch.revenue == Decimal("250.00") # 150 продажа + 100 долг Васи
assert batch.cash_collected == Decimal("150.00")
assert batch.still_to_collect == Decimal("700.00")
assert batch.coverage_pct == Decimal("17.65")
def test_self_consumed_buns_are_a_loss_against_margin(scenario, today):
summary = ledger.build(scenario["vault"].doc, today).summary
assert summary.gross_margin == Decimal("60.00") # 3 × (50 30)
assert summary.consumed_cost == Decimal("50.00") # 2 корицы × 25
assert summary.profit == Decimal("10.00")
def test_stock_and_receivables(scenario, today):
report = ledger.build(scenario["vault"].doc, today)
batch = report.batch_report(scenario["batch"].id)
assert batch.qty_sold == Decimal("10.000")
assert batch.qty_left == Decimal("25.000")
# 2×30 + 5×20 + 18×25
assert report.summary.stock_cost == Decimal("610.00")
assert report.summary.receivable == Decimal("100.00")
assert [(d.name, d.debt) for d in report.debtors] == [("Вася", Decimal("100.00"))]
def test_paying_the_bakery_closes_the_batch(scenario, today):
vault = scenario["vault"]
journal.add_bakery_payment(vault, 850, batch_id=scenario["batch"].id)
batch = ledger.build(vault.doc, today).batch_report(scenario["batch"].id)
assert batch.remaining_to_bakery == 0
assert batch.status == ledger.STATUS_SETTLED
assert ledger.build(vault.doc, today).summary.owed_to_bakery == 0
# --- FIFO по партиям ------------------------------------------------------
def test_sale_spills_from_the_older_batch_into_the_newer(vault, buns, today):
"""Привязка выручки к партии — единственная причина считать FIFO."""
old = journal.create_batch(
vault, today - timedelta(days=10), today + timedelta(days=4),
[buy(buns["мак"], 10, 20)],
)
new = journal.create_batch(
vault, today - timedelta(days=1), today + timedelta(days=13),
[buy(buns["мак"], 10, 20)],
)
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 14, 35)])
report = ledger.build(vault.doc, today)
assert report.batch_report(old.id).qty_sold == Decimal("10.000")
assert report.batch_report(new.id).qty_sold == Decimal("4.000")
assert report.batch_report(old.id).cash_collected == Decimal("350.00") # 10 × 35
assert report.batch_report(new.id).cash_collected == Decimal("140.00") # 4 × 35
def test_batch_order_follows_date_not_entry_order(vault, buns, today):
later = journal.create_batch(
vault, today, today + timedelta(days=14), [buy(buns["мак"], 5, 20)]
)
earlier = journal.create_batch(
vault, today - timedelta(days=5), today + timedelta(days=9), [buy(buns["мак"], 5, 20)]
)
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 5, 35)])
report = ledger.build(vault.doc, today)
assert report.batch_report(earlier.id).qty_sold == Decimal("5.000")
assert report.batch_report(later.id).qty_sold == 0
def test_allocation_is_deterministic(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
for _ in range(3):
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)])
first = ledger.build(vault.doc, today).allocations
second = ledger.build(vault.doc, today).allocations
assert [(a.sale_id, a.batch_id, a.qty) for a in first] == [
(a.sale_id, a.batch_id, a.qty) for a in second
]
def test_selling_more_than_bought_warns_instead_of_crashing(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 5, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 8, 35)])
report = ledger.build(vault.doc, today)
assert [(s.product_id, s.qty) for s in report.shortfalls] == [
(buns["мак"].id, Decimal("3.000"))
]
assert any("больше" in w for w in report.warnings)
# --- Частичная оплата -----------------------------------------------------
def test_partial_payment_counts_proportionally(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
sale = journal.create_sale(
vault, today, KIND_RETAIL, [line(buns["мак"], 10, 35)], paid_amount=0
)
journal.add_sale_payment(vault, sale.id, 140) # 40% от 350
report = ledger.build(vault.doc, today)
assert report.batches[0].revenue == Decimal("350.00")
assert report.batches[0].cash_collected == Decimal("140.00")
assert report.summary.receivable == Decimal("210.00")
def test_paying_off_the_debt_completes_coverage(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
sale = journal.create_sale(
vault, today, KIND_RETAIL, [line(buns["мак"], 10, 35)], paid_amount=0
)
journal.add_sale_payment(vault, sale.id, 350)
report = ledger.build(vault.doc, today)
assert report.batches[0].cash_collected == Decimal("350.00")
assert report.summary.receivable == 0
assert report.debtors == []
# --- Платежи пекарне ------------------------------------------------------
def test_unassigned_payment_pays_the_oldest_batch_first(vault, buns, today):
old = journal.create_batch(
vault, today - timedelta(days=10), today + timedelta(days=4), [buy(buns["мак"], 10, 20)]
)
new = journal.create_batch(
vault, today, today + timedelta(days=14), [buy(buns["мак"], 10, 20)]
)
journal.add_bakery_payment(vault, 250) # 200 закрывают старую, 50 идут в новую
report = ledger.build(vault.doc, today)
assert report.batch_report(old.id).remaining_to_bakery == 0
assert report.batch_report(new.id).remaining_to_bakery == Decimal("150.00")
def test_overpaying_one_batch_spills_over_instead_of_going_negative(vault, buns, today):
first = journal.create_batch(
vault, today - timedelta(days=5), today + timedelta(days=9), [buy(buns["мак"], 5, 20)]
)
second = journal.create_batch(
vault, today, today + timedelta(days=14), [buy(buns["мак"], 5, 20)]
)
journal.add_bakery_payment(vault, 180, batch_id=first.id) # партия стоит всего 100
report = ledger.build(vault.doc, today)
assert report.batch_report(first.id).remaining_to_bakery == 0
assert report.batch_report(second.id).remaining_to_bakery == Decimal("20.00")
def test_paying_more_than_everything_is_reported_as_advance(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 5, 20)])
journal.add_bakery_payment(vault, 300)
report = ledger.build(vault.doc, today)
assert report.unassigned_bakery_payment == Decimal("200.00")
assert report.summary.owed_to_bakery == 0
assert any("аванс" in w for w in report.warnings)
# --- Статусы и дедлайны ---------------------------------------------------
def test_status_open(vault, buns, today):
journal.create_batch(vault, today, today + timedelta(days=14), [buy(buns["мак"], 5, 20)])
assert ledger.build(vault.doc, today).batches[0].status == ledger.STATUS_OPEN
def test_status_due_soon(vault, buns, today):
journal.create_batch(vault, today, today + timedelta(days=2), [buy(buns["мак"], 5, 20)])
report = ledger.build(vault.doc, today).batches[0]
assert report.status == ledger.STATUS_DUE_SOON
assert report.days_left == 2
def test_status_overdue(vault, buns, today):
journal.create_batch(
vault, today - timedelta(days=20), today - timedelta(days=1), [buy(buns["мак"], 5, 20)]
)
report = ledger.build(vault.doc, today)
assert report.batches[0].status == ledger.STATUS_OVERDUE
assert report.summary.overdue_count == 1
def test_manually_closed_batch_is_settled_even_if_unpaid(vault, buns, today, in_two_weeks):
"""Иногда с пекарней договариваются на словах — учёт не должен спорить."""
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 5, 20)])
journal.set_batch_closed(vault, batch.id, True)
assert ledger.build(vault.doc, today).batches[0].status == ledger.STATUS_SETTLED
def test_next_due_picks_the_nearest_open_deadline(vault, buns, today):
journal.create_batch(vault, today, today + timedelta(days=14), [buy(buns["мак"], 5, 20)])
urgent = journal.create_batch(vault, today, today + timedelta(days=2), [buy(buns["мак"], 5, 20)])
summary = ledger.build(vault.doc, today).summary
assert summary.next_due.batch.id == urgent.id
def test_settled_batches_are_not_offered_as_next_due(vault, buns, today):
urgent = journal.create_batch(vault, today, today + timedelta(days=1), [buy(buns["мак"], 5, 20)])
later = journal.create_batch(vault, today, today + timedelta(days=20), [buy(buns["мак"], 5, 20)])
journal.add_bakery_payment(vault, 100, batch_id=urgent.id)
summary = ledger.build(vault.doc, today).summary
assert summary.next_due.batch.id == later.id
# --- Прочее ---------------------------------------------------------------
def test_gifts_count_as_consumption_not_revenue(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, today, KIND_GIFT, [line(buns["мак"], 3, 0)])
summary = ledger.build(vault.doc, today).summary
assert summary.revenue == 0
assert summary.consumed_cost == Decimal("60.00")
assert summary.profit == Decimal("-60.00")
def test_friend_price_yields_no_margin(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, today, KIND_FRIEND, [line(buns["мак"], 10, 20)])
summary = ledger.build(vault.doc, today).summary
assert summary.cash_collected == Decimal("200.00")
assert summary.gross_margin == 0
assert summary.profit == 0
def test_debts_without_counterparty_are_grouped_separately(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)], paid_amount=0)
debtors = ledger.build(vault.doc, today).debtors
assert [(d.name, d.debt) for d in debtors] == [("Без контрагента", Decimal("70.00"))]
def test_empty_database_reports_zeroes(vault, today):
report = ledger.build(vault.doc, today)
assert report.summary.owed_to_bakery == 0
assert report.summary.profit == 0
assert report.summary.next_due is None
assert report.warnings == []
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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
# --- Фасовки: пачка и штука — один товар ----------------------------------
@pytest.fixture
def cookies(vault):
"""Печенье: берётся пачками по 10, продаётся и пачкой, и поштучно.
Себестоимость 20 за штуку, то есть 200 за пачку. Поштучно уходит
по 30 , пачкой целиком за 200 .
"""
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
journal.set_packs(vault, product.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
return vault.doc.product(product.id)
def pack_buy(product, qty, cost):
return {"product_id": product.id, "qty": qty, "uom": "пачка", "unit_cost": cost}
def test_buying_packs_gives_stock_in_pieces(vault, cookies, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [pack_buy(cookies, 5, 200)])
report = ledger.build(vault.doc, today)
batch = report.batches[0]
assert batch.cost_total == Decimal("1000.00") # 5 пачек × 200
assert batch.qty_total == Decimal("50.000") # но 50 штук
assert batch.qty_left == Decimal("50.000")
def test_selling_singles_depletes_the_packs(vault, cookies, today, in_two_weeks):
"""Главное: печенька поштучно берётся из пачки, это не второй товар."""
journal.create_batch(vault, today, in_two_weeks, [pack_buy(cookies, 5, 200)])
journal.create_sale(vault, today, KIND_RETAIL, [line(cookies, 3, 30)])
report = ledger.build(vault.doc, today)
batch = report.batches[0]
assert batch.qty_sold == Decimal("3.000")
assert batch.qty_left == Decimal("47.000")
assert batch.cash_collected == Decimal("90.00") # 3 × 30
# Наценка считается от себестоимости штуки, выведенной из цены пачки.
assert report.summary.gross_margin == Decimal("30.00") # 3 × (30 20)
def test_selling_a_whole_pack_and_singles_together(vault, cookies, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [pack_buy(cookies, 5, 200)])
journal.create_sale(vault, today, KIND_RETAIL, [line(cookies, 3, 30)])
journal.create_sale(
vault, today, KIND_RETAIL,
[{"product_id": cookies.id, "qty": 1, "uom": "пачка", "unit_price": 200}],
)
report = ledger.build(vault.doc, today)
batch = report.batches[0]
assert batch.qty_sold == Decimal("13.000") # 3 штуки + 1 пачка
assert batch.qty_left == Decimal("37.000")
assert batch.cash_collected == Decimal("290.00") # 90 + 200
# Пачка ушла ровно по себестоимости, наценка только с поштучных.
assert report.summary.gross_margin == Decimal("30.00")
def test_stock_value_uses_cost_per_piece(vault, cookies, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [pack_buy(cookies, 5, 200)])
journal.create_sale(vault, today, KIND_RETAIL, [line(cookies, 3, 30)])
summary = ledger.build(vault.doc, today).summary
assert summary.stock_qty == Decimal("47.000")
assert summary.stock_cost == Decimal("940.00") # 47 × 20
def test_singles_can_outlast_one_batch_and_spill_into_the_next(vault, cookies, today):
journal.create_batch(
vault, today - timedelta(days=5), today + timedelta(days=9), [pack_buy(cookies, 1, 200)]
)
second = journal.create_batch(
vault, today, today + timedelta(days=14), [pack_buy(cookies, 1, 200)]
)
journal.create_sale(vault, today, KIND_RETAIL, [line(cookies, 14, 30)])
report = ledger.build(vault.doc, today)
assert report.batches[0].qty_sold == Decimal("10.000")
assert report.batch_report(second.id).qty_sold == Decimal("4.000")
def test_pack_size_is_snapshotted(vault, cookies, today, in_two_weeks):
"""Переопределили пачку — уже записанные документы не должны поехать."""
journal.create_batch(vault, today, in_two_weeks, [pack_buy(cookies, 2, 200)])
journal.set_packs(vault, cookies.id, [{"name": "пачка", "size": 25, "retail_price": 500}])
report = ledger.build(vault.doc, today)
assert report.batches[0].qty_total == Decimal("20.000") # по 10, как и покупали
def test_odd_pack_size_does_not_lose_kopecks(vault, today, in_two_weeks):
"""200 на 3 не делится нацело — округление не должно копиться."""
product = journal.create_product(vault, "Пирожки", unit="шт", cost_price=0, retail_price=100)
journal.set_packs(vault, product.id, [{"name": "тройка", "size": 3, "retail_price": 200}])
journal.create_batch(
vault, today, in_two_weeks,
[{"product_id": product.id, "qty": 1, "uom": "тройка", "unit_cost": 200}],
)
journal.create_sale(
vault, today, KIND_RETAIL,
[{"product_id": product.id, "qty": 1, "uom": "тройка", "unit_price": 200}],
)
report = ledger.build(vault.doc, today)
assert report.batches[0].cash_collected == Decimal("200.00")
assert report.summary.gross_margin == 0
assert report.summary.stock_qty == 0
def test_product_without_packs_behaves_exactly_as_before(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 4, 35)])
report = ledger.build(vault.doc, today)
assert report.batches[0].qty_left == Decimal("6.000")
assert report.batches[0].cash_collected == Decimal("140.00")
def test_stock_lists_products_with_remaining_quantity(scenario, today):
rows = {r.name: r.qty for r in ledger.build(scenario["vault"].doc, today).stock}
assert rows["Булка с повидлом"] == Decimal("2.000")
assert rows["Булка с маком"] == Decimal("5.000")
assert rows["Булка с корицей"] == Decimal("18.000")