fix: restrict transaction splits to one parent category, show subcategory breakdown

Раньше сплит позволял смешивать вообще любые категории. Реальная задача —
разложить одну покупку по подкатегориям ОДНОЙ базовой категории (100 € на
продукты: сколько на сладости, сколько на уход). А раз Dashboard и рейтинг
категорий сворачивают подкатегории в родителя, эта разбивка нигде не была
видна.

- _build_splits() требует, чтобы все категории сплита были одной родительской
  категорией или её прямыми подкатегориями.
- category_rollup.py отдаёт для каждого родительского тотала список
  category-источников, которые в него скинулись (children) — пусто, если
  источник один.
- Dashboard и рейтинг категорий: строка с непустым children раскрывается по
  клику, показывая вклад каждой подкатегории.
- Форма транзакции: выбор категории при сплите — сначала базовая (верхний
  уровень), затем строки сплита выбирают саму базовую или её подкатегорию.
This commit is contained in:
ZProger
2026-08-30 19:06:29 +03:00
parent f778742134
commit 140f58d33f
16 changed files with 580 additions and 130 deletions

View File

@@ -127,14 +127,20 @@ async def test_more_than_eight_expense_categories_roll_up_into_other(client: Asy
assert money(other[0]["amount"]) == Decimal("10.00")
async def test_split_transaction_counts_each_split_toward_its_own_category(
async def test_split_transaction_rolls_up_into_one_slice_with_a_children_breakdown(
client: AsyncClient, account_id, categories
):
"""A hypermarket receipt split between Groceries and Shopping must show
up as two separate slices on the donut, not one lump under whichever
category happened to be picked."""
"""A hypermarket receipt split between Groceries and one of its own
subcategories (Sweets) must show up as a single Groceries slice on the
donut with the full 100.00 total — a split's categories always share one
parent (see routes/transactions.py's _build_splits), so it never
fragments into separate top-level slices — but the slice must expose a
children breakdown so the Groceries/Sweets split is still visible."""
groceries = categories["Groceries"]["id"]
shopping = categories["Shopping"]["id"]
sweets_resp = await client.post(
"/categories", json={"name": "Sweets", "kind": "expense", "color": "#7a869a", "parent_id": groceries}
)
sweets = sweets_resp.json()["id"]
await client.post(
"/transactions",
json=_txn(
@@ -144,7 +150,7 @@ async def test_split_transaction_counts_each_split_toward_its_own_category(
date="2026-08-01",
splits=[
{"category_id": groceries, "amount": "70.00"},
{"category_id": shopping, "amount": "30.00"},
{"category_id": sweets, "amount": "30.00"},
],
),
)
@@ -154,5 +160,25 @@ async def test_split_transaction_counts_each_split_toward_its_own_category(
breakdown = {row["name"]: row for row in body["spending_by_category"]}
assert money(body["spent"]) == Decimal("100.00")
assert money(breakdown["Groceries"]["amount"]) == Decimal("70.00")
assert money(breakdown["Shopping"]["amount"]) == Decimal("30.00")
assert "Sweets" not in breakdown
assert money(breakdown["Groceries"]["amount"]) == Decimal("100.00")
children = {child["name"]: child for child in breakdown["Groceries"]["children"]}
# The 70.00 line was filed directly on Groceries itself (no
# subcategory), so it shows up as a child entry with Groceries' own id
# and name too — same as any genuine subcategory would.
assert money(children["Groceries"]["amount"]) == Decimal("70.00")
assert money(children["Sweets"]["amount"]) == Decimal("30.00")
async def test_a_single_category_slice_has_no_children_breakdown(client: AsyncClient, account_id, categories):
"""A category funded by exactly one source (the common case — no
splitting, no subcategories involved) shouldn't show a redundant
one-item breakdown of itself."""
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("/dashboard/summary", params={"year": 2026, "month": 8})
breakdown = {row["name"]: row for row in resp.json()["spending_by_category"]}
assert breakdown["Groceries"]["children"] == []