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 == [] # --- Фасовки: пачка и штука — один товар ---------------------------------- @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_tip_is_not_a_debt(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["мак"], 2, 35)], paid_amount=70, tip=30 ) assert sale.total == Decimal("70.00") assert sale.paid == Decimal("70.00") assert sale.tip == Decimal("30.00") assert sale.received == Decimal("100.00") assert sale.debt == 0 assert ledger.build(vault.doc, today).summary.receivable == 0 def test_overpayment_becomes_a_tip(vault, buns, today, in_two_weeks): """Булка 70, дали 100, сдачи не надо — 30 это чаевые.""" journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) sale = journal.create_sale( vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)], paid_amount=100 ) assert sale.paid == Decimal("70.00") assert sale.tip == Decimal("30.00") assert sale.debt == 0 def test_tip_counts_toward_batch_coverage(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)], tip=30) batch = ledger.build(vault.doc, today).batches[0] assert batch.cost_total == Decimal("200.00") assert batch.cash_collected == Decimal("100.00") # 70 за булки + 30 чаевых assert batch.still_to_collect == Decimal("100.00") assert batch.coverage_pct == Decimal("50.00") def test_tip_goes_straight_into_profit(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)], tip=30) summary = ledger.build(vault.doc, today).summary assert summary.gross_margin == Decimal("30.00") # 2 × (35 − 20) assert summary.tips == Decimal("30.00") assert summary.profit == Decimal("60.00") def test_tip_does_not_inflate_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_RETAIL, [line(buns["мак"], 2, 35)], tip=30) summary = ledger.build(vault.doc, today).summary assert summary.revenue == Decimal("70.00") assert summary.cash_collected == Decimal("100.00") def test_tip_survives_a_partially_paid_sale(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["мак"], 4, 35)], paid_amount=40, tip=25 ) assert sale.debt == Decimal("100.00") batch = ledger.build(vault.doc, today).batches[0] assert batch.cash_collected == Decimal("65.00") # 40 оплаты + 25 чаевых def test_tip_splits_between_batches_by_revenue(vault, buns, today): """Продажа списалась с двух партий — чаевые делятся между ними.""" old = journal.create_batch( vault, today - timedelta(days=5), today + timedelta(days=9), [buy(buns["мак"], 3, 20)] ) new = journal.create_batch( vault, today, today + timedelta(days=14), [buy(buns["мак"], 7, 20)] ) journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 4, 35)], tip=40) report = ledger.build(vault.doc, today) # Три штуки из старой, одна из новой — чаевые в тех же долях. assert report.batch_report(old.id).cash_collected == Decimal("135.00") # 105 + 30 assert report.batch_report(new.id).cash_collected == Decimal("45.00") # 35 + 10 assert report.summary.cash_collected == Decimal("180.00") def test_paying_more_than_the_debt_becomes_a_tip(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["мак"], 2, 35)], paid_amount=0 ) journal.add_sale_payment(vault, sale.id, 100) assert sale.paid == Decimal("70.00") assert sale.tip == Decimal("30.00") assert sale.debt == 0 def test_consumption_never_gets_a_tip(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_SELF, [line(buns["мак"], 2, 0)], paid_amount=50, tip=50 ) assert sale.tip == 0 assert ledger.build(vault.doc, today).summary.tips == 0 with pytest.raises(journal.ValidationError, match="не бывает"): journal.set_sale_tip(vault, sale.id, 100) def test_tip_can_be_added_afterwards(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["мак"], 2, 35)]) journal.set_sale_tip(vault, sale.id, 15) assert sale.tip == Decimal("15.00") assert ledger.build(vault.doc, today).summary.tips == Decimal("15.00") def test_tip_without_any_stock_still_counts_as_income(vault, buns, today): """Продали то, чего не закупали: чаевые получены и не должны потеряться. Сама выручка при этом ни к какой партии не относится — на это ругается предупреждение о нехватке. Чаевые же считаются по продажам напрямую, поэтому в прибыль они попадают в любом случае. """ journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 1, 35)], tip=20) report = ledger.build(vault.doc, today) assert report.summary.tips == Decimal("20.00") assert report.summary.profit == Decimal("20.00") assert any("больше" in w for w in report.warnings) def test_negative_tip_rejected(vault, buns, today, in_two_weeks): journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) with pytest.raises(journal.ValidationError, match="отрицательн"): journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 1, 35)], tip=-5) def test_no_tips_changes_nothing(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)]) summary = ledger.build(vault.doc, today).summary assert summary.tips == 0 assert summary.cash_collected == Decimal("140.00") assert summary.profit == Decimal("60.00") # --- Дробные количества: розлив и вес ------------------------------------ @pytest.fixture def juice(vault): """Сок: берётся литрами, продаётся в том числе по пол-литра.""" return journal.create_product(vault, "Сок", unit="л", cost_price=80, retail_price=120) def test_fractional_sale_leaves_fractional_stock(vault, juice, today, in_two_weeks): journal.create_batch( vault, today, in_two_weeks, [{"product_id": juice.id, "qty": 10, "unit_cost": 80}] ) journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.5", 120)]) report = ledger.build(vault.doc, today) batch = report.batches[0] assert batch.qty_sold == Decimal("0.500") assert batch.qty_left == Decimal("9.500") assert batch.cash_collected == Decimal("60.00") # 0,5 × 120 assert report.summary.gross_margin == Decimal("20.00") # 0,5 × (120 − 80) def test_fractional_purchase(vault, juice, today, in_two_weeks): journal.create_batch( vault, today, in_two_weeks, [{"product_id": juice.id, "qty": "2.5", "unit_cost": 80}] ) report = ledger.build(vault.doc, today) assert report.batches[0].qty_total == Decimal("2.500") assert report.batches[0].cost_total == Decimal("200.00") # 2,5 × 80 def test_many_fractional_sales_add_up_exactly(vault, juice, today, in_two_weeks): """Дробные не должны копить погрешность — потому и Decimal, а не float.""" journal.create_batch( vault, today, in_two_weeks, [{"product_id": juice.id, "qty": 10, "unit_cost": 80}] ) for _ in range(20): journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.1", 120)]) report = ledger.build(vault.doc, today) assert report.batches[0].qty_sold == Decimal("2.000") assert report.batches[0].qty_left == Decimal("8.000") assert report.batches[0].cash_collected == Decimal("240.00") def test_fractional_pack_size(vault, juice, today, in_two_weeks): """Бутылка 1,5 л — фасовка дробного размера.""" journal.set_packs(vault, juice.id, [{"name": "бутылка", "size": "1.5", "retail_price": 170}]) journal.create_batch( vault, today, in_two_weeks, [{"product_id": juice.id, "qty": 4, "uom": "бутылка", "unit_cost": 120}], ) journal.create_sale( vault, today, KIND_RETAIL, [{"product_id": juice.id, "qty": 1, "uom": "бутылка", "unit_price": 170}], ) journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.5", 120)]) report = ledger.build(vault.doc, today) batch = report.batches[0] assert batch.qty_total == Decimal("6.000") # 4 бутылки × 1,5 л assert batch.qty_sold == Decimal("2.000") # 1,5 + 0,5 assert batch.qty_left == Decimal("4.000") assert batch.cash_collected == Decimal("230.00") # 170 + 60 def test_fractional_stock_value(vault, juice, today, in_two_weeks): journal.create_batch( vault, today, in_two_weeks, [{"product_id": juice.id, "qty": 10, "unit_cost": 80}] ) journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.25", 120)]) summary = ledger.build(vault.doc, today).summary assert summary.stock_qty == Decimal("9.750") assert summary.stock_cost == Decimal("780.00") # 9,75 × 80 def test_fractional_self_consumption(vault, juice, today, in_two_weeks): journal.create_batch( vault, today, in_two_weeks, [{"product_id": juice.id, "qty": 5, "unit_cost": 80}] ) journal.create_sale(vault, today, KIND_SELF, [line(juice, "0.5", 0)]) summary = ledger.build(vault.doc, today).summary assert summary.consumed_cost == Decimal("40.00") # 0,5 × 80 assert summary.profit == Decimal("-40.00") def test_fractional_sale_spills_across_batches(vault, juice, today): journal.create_batch( vault, today - timedelta(days=5), today + timedelta(days=9), [{"product_id": juice.id, "qty": "1.5", "unit_cost": 80}], ) second = journal.create_batch( vault, today, today + timedelta(days=14), [{"product_id": juice.id, "qty": "2.5", "unit_cost": 80}], ) journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "2.25", 120)]) report = ledger.build(vault.doc, today) assert report.batches[0].qty_sold == Decimal("1.500") assert report.batch_report(second.id).qty_sold == Decimal("0.750") assert report.batch_report(second.id).qty_left == Decimal("1.750") def test_summary_counts_positions_because_units_differ(vault, juice, buns, today, in_two_weeks): """Литры, килограммы и штуки в одну сумму не складываются. Сводка поэтому показывает деньги и число позиций, а не общее количество. """ cheese = journal.create_product(vault, "Сыр", unit="кг", cost_price=600, retail_price=900) journal.create_batch( vault, today, in_two_weeks, [ {"product_id": juice.id, "qty": 10, "unit_cost": 80}, {"product_id": cheese.id, "qty": "3.5", "unit_cost": 600}, buy(buns["мак"], 20, 20), ], ) summary = ledger.build(vault.doc, today).summary assert summary.stock_positions == 3 assert summary.stock_cost == Decimal("3300.00") # 800 + 2100 + 400 def test_fractional_shortfall_is_reported(vault, juice, today, in_two_weeks): journal.create_batch( vault, today, in_two_weeks, [{"product_id": juice.id, "qty": "0.5", "unit_cost": 80}] ) journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.75", 120)]) report = ledger.build(vault.doc, today) assert [s.qty for s in report.shortfalls] == [Decimal("0.250")] 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")