food-market/tests/test_ledger.py
Claude 582f041dba Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела
бы закрытой, когда за булки ещё должны.

Ввёл оплату больше суммы — булка 70, дали 100 — разница уходит в чаевые
сама. Разбор делается по окончании ввода, а не на каждом нажатии, иначе
поле дёргалось бы посреди набора. То же при приёме оплаты по долгу:
заплатили больше, чем оставалось, — излишек становится чаевыми, а не
теряется, как было раньше.

В расчётах чаевые идут в покрытие партии (это живые деньги, ими так же
рассчитываются с пекарней) и целиком добавляются в прибыль, но не входят
в выручку и не создают долга. Продажа, списанная с нескольких партий,
делит чаевые между ними пропорционально выручке.

По съеденному и подаренному чаевых не бывает — поле отключается.

Миграция схемы 2→3 проставляет ноль: у кого чаевых нет, тот появления
этой фичи не заметит.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:56:26 +03:00

729 lines
32 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.

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")