Долг было непонятно как записать, и не зря. Оплата была просто денежным полем с полной суммой: долг получался, если догадаться стереть число и вписать ноль. Дальше — хуже. Подсказка при этом продолжала уверять, что оплачено полностью, а любая правка строк подставляла полную сумму обратно и молча стирала выставленный долг. Теперь выбор явный: заплатил полностью / взял в долг целиком / заплатил часть. Пересчёт работает только в первом режиме. Поле суммы осталось живым — вписанное руками число само зажигает подходящий переключатель. Долг без имени записать нельзя: на экране долгов такая запись попадает в кучу «без контрагента», и с кого спрашивать — уже не узнать. В быстром вводе это предупреждение, а не запрет: внося историю, можно и правда не помнить, кто это был. На экране долгов человека можно выбрать целиком и рассчитаться сразу за всё: деньги приходят одной суммой, а гасятся долги по очереди, со старых. Цены фасовок переехали в «Цены и история». Они меняются одним решением поставщика вместе с ценой товара, а история была только у товара — у коробки цена уезжала молча. Заодно закрыт второй вход: в карточке товара цены фасовок теперь только показываются, правится там состав. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1009 lines
45 KiB
Python
1009 lines
45 KiB
Python
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")
|