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

@@ -26,7 +26,7 @@ from app.models.goal import Goal, GoalContribution
from app.models.recurring import RecurringTransaction
from app.models.settings import AppSettings
from app.models.tag import Tag
from app.models.transaction import Transaction
from app.models.transaction import Transaction, TransactionSplit
from app.schemas.backup import (
AccountBackup,
AppSettingsBackup,
@@ -42,6 +42,7 @@ from app.schemas.backup import (
RecurringTransactionBackup,
TagBackup,
TransactionBackup,
TransactionSplitBackup,
)
BACKUP_FORMAT_VERSION = 1
@@ -52,6 +53,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
categories = (await session.execute(select(Category))).scalars().all()
tags = (await session.execute(select(Tag))).scalars().all()
transactions = (await session.execute(select(Transaction).options(selectinload(Transaction.tags)))).scalars().all()
transaction_splits = (await session.execute(select(TransactionSplit))).scalars().all()
assets = (await session.execute(select(Asset))).scalars().all()
valuations = (await session.execute(select(AssetValuation))).scalars().all()
crypto_holdings = (await session.execute(select(CryptoHolding))).scalars().all()
@@ -75,6 +77,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
)
for row in transactions
],
transaction_splits=[TransactionSplitBackup.model_validate(row) for row in transaction_splits],
assets=[AssetBackup.model_validate(row) for row in assets],
asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations],
crypto_holdings=[CryptoHoldingBackup.model_validate(row) for row in crypto_holdings],
@@ -110,6 +113,13 @@ def _validate_references(payload: BackupPayload) -> None:
if tag_id not in tag_ids:
raise HTTPException(400, f"Transaction {t.id} references unknown tag_id {tag_id}")
transaction_ids = {row.id for row in payload.transactions}
for s in payload.transaction_splits:
if s.transaction_id not in transaction_ids:
raise HTTPException(400, f"Transaction split {s.id} references unknown transaction_id {s.transaction_id}")
if s.category_id is not None and s.category_id not in category_ids:
raise HTTPException(400, f"Transaction split {s.id} references unknown category_id {s.category_id}")
for v in payload.asset_valuations:
if v.asset_id not in asset_ids:
raise HTTPException(400, f"Asset valuation {v.id} references unknown asset_id {v.asset_id}")
@@ -176,8 +186,10 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
await session.execute(delete(GoalContribution))
await session.execute(delete(Goal))
await session.execute(delete(RecurringTransaction))
# Deleting transactions cascades transaction_tags rows (ON DELETE
# CASCADE) — tags themselves still need their own delete.
# Deleting transactions cascades transaction_tags and
# transaction_splits rows (ON DELETE CASCADE) — deleted explicitly
# here anyway to keep this block's ordering self-documenting.
await session.execute(delete(TransactionSplit))
await session.execute(delete(Transaction))
await session.execute(delete(Tag))
await session.execute(delete(Asset))
@@ -208,6 +220,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
row.id: Transaction(**row.model_dump(exclude={"tag_ids"}), tags=[]) for row in payload.transactions
}
session.add_all(transactions_by_id.values())
session.add_all(TransactionSplit(**row.model_dump()) for row in payload.transaction_splits)
session.add_all(AssetValuation(**row.model_dump()) for row in payload.asset_valuations)
session.add_all(CryptoHolding(**row.model_dump()) for row in payload.crypto_holdings)
@@ -229,6 +242,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
await _reset_sequence(session, "tags", payload.tags)
await _reset_sequence(session, "assets", payload.assets)
await _reset_sequence(session, "transactions", payload.transactions)
await _reset_sequence(session, "transaction_splits", payload.transaction_splits)
await _reset_sequence(session, "asset_valuations", payload.asset_valuations)
await _reset_sequence(session, "crypto_transactions", payload.crypto_transactions)
await _reset_sequence(session, "budgets", payload.budgets)