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

@@ -52,13 +52,15 @@ _TYPE_TO_CATEGORY_KIND = {
async def _ensure_category_matches_type(
session: AsyncSession, category_id: int | None, transaction_type: TransactionType
) -> None:
) -> Category | None:
"""A category picked for an income transaction must itself be an income
category (and likewise for expense) — otherwise the dashboard's spending
breakdown, which only joins EXPENSE-typed rows, would silently misclassify
the entry."""
the entry. Returns the fetched category (or None for category_id=None) so
callers that also need the row itself — _build_splits, below — don't have
to fetch it a second time."""
if category_id is None:
return
return None
expected_kind = _TYPE_TO_CATEGORY_KIND.get(transaction_type)
category = await session.get(Category, category_id)
if category is None:
@@ -68,13 +70,32 @@ async def _ensure_category_matches_type(
status_code=400,
detail=f"Category '{category.name}' is a {category.kind.value} category and cannot be used for a {transaction_type.value} transaction",
)
return category
async def _build_splits(
session: AsyncSession, splits: list[TransactionSplitInput], transaction_type: TransactionType
) -> list[TransactionSplit]:
"""A split's whole point is dividing one purchase's total across the
*subcategories of one parent* (a hypermarket receipt: part groceries ->
Sweets, part -> Alcohol) — not across unrelated top-level categories, or
the numbers would roll up into two different parents and the "spent X on
Groceries, split between Sweets/Alcohol" picture the feature exists for
falls apart. Each split may point at that parent category itself (an
unspecified-subcategory line) or at any one of its direct children —
enforced by requiring every split's own top-level ancestor
(parent_id, or its own id if it has none) to agree.
"""
top_level_ids: set[int] = set()
for split in splits:
await _ensure_category_matches_type(session, split.category_id, transaction_type)
category = await _ensure_category_matches_type(session, split.category_id, transaction_type)
assert category is not None # split.category_id is required (not Optional) on the schema
top_level_ids.add(category.parent_id if category.parent_id is not None else category.id)
if len(top_level_ids) > 1:
raise HTTPException(
status_code=400,
detail="All split categories must be the same parent category or its direct subcategories",
)
return [TransactionSplit(category_id=s.category_id, amount=s.amount, note=s.note) for s in splits]
@@ -196,17 +217,13 @@ async def bulk_create_transactions(
instead of leaving a half-imported statement behind."""
for item in payload.items:
await _ensure_category_matches_type(session, item.category_id, item.type)
for split in item.splits or []:
await _ensure_category_matches_type(session, split.category_id, item.type)
transactions = []
for item in payload.items:
transaction = Transaction(**item.model_dump(exclude={"tag_ids", "splits"}))
transaction.tags = await _resolve_tags(session, item.tag_ids)
if item.splits:
transaction.splits = [
TransactionSplit(category_id=s.category_id, amount=s.amount, note=s.note) for s in item.splits
]
transaction.splits = await _build_splits(session, item.splits, item.type)
transactions.append(transaction)
session.add_all(transactions)

View File

@@ -1,6 +1,18 @@
from decimal import Decimal
from pydantic import BaseModel
from pydantic import BaseModel, Field
class CategoryBreakdownChildItem(BaseModel):
"""One subcategory's (or the parent's own direct, un-subcategorized)
share of a CategoryBreakdownItem's total — see
services/category_rollup.py's CategoryRollupChildItem."""
category_id: int
name: str
color: str
icon: str | None
amount: Decimal
class CategoryBreakdownItem(BaseModel):
@@ -10,6 +22,10 @@ class CategoryBreakdownItem(BaseModel):
icon: str | None
amount: Decimal
percent: float
# Populated only when this slice's spend came from more than one
# distinct category (subcategories, or a mix of the parent itself and
# its children) — e.g. a receipt split across "Groceries" subcategories.
children: list[CategoryBreakdownChildItem] = Field(default_factory=list)
class DashboardSummary(BaseModel):

View File

@@ -1,7 +1,19 @@
from datetime import date as date_
from decimal import Decimal
from pydantic import BaseModel
from pydantic import BaseModel, Field
class CategoryRankingChildItem(BaseModel):
"""One subcategory's (or the parent's own direct, un-subcategorized)
share of a CategoryRankingItem's total — see
services/category_rollup.py's CategoryRollupChildItem."""
category_id: int
name: str
color: str
icon: str | None
amount: Decimal
class CategorySpendingPoint(BaseModel):
@@ -31,6 +43,10 @@ class CategoryRankingItem(BaseModel):
amount: Decimal
percent: float
transaction_count: int
# Populated only when this category's spend came from more than one
# distinct category (subcategories, or a mix of itself and its
# children) — e.g. a receipt split across "Groceries" subcategories.
children: list[CategoryRankingChildItem] = Field(default_factory=list)
class CategoryRankingReport(BaseModel):

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
]