Надо было отдать 5 500, взяли 5 000, полтысячи оставили. Долг закрыт, но денег никто не отдавал. У расчёта с пекарней появился вид: деньги или прощённый остаток. Свалить их в одну кучу нельзя — тогда «Отдано пекарне» в статистике показывало бы суммы, которых не платил. Поэтому «Отдано» — только живые деньги, «Прощено» — отдельная колонка везде: в закупках, в сводке, в статистике по периодам и по времени. В прибыль прощённое идёт целиком: эти деньги предназначались пекарне, а остались у тебя. «Осталось собрать» на ту же сумму уменьшается — собирать под прощённый остаток уже не надо. Простить больше долга нельзя: лишнее не засчитывается и, в отличие от переплаты деньгами, никуда не переливается. В диалоге платежа галочка «Остаток простили — закрыть партию»: вводишь сколько отдал, остаток уходит отдельной записью, партия закрывается сама, потому что её долг становится нулём. Миграция схемы 3→4 помечает всё записанное раньше как живые деньги. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
499 lines
22 KiB
Python
499 lines
22 KiB
Python
"""Сводные цифры по периодам, товарам и людям."""
|
||
|
||
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) == []
|