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

@@ -13,7 +13,7 @@ funded partly by plain transactions and partly by split lines still shows
one correct total.
"""
from collections import defaultdict
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import date as date_
from decimal import Decimal
@@ -25,6 +25,20 @@ from app.models.enums import TransactionType
from app.models.transaction import Transaction, TransactionSplit
@dataclass
class CategoryRollupChildItem:
"""One leaf category's share of its parent's total — a real
subcategory, or the parent category itself when some of the spend was
filed directly under it with no more specific subcategory (category_id
equals the enclosing CategoryRollupItem.category_id in that case)."""
category_id: int
name: str
color: str
icon: str | None
amount: Decimal
@dataclass
class CategoryRollupItem:
category_id: int
@@ -37,6 +51,13 @@ class CategoryRollupItem:
# transaction counts once per top-level category it touches, same as a
# plain one counts once for its own category.
transaction_count: int
# Populated only when more than one distinct category actually fed this
# total (a subcategory, or a direct-to-parent line, or several of
# either) — e.g. a receipt split between "Sweets" and "Alcohol" under
# "Groceries". A single-source category (the common case, no splitting
# or subcategories involved) leaves this empty rather than showing a
# redundant one-item breakdown of itself.
children: list[CategoryRollupChildItem] = field(default_factory=list)
async def _raw_category_contributions(
@@ -92,15 +113,34 @@ async def rollup_spending_by_top_level_category(
amount_by_effective: dict[int, Decimal] = defaultdict(Decimal)
txn_ids_by_effective: dict[int, set[int]] = defaultdict(set)
# effective (top-level) category id -> {actual leaf category_id: amount}
# — the leaf is the same as the effective id when a contribution was
# filed directly on the parent, and a genuine child id otherwise.
amount_by_leaf: dict[int, dict[int, Decimal]] = defaultdict(lambda: defaultdict(Decimal))
for transaction_id, category_id, amount in contributions:
category = categories_by_id.get(category_id)
effective_id = category.parent_id if category and category.parent_id is not None else category_id
amount_by_effective[effective_id] += amount
amount_by_leaf[effective_id][category_id] += amount
txn_ids_by_effective[effective_id].add(transaction_id)
items: list[CategoryRollupItem] = []
for effective_id, amount in amount_by_effective.items():
category = categories_by_id.get(effective_id)
leaf_amounts = amount_by_leaf[effective_id]
children: list[CategoryRollupChildItem] = []
if len(leaf_amounts) > 1:
for leaf_id, leaf_amount in sorted(leaf_amounts.items(), key=lambda pair: -pair[1]):
leaf_category = categories_by_id.get(leaf_id)
children.append(
CategoryRollupChildItem(
category_id=leaf_id,
name=leaf_category.name if leaf_category else "?",
color=leaf_category.color if leaf_category else "#898781",
icon=leaf_category.icon if leaf_category else None,
amount=leaf_amount,
)
)
items.append(
CategoryRollupItem(
category_id=effective_id,
@@ -110,6 +150,7 @@ async def rollup_spending_by_top_level_category(
sort_order=category.sort_order if category else 0,
amount=amount,
transaction_count=len(txn_ids_by_effective[effective_id]),
children=children,
)
)
items.sort(key=lambda item: (-item.amount, item.sort_order))

View File

@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import TransactionType
from app.models.transaction import Transaction
from app.schemas.dashboard import CategoryBreakdownItem, DashboardSummary
from app.schemas.dashboard import CategoryBreakdownChildItem, CategoryBreakdownItem, DashboardSummary
from app.services.category_rollup import rollup_spending_by_top_level_category
# Categorical slots are capped at 8 (dataviz skill: a 9th series folds into "Other",
@@ -54,6 +54,13 @@ async def get_dashboard_summary(session: AsyncSession, year: int, month: int) ->
CategoryBreakdownItem(
category_id=row.category_id, name=row.name, color=row.color, icon=row.icon,
amount=row.amount, percent=_percent(row.amount),
children=[
CategoryBreakdownChildItem(
category_id=child.category_id, name=child.name, color=child.color, icon=child.icon,
amount=child.amount,
)
for child in row.children
],
)
for row in top_rows
]

View File

@@ -16,6 +16,7 @@ from app.models.category import Category
from app.models.enums import CategoryKind, TransactionType
from app.models.transaction import Transaction, TransactionSplit
from app.schemas.reports import (
CategoryRankingChildItem,
CategoryRankingItem,
CategoryRankingReport,
CategorySpendingPoint,
@@ -155,6 +156,13 @@ async def get_category_ranking_report(
amount=row.amount,
percent=_percent(row.amount),
transaction_count=row.transaction_count,
children=[
CategoryRankingChildItem(
category_id=child.category_id, name=child.name, color=child.color, icon=child.icon,
amount=child.amount,
)
for child in row.children
],
)
for row in rows
]