Files
aurum/backend/app/services/reports_service.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

171 lines
7.0 KiB
Python

"""Long-range spending reports — the analysis the month-scoped Dashboard
breakdown can't answer on its own: "how much did I spend on X this month,
and how much in total over N years" (single-category detail), and "which
categories cost the most over this whole period" (ranking, across all
categories of one kind at once, not just the current month).
"""
from collections import defaultdict
from datetime import date as date_
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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,
CategorySpendingReport,
)
from app.services.category_rollup import rollup_spending_by_top_level_category
def _next_month(year: int, month: int) -> tuple[int, int]:
return (year + 1, 1) if month == 12 else (year, month + 1)
async def get_category_spending_report(
session: AsyncSession, category_id: int, start_date: date_ | None, end_date: date_ | None
) -> CategorySpendingReport:
category = await session.get(Category, category_id)
if category is None:
raise HTTPException(status_code=404, detail="Category not found")
# A top-level category's own report folds in its subcategories' spending
# too (same rollup as the Dashboard breakdown); a subcategory picked
# directly shows just its own transactions — there's nothing beneath it.
category_ids: list[int] = [category_id]
if category.parent_id is None:
child_ids = (
await session.execute(select(Category.id).where(Category.parent_id == category_id))
).scalars().all()
category_ids.extend(child_ids)
# Plain transactions filed directly under one of these categories, plus
# split lines that assign part of a transaction to one of them — same
# two sources category_rollup.py unions for the Dashboard/ranking report.
plain_stmt = select(Transaction.id, Transaction.date, Transaction.amount).where(
Transaction.category_id.in_(category_ids)
)
split_stmt = (
select(TransactionSplit.transaction_id, Transaction.date, TransactionSplit.amount)
.join(Transaction, Transaction.id == TransactionSplit.transaction_id)
.where(TransactionSplit.category_id.in_(category_ids))
)
if start_date:
plain_stmt = plain_stmt.where(Transaction.date >= start_date)
split_stmt = split_stmt.where(Transaction.date >= start_date)
if end_date:
plain_stmt = plain_stmt.where(Transaction.date <= end_date)
split_stmt = split_stmt.where(Transaction.date <= end_date)
plain_rows = (await session.execute(plain_stmt)).all()
split_rows = (await session.execute(split_stmt)).all()
contributions = [(r[0], r[1], r[2]) for r in plain_rows] + [(r[0], r[1], r[2]) for r in split_rows]
empty = CategorySpendingReport(
category_id=category.id,
category_name=category.name,
category_color=category.color,
category_icon=category.icon,
start_date=start_date,
end_date=end_date,
total_amount=Decimal("0"),
transaction_count=0,
average_per_month=Decimal("0"),
series=[],
)
if not contributions:
return empty
dates = [txn_date for _, txn_date, _ in contributions]
effective_start = start_date or min(dates)
effective_end = end_date or max(dates)
by_month: dict[tuple[int, int], Decimal] = defaultdict(Decimal)
for _, txn_date, amount in contributions:
by_month[(txn_date.year, txn_date.month)] += amount
total_amount = sum((amount for _, _, amount in contributions), Decimal("0"))
# Distinct transactions, not rows — a transaction split across two of
# these categories (e.g. parent + one of its children) must count once,
# the same as a plain transaction filed under just one of them.
total_count = len({transaction_id for transaction_id, _, _ in contributions})
series: list[CategorySpendingPoint] = []
year, month = effective_start.year, effective_start.month
while (year, month) <= (effective_end.year, effective_end.month):
series.append(CategorySpendingPoint(year=year, month=month, amount=by_month.get((year, month), Decimal("0"))))
year, month = _next_month(year, month)
months_count = len(series)
average_per_month = (total_amount / months_count).quantize(Decimal("0.01")) if months_count else Decimal("0")
return CategorySpendingReport(
category_id=category.id,
category_name=category.name,
category_color=category.color,
category_icon=category.icon,
start_date=effective_start,
end_date=effective_end,
total_amount=total_amount,
transaction_count=total_count,
average_per_month=average_per_month,
series=series,
)
_KIND_TO_TRANSACTION_TYPE = {
CategoryKind.EXPENSE: TransactionType.EXPENSE,
CategoryKind.INCOME: TransactionType.INCOME,
}
async def get_category_ranking_report(
session: AsyncSession, kind: CategoryKind, start_date: date_ | None, end_date: date_ | None
) -> CategoryRankingReport:
"""All categories of one kind, ranked by total spent/earned over an
arbitrary period — "which category costs the most" across the whole
range, unlike the month-scoped Dashboard breakdown or the
single-category detail above."""
# A transaction's type already restricts it to categories of the
# matching kind (enforced at write time by _ensure_category_matches_type
# in routes/transactions.py), so filtering by transaction_type below is
# enough — no separate kind filter needed, and the shared rollup already
# unions plain transactions with split lines the same way the Dashboard
# breakdown does.
rows = await rollup_spending_by_top_level_category(
session, transaction_type=_KIND_TO_TRANSACTION_TYPE[kind], start_date=start_date, end_date=end_date
)
total_amount = sum((row.amount for row in rows), Decimal("0"))
def _percent(amount: Decimal) -> float:
return float(amount / total_amount * 100) if total_amount else 0.0
items = [
CategoryRankingItem(
category_id=row.category_id,
name=row.name,
color=row.color,
icon=row.icon,
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
]
return CategoryRankingReport(start_date=start_date, end_date=end_date, total_amount=total_amount, items=items)