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

@@ -272,6 +272,19 @@ const ru = {
"transactions.form.errorSelectDestination": "Выберите счёт назначения для перевода",
"transactions.form.errorSameAccount": "Счёт назначения должен отличаться от исходного счёта",
"transactions.form.saveError": "Не удалось сохранить транзакцию. Проверьте данные и попробуйте снова.",
"transactions.form.splitToggle": "Разбить на несколько категорий",
"transactions.form.splitToggleOff": "Одна категория",
"transactions.form.splitHint": "Одна покупка — несколько категорий, например часть чека — продукты, часть — бытовая химия.",
"transactions.form.splitCategoryPlaceholder": "Выберите категорию",
"transactions.form.splitNotePlaceholder": "Заметка (необязательно)",
"transactions.form.splitAddRow": "Добавить категорию",
"transactions.form.splitRemoveRow": "Убрать категорию",
"transactions.form.splitRemainingLabel": "Осталось распределить: {{amount}}",
"transactions.form.splitOverAllocatedLabel": "Превышение суммы на: {{amount}}",
"transactions.form.splitFullyAllocatedLabel": "Вся сумма распределена",
"transactions.form.errorSplitMinRows": "Нужно как минимум 2 категории для разбивки",
"transactions.form.errorSplitIncomplete": "Выберите категорию и сумму для каждой строки разбивки",
"transactions.form.errorSplitMismatch": "Сумма по категориям должна совпадать с суммой транзакции",
"reports.categoryLabel": "Категория",
"reports.expenseGroup": "Расходы",
@@ -734,6 +747,19 @@ const en: Record<keyof typeof ru, string> = {
"transactions.form.errorSelectDestination": "Select a destination account for the transfer",
"transactions.form.errorSameAccount": "The destination account must be different from the source account",
"transactions.form.saveError": "Failed to save the transaction. Check the data and try again.",
"transactions.form.splitToggle": "Split across multiple categories",
"transactions.form.splitToggleOff": "Single category",
"transactions.form.splitHint": "One purchase, several categories — e.g. part of a receipt is groceries, part is household chemicals.",
"transactions.form.splitCategoryPlaceholder": "Select a category",
"transactions.form.splitNotePlaceholder": "Note (optional)",
"transactions.form.splitAddRow": "Add category",
"transactions.form.splitRemoveRow": "Remove category",
"transactions.form.splitRemainingLabel": "Remaining to allocate: {{amount}}",
"transactions.form.splitOverAllocatedLabel": "Over-allocated by: {{amount}}",
"transactions.form.splitFullyAllocatedLabel": "Fully allocated",
"transactions.form.errorSplitMinRows": "Splitting needs at least 2 categories",
"transactions.form.errorSplitIncomplete": "Pick a category and an amount for every split row",
"transactions.form.errorSplitMismatch": "Split amounts must add up to the transaction amount",
"reports.categoryLabel": "Category",
"reports.expenseGroup": "Expenses",