mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
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:
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user