feat: split a transaction across multiple categories

Один чек часто смешивает несколько категорий трат (гипермаркет: часть — продукты,
часть — бытовая химия). Раньше такую транзакцию приходилось огрублять до одной
категории или дробить вручную на несколько записей. Теперь у транзакции есть
второй режим: 2+ доли (TransactionSplit) с суммами по разным категориям, которые
обязаны в точности складываться в сумму транзакции.

- Новая модель TransactionSplit + миграция.
- split_rule_violation() проверяет инварианты на создании и редактировании.
- category_rollup.py — общий helper, которым теперь пользуются Dashboard, отчёт
  по категориям и бюджеты, учитывая доли сплитов наравне с обычными транзакциями.
- Фильтр по категории в списке транзакций находит и сплит-транзакции.
- Сплиты попадают в полный бэкап/восстановление БД.
- 13 новых backend-тестов.
- Фронтенд: переключатель «Разбить на несколько категорий» в форме транзакции
  с повторяемыми строками и индикатором остатка суммы; строка транзакции в
  списке показывает все категории сплита.
This commit is contained in:
ZProger
2026-08-30 18:42:13 +03:00
parent b045d378f0
commit 0134cdb6bf
20 changed files with 1008 additions and 144 deletions

View File

@@ -15,7 +15,7 @@ from sqlalchemy.orm import selectinload
from app.models.budget import Budget
from app.models.category import Category
from app.models.enums import CategoryKind, TransactionType
from app.models.transaction import Transaction
from app.models.transaction import Transaction, TransactionSplit
from app.schemas.budget import BudgetCreate, BudgetStatus, BudgetStatusResponse, BudgetUpdate
_EAGER = (selectinload(Budget.category),)
@@ -92,7 +92,11 @@ async def get_budget_status(session: AsyncSession, year: int, month: int) -> Bud
counted_ids = set(budget_category_ids).union(
child_id for children in children_by_parent.values() for child_id in children
)
spent_stmt = (
# A split transaction has category_id=NULL on its own row — its spend
# lives on its split lines instead (see category_rollup.py), so a plain
# sum on Transaction.category_id alone would silently under-count a
# budget funded partly by split purchases.
plain_stmt = (
select(Transaction.category_id, func.coalesce(func.sum(Transaction.amount), 0))
.where(
Transaction.category_id.in_(counted_ids),
@@ -102,7 +106,22 @@ async def get_budget_status(session: AsyncSession, year: int, month: int) -> Bud
)
.group_by(Transaction.category_id)
)
spent_by_category: dict[int, Decimal] = {row[0]: row[1] for row in (await session.execute(spent_stmt)).all()}
split_stmt = (
select(TransactionSplit.category_id, func.coalesce(func.sum(TransactionSplit.amount), 0))
.join(Transaction, Transaction.id == TransactionSplit.transaction_id)
.where(
TransactionSplit.category_id.in_(counted_ids),
Transaction.type == TransactionType.EXPENSE,
Transaction.date >= start,
Transaction.date <= end,
)
.group_by(TransactionSplit.category_id)
)
spent_by_category: dict[int, Decimal] = defaultdict(Decimal)
for category_id, amount in (await session.execute(plain_stmt)).all():
spent_by_category[category_id] += amount
for category_id, amount in (await session.execute(split_stmt)).all():
spent_by_category[category_id] += amount
items = []
for budget in budgets: