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