food-market/tests/test_ledger.py
food-market d08d2421f7 Долг как явный выбор, история цен у фасовок
Долг было непонятно как записать, и не зря. Оплата была просто денежным
полем с полной суммой: долг получался, если догадаться стереть число
и вписать ноль. Дальше — хуже. Подсказка при этом продолжала уверять, что
оплачено полностью, а любая правка строк подставляла полную сумму обратно
и молча стирала выставленный долг.

Теперь выбор явный: заплатил полностью / взял в долг целиком / заплатил
часть. Пересчёт работает только в первом режиме. Поле суммы осталось
живым — вписанное руками число само зажигает подходящий переключатель.

Долг без имени записать нельзя: на экране долгов такая запись попадает
в кучу «без контрагента», и с кого спрашивать — уже не узнать. В быстром
вводе это предупреждение, а не запрет: внося историю, можно и правда не
помнить, кто это был.

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

Цены фасовок переехали в «Цены и история». Они меняются одним решением
поставщика вместе с ценой товара, а история была только у товара —
у коробки цена уезжала молча. Заодно закрыт второй вход: в карточке
товара цены фасовок теперь только показываются, правится там состав.

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

1009 lines
45 KiB
Python
Raw Permalink 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_bulk_pack_is_cheaper_per_piece(vault, today, in_two_weeks):
"""Коробка обходится дешевле поштучного — иначе смысла её брать нет.
Печенье поштучно 25 ₽, коробка из 20 — 300 ₽, то есть по 15 ₽ за штуку.
Продаём поштучно по 25: наценка идёт от 15, а не от 25.
"""
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
product = vault.doc.product(product.id)
assert product.cost_for("коробка") == Decimal("300.00")
assert product.retail_for("коробка") == Decimal("500.00")
journal.create_batch(
vault, today, in_two_weeks,
[{"product_id": product.id, "qty": 1, "uom": "коробка", "unit_cost": 300}],
)
journal.create_sale(vault, today, KIND_RETAIL, [line(product, 20, 25)])
report = ledger.build(vault.doc, today)
batch = report.batches[0]
assert batch.cost_total == Decimal("300.00") # долг пекарне — за коробку
assert batch.qty_total == Decimal("20.000")
assert batch.cash_collected == Decimal("500.00") # 20 × 25
assert report.summary.gross_margin == Decimal("200.00") # 20 × (25 15)
def test_mixed_pack_and_single_purchases_average_the_cost(vault, today, in_two_weeks):
"""В одной партии и коробка, и штуки — остаток по товару всё равно один.
Себестоимость базовой единицы становится средневзвешенной, иначе непонятно,
какая из двух цен списывается при поштучной продаже.
"""
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
product = vault.doc.product(product.id)
journal.create_batch(
vault, today, in_two_weeks,
[
{"product_id": product.id, "qty": 1, "uom": "коробка", "unit_cost": 300},
{"product_id": product.id, "qty": 5, "uom": "шт", "unit_cost": 25},
],
)
report = ledger.build(vault.doc, today)
batch = report.batches[0]
assert batch.cost_total == Decimal("425.00") # 300 + 125
assert batch.qty_total == Decimal("25.000") # 20 + 5
assert batch.stock_cost == Decimal("425.00") # 25 штук по 17 ₽ в среднем
def test_pack_prices_change_through_history(vault, today):
"""Цена коробки меняется только там же, где цена штуки, — с историей.
Иначе у товара история есть, а у фасовки нет, хотя дорожают они обычно
одним решением поставщика.
"""
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
journal.change_prices(
vault, product.id, 28, 30, today, "подорожала мука",
packs=[{"name": "коробка", "cost_price": 340, "retail_price": 560}],
)
product = vault.doc.product(product.id)
assert product.cost_for("коробка") == Decimal("340.00")
assert product.retail_for("коробка") == Decimal("560.00")
# Обе точки истории знают цены фасовки, включая стартовую.
assert [p.pack("коробка").cost_price for p in product.price_history] == [
Decimal("300.00"), Decimal("340.00")
]
def test_editing_pack_size_does_not_touch_its_prices(vault):
"""Правка состава фасовок — не правка цен, и молча их сбрасывать нельзя."""
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
# Карточка товара присылает нули: цены там показаны только для чтения.
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 24, "cost_price": 0, "retail_price": 0}],
)
pack = vault.doc.product(product.id).packs[0]
assert pack.size == Decimal("24.000")
assert pack.cost_price == Decimal("300.00")
assert pack.retail_price == Decimal("500.00")
def test_new_pack_price_lands_in_history(vault, today):
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
journal.change_prices(vault, product.id, 26, 27, today, "первое подорожание")
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
product = vault.doc.product(product.id)
# Отдельной точкой: приписывать коробке цену задним числом было бы враньём.
assert "заведена фасовка «коробка»" in product.price_history[-1].note
assert product.price_history[-1].pack("коробка").cost_price == Decimal("300.00")
assert product.price_history[0].packs == []
def test_price_change_without_packs_keeps_them(vault, today):
"""packs=None — «фасовки не трогаем», но снимок всё равно полный."""
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
journal.change_prices(vault, product.id, 28, 30, today, "только штука")
product = vault.doc.product(product.id)
assert product.cost_for("коробка") == Decimal("300.00")
assert product.price_history[-1].pack("коробка").cost_price == Decimal("300.00")
def test_price_change_of_only_a_pack_is_recorded(vault, today):
"""Подорожала только коробка — это тоже изменение, а не пустая правка."""
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
before = len(vault.doc.product(product.id).price_history)
journal.change_prices(
vault, product.id, 25, 25, today, "коробка подорожала",
packs=[{"name": "коробка", "cost_price": 320, "retail_price": 500}],
)
product = vault.doc.product(product.id)
assert len(product.price_history) == before + 1
assert product.cost_for("коробка") == Decimal("320.00")
def test_unchanged_prices_add_nothing(vault, today):
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
before = len(vault.doc.product(product.id).price_history)
journal.change_prices(
vault, product.id, 25, 25, today, "ничего не менял",
packs=[{"name": "коробка", "cost_price": 300, "retail_price": 500}],
)
assert len(vault.doc.product(product.id).price_history) == before
def test_pack_without_its_own_cost_still_derives_it(vault, today, in_two_weeks):
"""Пустая цена фасовки — прежнее поведение, а не ноль."""
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
journal.set_packs(vault, product.id, [{"name": "пачка", "size": 10}])
product = vault.doc.product(product.id)
assert product.cost_for("пачка") == Decimal("200.00") # 20 × 10
assert product.retail_for("пачка") == Decimal("300.00") # 30 × 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_bakery_taking_less_closes_the_batch(vault, buns, today, in_two_weeks):
"""Надо было отдать 5 500, взяли 5 000, полтысячи оставили."""
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 275, 20)])
assert batch.cost_total == Decimal("5500.00")
journal.add_bakery_payment(vault, 5000, today, batch_id=batch.id)
journal.write_off_batch(vault, batch.id, 500, today, "остаток простили")
report = ledger.build(vault.doc, today).batch_report(batch.id)
assert report.paid_to_bakery == Decimal("5000.00")
assert report.discount == Decimal("500.00")
assert report.remaining_to_bakery == 0
assert report.status == ledger.STATUS_SETTLED
def test_forgiven_money_is_income(vault, buns, today, in_two_weeks):
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 10, 35)])
journal.add_bakery_payment(vault, 150, today, batch_id=batch.id)
journal.write_off_batch(vault, batch.id, 50, today)
summary = ledger.build(vault.doc, today).summary
assert summary.bakery_discounts == Decimal("50.00")
assert summary.gross_margin == Decimal("150.00")
assert summary.profit == Decimal("200.00") # 150 наценки + 50 прощённых
assert summary.owed_to_bakery == 0
def test_forgiven_money_is_not_a_payment(vault, buns, today, in_two_weeks):
"""«Отдано пекарне» должно показывать только реально отданные деньги."""
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.write_off_batch(vault, batch.id, 200, today)
report = ledger.build(vault.doc, today).batch_report(batch.id)
assert report.paid_to_bakery == 0
assert report.discount == Decimal("200.00")
assert report.settled_amount == Decimal("200.00")
def test_discount_reduces_what_still_needs_collecting(vault, buns, today, in_two_weeks):
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)])
journal.write_off_batch(vault, batch.id, 50, today)
report = ledger.build(vault.doc, today).batch_report(batch.id)
assert report.cost_total == Decimal("200.00")
assert report.cash_collected == Decimal("70.00")
assert report.still_to_collect == Decimal("80.00") # 200 50 прощено 70
def test_discount_beyond_the_debt_is_ignored(vault, buns, today, in_two_weeks):
"""Простить больше, чем должен, нельзя — лишнее не доход."""
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.write_off_batch(vault, batch.id, 900, today)
report = ledger.build(vault.doc, today)
assert report.batch_report(batch.id).discount == Decimal("200.00")
assert report.summary.bakery_discounts == Decimal("200.00")
assert report.unassigned_bakery_payment == 0 # и никуда не переливается
def test_discount_and_payment_together_never_overshoot(vault, buns, today, in_two_weeks):
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.add_bakery_payment(vault, 180, today, batch_id=batch.id)
journal.write_off_batch(vault, batch.id, 100, today)
report = ledger.build(vault.doc, today).batch_report(batch.id)
assert report.paid_to_bakery == Decimal("180.00")
assert report.discount == Decimal("20.00") # больше долга не спишется
assert report.remaining_to_bakery == 0
def test_discount_must_name_a_batch(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.add_bakery_payment(vault, 100, today, kind="discount")
def test_discount_is_journalled(vault, buns, today, in_two_weeks):
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.write_off_batch(vault, batch.id, 50, today)
entry = vault.doc.journal[-1]
assert entry.action == "bakery_payment.discount"
assert "Скидка пекарни" in entry.label
def test_plain_payments_are_unaffected(vault, buns, today, in_two_weeks):
"""Кто скидок не получает, тот не должен заметить появления этой фичи."""
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.add_bakery_payment(vault, 120, today, batch_id=batch.id)
report = ledger.build(vault.doc, today).batch_report(batch.id)
assert report.paid_to_bakery == Decimal("120.00")
assert report.discount == 0
assert report.remaining_to_bakery == Decimal("80.00")
assert ledger.build(vault.doc, today).summary.bakery_discounts == 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")