"""Сводные цифры по периодам, товарам и людям.""" 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 · 17–23 августа 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) == []