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

@@ -1,5 +1,5 @@
import { useState } from "react";
import { ArrowLeftRight, CalendarSearch, Pencil, StickyNote, Trash2 } from "lucide-react";
import { ArrowLeftRight, CalendarSearch, Pencil, SquareDivide, StickyNote, Trash2 } from "lucide-react";
import { Dialog } from "@/components/ui/Dialog";
import { getCategoryIcon } from "@/lib/icons";
import { formatCurrency, formatTransactionDate } from "@/lib/format";
@@ -35,8 +35,14 @@ export function TransactionsTable({ items, onEdit, onDelete, onJumpToMonth }: Tr
{items.map((tx) => {
const isTransfer = tx.type === "transfer";
const isExpense = tx.type === "expense";
const Icon = isTransfer ? ArrowLeftRight : getCategoryIcon(tx.category?.icon);
const color = isTransfer ? "var(--text-muted)" : tx.category?.color ?? "var(--text-muted)";
const isSplit = tx.splits.length > 0;
const Icon = isTransfer ? ArrowLeftRight : isSplit ? SquareDivide : getCategoryIcon(tx.category?.icon);
const color = isTransfer || isSplit ? "var(--text-muted)" : tx.category?.color ?? "var(--text-muted)";
const categoryLabel = isSplit
? tx.splits.map((split) => (split.category ? translateCategoryName(split.category.name) : "?")).join(" + ")
: tx.category
? translateCategoryName(tx.category.name)
: null;
return (
<li key={tx.id} className="group flex items-center gap-3 py-3">
@@ -52,7 +58,7 @@ export function TransactionsTable({ items, onEdit, onDelete, onJumpToMonth }: Tr
<span className="block truncate text-xs text-text-muted">
{formatTransactionDate(tx.date, Boolean(onJumpToMonth))} · {tx.account.name}
{isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""}
{tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""}
{categoryLabel ? ` · ${categoryLabel}` : ""}
{tx.tags.length > 0 ? ` · ${tx.tags.map((tag) => tag.name).join(", ")}` : ""}
</span>
</span>