food-market/tests/test_stats.py
Claude 38e9614a25 Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.

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

Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.

График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.

Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.

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

439 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сводные цифры по периодам, товарам и людям."""
from datetime import date, timedelta
from decimal import Decimal
import pytest
from app import journal, ledger, 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_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) == []