food-market/tests/test_stats.py
Claude 3968806094 Пекарня может простить остаток
Надо было отдать 5 500, взяли 5 000, полтысячи оставили. Долг закрыт,
но денег никто не отдавал.

У расчёта с пекарней появился вид: деньги или прощённый остаток. Свалить
их в одну кучу нельзя — тогда «Отдано пекарне» в статистике показывало бы
суммы, которых не платил. Поэтому «Отдано» — только живые деньги,
«Прощено» — отдельная колонка везде: в закупках, в сводке, в статистике
по периодам и по времени.

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

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

Миграция схемы 3→4 помечает всё записанное раньше как живые деньги.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:44:19 +03:00

499 lines
22 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, stats
from app.models import KIND_FRIEND, 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}
def report_for(vault, today=None):
return ledger.build(vault.doc, today or date.today())
# --- границы периодов -----------------------------------------------------
def test_week_bounds_run_monday_to_sunday():
# 19 августа 2026 — среда.
start, end = stats.week_bounds(date(2026, 8, 19))
assert (start, end) == (date(2026, 8, 17), date(2026, 8, 23))
assert start.isoweekday() == 1 and end.isoweekday() == 7
def test_month_bounds():
assert stats.month_bounds(date(2026, 2, 14)) == (date(2026, 2, 1), date(2026, 2, 28))
assert stats.month_bounds(date(2024, 2, 14)) == (date(2024, 2, 1), date(2024, 2, 29))
assert stats.month_bounds(date(2026, 12, 5)) == (date(2026, 12, 1), date(2026, 12, 31))
def test_year_bounds():
assert stats.year_bounds(date(2026, 7, 7)) == (date(2026, 1, 1), date(2026, 12, 31))
def test_labels_are_readable():
_, week, _, _ = stats.bucket_of(date(2026, 8, 19), stats.GRAIN_WEEK)
assert week == "Неделя 34 · 1723 августа 2026"
_, month, _, _ = stats.bucket_of(date(2026, 8, 19), stats.GRAIN_MONTH)
assert month == "Август 2026"
_, year, _, _ = stats.bucket_of(date(2026, 8, 19), stats.GRAIN_YEAR)
assert year == "2026 год"
def test_week_label_spanning_two_months():
_, label, _, _ = stats.bucket_of(date(2026, 10, 1), stats.GRAIN_WEEK)
assert label == "Неделя 40 · 28 сентября 4 октября 2026"
def test_week_label_spanning_two_years():
_, label, _, _ = stats.bucket_of(date(2026, 12, 31), stats.GRAIN_WEEK)
assert "2026" in label and "2027" in label
def test_keys_sort_chronologically():
keys = [
stats.bucket_of(date(2026, 1, 5), stats.GRAIN_WEEK)[0],
stats.bucket_of(date(2026, 10, 5), stats.GRAIN_WEEK)[0],
]
assert keys == sorted(keys) # W02 против W41, а не «10» перед «2»
def test_unknown_grain_is_rejected():
with pytest.raises(ValueError):
stats.bucket_of(date.today(), "квартал")
# --- корзины --------------------------------------------------------------
@pytest.fixture
def three_months(vault, buns):
"""Три месяца торговли с долгом, чаевыми и съеденным."""
june, july, august = date(2026, 6, 10), date(2026, 7, 10), date(2026, 8, 10)
journal.create_batch(vault, june, june + timedelta(days=20), [buy(buns["мак"], 100, 20)])
journal.create_sale(vault, june, KIND_RETAIL, [line(buns["мак"], 10, 35)])
journal.create_sale(vault, july, KIND_RETAIL, [line(buns["мак"], 20, 35)], tip=50)
journal.create_sale(vault, july, KIND_SELF, [line(buns["мак"], 2, 0)])
journal.create_sale(vault, august, KIND_FRIEND, [line(buns["мак"], 5, 20)])
return vault
def buckets_by_key(vault, grain, today=None):
result = stats.build_buckets(vault.doc, report_for(vault, today), grain)
return {b.key: b for b in result}
def test_months_are_split_correctly(three_months):
months = buckets_by_key(three_months, stats.GRAIN_MONTH, date(2026, 8, 31))
assert list(months) == ["2026-06", "2026-07", "2026-08"]
june = months["2026-06"]
assert june.revenue == Decimal("350.00") # 10 × 35
assert june.cogs == Decimal("200.00") # 10 × 20
assert june.margin == Decimal("150.00")
assert june.purchased == Decimal("2000.00") # партия взята в июне
def test_tips_and_consumption_land_in_the_right_month(three_months):
july = buckets_by_key(three_months, stats.GRAIN_MONTH, date(2026, 8, 31))["2026-07"]
assert july.tips == Decimal("50.00")
assert july.consumed_cost == Decimal("40.00") # 2 × 20
assert july.margin == Decimal("300.00") # 20 × 15
assert july.profit == Decimal("310.00") # 300 + 50 40
def test_friend_price_gives_no_margin(three_months):
august = buckets_by_key(three_months, stats.GRAIN_MONTH, date(2026, 8, 31))["2026-08"]
assert august.revenue == Decimal("100.00")
assert august.margin == 0
assert august.profit == 0
def test_empty_months_are_skipped(vault, buns):
"""В марте не торговали — выдуманного нуля в таблице быть не должно."""
journal.create_batch(vault, date(2026, 1, 5), date(2026, 1, 20), [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, date(2026, 4, 5), KIND_RETAIL, [line(buns["мак"], 1, 35)])
keys = list(buckets_by_key(vault, stats.GRAIN_MONTH, date(2026, 5, 1)))
assert keys == ["2026-01", "2026-04"]
def test_years_roll_up_months(three_months, buns):
"""Год обязан быть в точности суммой своих месяцев."""
journal.create_sale(
three_months, date(2025, 12, 20), KIND_RETAIL, [line(buns["мак"], 1, 35)]
)
report = report_for(three_months, date(2026, 12, 31))
years = {b.key: b for b in stats.build_buckets(three_months.doc, report, stats.GRAIN_YEAR)}
months = stats.build_buckets(three_months.doc, report, stats.GRAIN_MONTH)
assert list(years) == ["2025", "2026"]
of_2026 = [b for b in months if b.start.year == 2026]
assert years["2026"].revenue == sum(b.revenue for b in of_2026)
assert years["2026"].profit == sum(b.profit for b in of_2026)
assert years["2026"].sales_count == sum(b.sales_count for b in of_2026)
# 10×35 в июне + 20×35 в июле + 5×20 другу в августе
assert years["2026"].revenue == Decimal("1150.00")
def test_weeks_split_by_iso_week(vault, buns):
monday, sunday = date(2026, 8, 17), date(2026, 8, 23)
journal.create_batch(vault, monday, sunday, [buy(buns["мак"], 50, 20)])
journal.create_sale(vault, monday, KIND_RETAIL, [line(buns["мак"], 1, 35)])
journal.create_sale(vault, sunday, KIND_RETAIL, [line(buns["мак"], 1, 35)])
journal.create_sale(vault, sunday + timedelta(days=1), KIND_RETAIL, [line(buns["мак"], 1, 35)])
weeks = buckets_by_key(vault, stats.GRAIN_WEEK, date(2026, 9, 1))
assert weeks["2026-W34"].sales_count == 2
assert weeks["2026-W35"].sales_count == 1
# --- касса против начислений ----------------------------------------------
def test_money_lands_in_the_month_it_arrived(vault, buns):
"""Долг за август, возвращённый в сентябре, — сентябрьские деньги."""
august, september = date(2026, 8, 10), date(2026, 9, 5)
journal.create_batch(vault, august, august + timedelta(days=20), [buy(buns["мак"], 50, 20)])
sale = journal.create_sale(
vault, august, KIND_RETAIL, [line(buns["мак"], 10, 35)], paid_amount=0
)
journal.add_sale_payment(vault, sale.id, 350, september)
months = buckets_by_key(vault, stats.GRAIN_MONTH, date(2026, 9, 30))
assert months["2026-08"].revenue == Decimal("350.00") # наторговал в августе
assert months["2026-08"].collected == 0 # но денег в августе не видел
assert months["2026-09"].collected == Decimal("350.00")
assert months["2026-09"].revenue == 0
def test_bakery_payments_land_by_their_own_date(vault, buns):
august = date(2026, 8, 10)
batch = journal.create_batch(
vault, august, august + timedelta(days=20), [buy(buns["мак"], 50, 20)]
)
journal.add_bakery_payment(vault, 400, date(2026, 9, 3), batch_id=batch.id)
months = buckets_by_key(vault, stats.GRAIN_MONTH, date(2026, 9, 30))
assert months["2026-08"].purchased == Decimal("1000.00")
assert months["2026-08"].paid_to_bakery == 0
assert months["2026-09"].paid_to_bakery == Decimal("400.00")
def test_forgiven_remainder_is_not_money_paid(vault, buns):
"""«Отдано пекарне» и «Прощено» — разные колонки, и это принципиально."""
august = date(2026, 8, 10)
batch = journal.create_batch(
vault, august, august + timedelta(days=20), [buy(buns["мак"], 275, 20)]
)
journal.add_bakery_payment(vault, 5000, august, batch_id=batch.id)
journal.write_off_batch(vault, batch.id, 500, august)
month = buckets_by_key(vault, stats.GRAIN_MONTH, date(2026, 8, 31))["2026-08"]
assert month.paid_to_bakery == Decimal("5000.00")
assert month.bakery_discount == Decimal("500.00")
def test_forgiven_remainder_adds_to_period_profit(vault, buns):
august = date(2026, 8, 10)
batch = journal.create_batch(
vault, august, august + timedelta(days=20), [buy(buns["мак"], 10, 20)]
)
journal.create_sale(vault, august, KIND_RETAIL, [line(buns["мак"], 10, 35)])
journal.write_off_batch(vault, batch.id, 50, august)
month = buckets_by_key(vault, stats.GRAIN_MONTH, date(2026, 8, 31))["2026-08"]
assert month.margin == Decimal("150.00")
assert month.profit == Decimal("200.00")
def test_forgiven_remainder_lands_by_its_own_date(vault, buns):
august, september = date(2026, 8, 10), date(2026, 9, 4)
batch = journal.create_batch(
vault, august, august + timedelta(days=20), [buy(buns["мак"], 10, 20)]
)
journal.write_off_batch(vault, batch.id, 200, september)
months = buckets_by_key(vault, stats.GRAIN_MONTH, date(2026, 9, 30))
assert months["2026-08"].bakery_discount == 0
assert months["2026-09"].bakery_discount == Decimal("200.00")
def test_totals_include_the_discount(vault, buns):
august = date(2026, 8, 10)
batch = journal.create_batch(
vault, august, august + timedelta(days=20), [buy(buns["мак"], 10, 20)]
)
journal.write_off_batch(vault, batch.id, 200, august)
buckets = stats.build_buckets(vault.doc, report_for(vault, date(2026, 8, 31)), stats.GRAIN_MONTH)
assert stats.totals(buckets).bakery_discount == Decimal("200.00")
def test_period_profit_counts_the_discount(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["мак"], 4, 35)])
journal.write_off_batch(vault, batch.id, 60, today)
period = stats.build_periods(vault.doc, report_for(vault, today))[0]
assert period.margin == Decimal("60.00")
assert period.profit == Decimal("120.00") # 60 наценки + 60 прощённых
def test_tips_count_as_money_at_sale_time(vault, buns):
"""Чаевые дают из рук в руки, своей даты у них нет."""
day = date(2026, 8, 10)
journal.create_batch(vault, day, day + timedelta(days=14), [buy(buns["мак"], 50, 20)])
journal.create_sale(vault, day, KIND_RETAIL, [line(buns["мак"], 2, 35)], paid_amount=0, tip=30)
month = buckets_by_key(vault, stats.GRAIN_MONTH, date(2026, 8, 31))["2026-08"]
assert month.collected == Decimal("30.00")
# --- количества и проценты ------------------------------------------------
def test_quantities_are_not_mixed_across_units(vault, buns, today, in_two_weeks):
juice = journal.create_product(vault, "Сок", unit="л", cost_price=80, retail_price=120)
journal.create_batch(
vault, today, in_two_weeks,
[buy(buns["мак"], 10, 20), {"product_id": juice.id, "qty": 5, "unit_cost": 80}],
)
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 3, 35)])
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "1.5", 120)])
bucket = stats.build_buckets(vault.doc, report_for(vault, today), stats.GRAIN_MONTH)[0]
assert bucket.qty_by_unit == {"шт": Decimal("3.000"), "л": Decimal("1.500")}
assert bucket.qty_text == "1,5 л · 3 шт"
def test_packs_are_counted_in_base_units(vault, today, in_two_weeks):
cookies = journal.create_product(vault, "Печенье", cost_price=20, retail_price=30)
journal.set_packs(vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
journal.create_batch(
vault, today, in_two_weeks,
[{"product_id": cookies.id, "qty": 5, "uom": "пачка", "unit_cost": 200}],
)
journal.create_sale(
vault, today, KIND_RETAIL,
[{"product_id": cookies.id, "qty": 2, "uom": "пачка", "unit_price": 200}],
)
bucket = stats.build_buckets(vault.doc, report_for(vault, today), stats.GRAIN_MONTH)[0]
assert bucket.qty_by_unit == {"пачка": Decimal("20.000")}
def test_margin_percentage(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["мак"], 10, 35)])
bucket = stats.build_buckets(vault.doc, report_for(vault, today), stats.GRAIN_MONTH)[0]
assert bucket.margin_pct == Decimal("42.86") # 150 из 350
def test_margin_percentage_without_revenue_is_zero(vault):
assert stats.Bucket(key="x", label="x", start=date.today(), end=date.today()).margin_pct == 0
# --- итоги ----------------------------------------------------------------
def test_totals_sum_every_bucket(three_months):
buckets = stats.build_buckets(three_months.doc, report_for(three_months, date(2026, 8, 31)), stats.GRAIN_MONTH)
total = stats.totals(buckets)
assert total.revenue == sum(b.revenue for b in buckets)
assert total.profit == sum(b.profit for b in buckets)
assert total.sales_count == 4
assert total.start == buckets[0].start
assert total.end == buckets[-1].end
def test_totals_of_nothing_are_zero(vault):
total = stats.totals([])
assert total.revenue == 0 and total.profit == 0 and total.sales_count == 0
def test_totals_keep_units_apart(vault, buns, today, in_two_weeks):
juice = journal.create_product(vault, "Сок", unit="л", cost_price=80, retail_price=120)
journal.create_batch(
vault, today, in_two_weeks,
[buy(buns["мак"], 10, 20), {"product_id": juice.id, "qty": 5, "unit_cost": 80}],
)
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 3, 35)])
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, 2, 120)])
buckets = stats.build_buckets(vault.doc, report_for(vault, today), stats.GRAIN_MONTH)
total = stats.totals(buckets)
assert total.qty_by_unit == {"шт": Decimal("3.000"), "л": Decimal("2.000")}
# --- разрез по партиям ----------------------------------------------------
def test_periods_carry_batch_economics(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["мак"], 4, 35)], tip=25)
journal.create_sale(vault, today, KIND_SELF, [line(buns["мак"], 1, 0)])
period = stats.build_periods(vault.doc, report_for(vault, today))[0]
assert period.batch.id == batch.id
assert period.margin == Decimal("60.00") # 4 × 15
assert period.tips == Decimal("25.00")
assert period.report.consumed_cost == Decimal("20.00")
assert period.profit == Decimal("65.00") # 60 + 25 20
assert period.days_total == 14
def test_period_tips_split_between_batches(vault, buns, today):
first = journal.create_batch(
vault, today - timedelta(days=5), today + timedelta(days=9), [buy(buns["мак"], 3, 20)]
)
second = 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)
periods = {p.batch.id: p for p in stats.build_periods(vault.doc, report_for(vault, today))}
assert periods[first.id].tips == Decimal("30.00") # три штуки из четырёх
assert periods[second.id].tips == Decimal("10.00")
def test_period_quantities_keep_units_apart(vault, buns, today, in_two_weeks):
"""В партии и сок в литрах, и булки в штуках — складывать их нельзя."""
juice = journal.create_product(vault, "Сок", unit="л", cost_price=80, retail_price=120)
journal.create_batch(
vault, today, in_two_weeks,
[buy(buns["мак"], 10, 20), {"product_id": juice.id, "qty": 6, "unit_cost": 80}],
)
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 4, 35)])
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "1.5", 120)])
period = stats.build_periods(vault.doc, report_for(vault, today))[0]
assert period.sold_by_unit == {"шт": Decimal("4.000"), "л": Decimal("1.500")}
assert period.left_by_unit == {"шт": Decimal("6.000"), "л": Decimal("4.500")}
assert period.sold_text == "1,5 л · 4 шт"
assert period.left_text == "4,5 л · 6 шт"
def test_period_quantities_include_what_was_eaten(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["мак"], 3, 35)])
journal.create_sale(vault, today, KIND_SELF, [line(buns["мак"], 2, 0)])
period = stats.build_periods(vault.doc, report_for(vault, today))[0]
assert period.sold_by_unit == {"шт": Decimal("5.000")} # выбыло всего
assert period.left_by_unit == {"шт": Decimal("5.000")}
def test_units_text_hides_empty_units(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
period = stats.build_periods(vault.doc, report_for(vault, today))[0]
assert period.sold_text == "" # ничего не продано — и нечего показывать
assert period.left_text == "10 шт"
def test_periods_are_listed_even_without_sales(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
period = stats.build_periods(vault.doc, report_for(vault, today))[0]
assert period.margin == 0
assert period.report.cost_total == Decimal("200.00")
# --- разрез по товарам ----------------------------------------------------
def test_products_ranked_by_margin(vault, buns, today, in_two_weeks):
journal.create_batch(
vault, today, in_two_weeks,
[buy(buns["мак"], 20, 20), buy(buns["повидло"], 20, 30)],
)
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)]) # 30
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["повидло"], 10, 50)]) # 200
rows = stats.build_products(vault.doc, report_for(vault, today))
assert [r.name for r in rows][:2] == ["Булка с повидлом", "Булка с маком"]
assert rows[0].margin == Decimal("200.00")
assert rows[0].sold_qty == Decimal("10.000")
assert rows[0].margin_pct == Decimal("40.00") # 200 из 500
def test_products_separate_sold_from_eaten(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 20, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 5, 35)])
journal.create_sale(vault, today, KIND_SELF, [line(buns["мак"], 3, 0)])
row = next(r for r in stats.build_products(vault.doc, report_for(vault, today)) if r.name == "Булка с маком")
assert row.sold_qty == Decimal("5.000")
assert row.consumed_qty == Decimal("3.000")
assert row.consumed_cost == Decimal("60.00")
assert row.stock_qty == Decimal("12.000")
def test_products_show_untouched_stock(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["корица"], 8, 25)])
row = next(r for r in stats.build_products(vault.doc, report_for(vault, today)) if r.name == "Булка с корицей")
assert row.sold_qty == 0
assert row.stock_qty == Decimal("8.000")
assert row.stock_cost == Decimal("200.00")
# --- разрез по людям ------------------------------------------------------
def test_people_ranked_by_revenue(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 50, 20)])
vasya = journal.create_counterparty(vault, "Вася")
petya = journal.create_counterparty(vault, "Петя")
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)], counterparty_id=vasya.id)
journal.create_sale(
vault, today, KIND_RETAIL, [line(buns["мак"], 10, 35)],
counterparty_id=petya.id, paid_amount=100, tip=20,
)
rows = stats.build_people(vault.doc)
assert [r.name for r in rows] == ["Петя", "Вася"]
assert rows[0].revenue == Decimal("350.00")
assert rows[0].paid == Decimal("100.00")
assert rows[0].tips == Decimal("20.00")
assert rows[0].debt == Decimal("250.00")
assert rows[0].sales_count == 1
def test_walk_in_sales_grouped_separately(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 50, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)])
rows = stats.build_people(vault.doc)
assert [r.name for r in rows] == ["Без контрагента"]
def test_consumption_is_not_a_person(vault, buns, today, in_two_weeks):
"""Съел сам — это не покупатель, в списке людей ему не место."""
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 50, 20)])
journal.create_sale(vault, today, KIND_SELF, [line(buns["мак"], 2, 0)])
assert stats.build_people(vault.doc) == []
def test_empty_database_produces_empty_sections(vault, today):
report = report_for(vault, today)
assert stats.build_buckets(vault.doc, report, stats.GRAIN_MONTH) == []
assert stats.build_periods(vault.doc, report) == []
assert stats.build_products(vault.doc, report) == []
assert stats.build_people(vault.doc) == []