Files
aurum/backend/tests/test_reports.py
ZProger 140f58d33f fix: restrict transaction splits to one parent category, show subcategory breakdown
Раньше сплит позволял смешивать вообще любые категории. Реальная задача —
разложить одну покупку по подкатегориям ОДНОЙ базовой категории (100 € на
продукты: сколько на сладости, сколько на уход). А раз Dashboard и рейтинг
категорий сворачивают подкатегории в родителя, эта разбивка нигде не была
видна.

- _build_splits() требует, чтобы все категории сплита были одной родительской
  категорией или её прямыми подкатегориями.
- category_rollup.py отдаёт для каждого родительского тотала список
  category-источников, которые в него скинулись (children) — пусто, если
  источник один.
- Dashboard и рейтинг категорий: строка с непустым children раскрывается по
  клику, показывая вклад каждой подкатегории.
- Форма транзакции: выбор категории при сплите — сначала базовая (верхний
  уровень), затем строки сплита выбирают саму базовую или её подкатегорию.
2026-08-30 19:06:29 +03:00

79 lines
3.2 KiB
Python

"""Long-range reports: the category ranking (which categories cost the
most over an arbitrary period) and the single-category spending detail —
both split-aware via services/category_rollup.py, same as the Dashboard
breakdown (see test_dashboard.py).
"""
from decimal import Decimal
from httpx import AsyncClient
from tests.helpers import money, txn_payload as _txn
async def test_ranking_rolls_up_a_split_into_one_item_with_children(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
sweets = (
await client.post("/categories", json={"name": "Sweets", "kind": "expense", "color": "#7a869a", "parent_id": groceries})
).json()["id"]
await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=None,
date="2026-08-01",
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": sweets, "amount": "30.00"},
],
),
)
resp = await client.get("/reports/category-ranking", params={"kind": "expense"})
items = {item["name"]: item for item in resp.json()["items"]}
assert "Sweets" not in items
assert money(items["Groceries"]["amount"]) == Decimal("100.00")
children = {child["name"]: child for child in items["Groceries"]["children"]}
assert money(children["Groceries"]["amount"]) == Decimal("70.00")
assert money(children["Sweets"]["amount"]) == Decimal("30.00")
async def test_ranking_item_with_a_single_source_has_no_children(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
await client.post("/transactions", json=_txn(account_id, amount="50.00", category_id=groceries, date="2026-08-01"))
resp = await client.get("/reports/category-ranking", params={"kind": "expense"})
items = {item["name"]: item for item in resp.json()["items"]}
assert items["Groceries"]["children"] == []
async def test_category_spending_report_counts_a_subcategorys_split_share(client: AsyncClient, account_id, categories):
"""A top-level category's own report folds in both its plain
transactions and any split lines landing on it or its subcategories."""
groceries = categories["Groceries"]["id"]
sweets = (
await client.post("/categories", json={"name": "Sweets", "kind": "expense", "color": "#7a869a", "parent_id": groceries})
).json()["id"]
await client.post(
"/transactions",
json=_txn(
account_id,
amount="100.00",
category_id=None,
date="2026-08-01",
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": sweets, "amount": "30.00"},
],
),
)
parent_report = (await client.get("/reports/category-spending", params={"category_id": groceries})).json()
assert money(parent_report["total_amount"]) == Decimal("100.00")
assert parent_report["transaction_count"] == 1
child_report = (await client.get("/reports/category-spending", params={"category_id": sweets})).json()
assert money(child_report["total_amount"]) == Decimal("30.00")