/** * Raporlar (Bölüm 6.14). Saf hesaplar: kategori dağılımı (üst kategori toplamları ve alt kategori * kırılımı), toplam gidere oran, önceki aya göre değişim, tasarruf oranı. Tutarlar kuruş; oranlar * bir ondalıklı yüzde (14,3). */ /** Payın toplamdaki yüzdesi, bir ondalık (toplam 0 ise 0). */ export function sharePct(part: number, total: number): number { if (total <= 0) return 0; return Math.round((part * 1000) / total) / 10; } /** Önceki döneme göre yüzde değişim; önceki 0 ise hesaplanamaz (null). */ export function changePct(current: number, previous: number): number | null { if (previous === 0) return null; return Math.round(((current - previous) * 1000) / previous) / 10; } /** Tasarruf oranı = (gelir − gider) / gelir; gelir yoksa null. */ export function savingsRate(incomeMinor: number, expenseMinor: number): number | null { if (incomeMinor <= 0) return null; return sharePct(incomeMinor - expenseMinor, incomeMinor); } export type ReportCategory = { id: string; parentId: string | null; name: string; order: number; }; export type CategoryShare = { id: string; name: string; depth: number; amountMinor: number; sharePct: number; prevMinor: number; changeMinor: number; changePct: number | null; }; export type TopCategoryShare = CategoryShare & { /** Alt kategoriler (ağaç sırasıyla, derinlik 1+); tutarları kendi tutarlarıdır. */ children: CategoryShare[]; /** Üst kategoriye doğrudan (alt kategori seçilmeden) yazılan tutar. */ ownMinor: number; }; export type CategoryBreakdown = { totalMinor: number; prevTotalMinor: number; rows: TopCategoryShare[]; }; /** * Üst kategori toplamları (alt ağaç dahil) ve alt kategori kırılımı. Tutarı ve önceki tutarı * sıfır olan kategoriler gösterilmez. Satırlar tutara göre büyükten küçüğe. */ export function categoryBreakdown( categories: readonly ReportCategory[], current: ReadonlyMap, previous: ReadonlyMap, ): CategoryBreakdown { const byId = new Map(categories.map((c) => [c.id, c])); const children = new Map(); const roots: ReportCategory[] = []; for (const c of categories) { if (c.parentId && byId.has(c.parentId)) children.set(c.parentId, [...(children.get(c.parentId) ?? []), c]); else roots.push(c); } const sorted = (list: ReportCategory[]) => [...list].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, "tr")); const totalMinor = [...current.values()].reduce((a, b) => a + b, 0); const prevTotalMinor = [...previous.values()].reduce((a, b) => a + b, 0); const share = (id: string, name: string, depth: number, cur: number, prev: number) => ({ id, name, depth, amountMinor: cur, sharePct: sharePct(cur, totalMinor), prevMinor: prev, changeMinor: cur - prev, changePct: changePct(cur, prev), }); const rows: TopCategoryShare[] = []; for (const root of roots) { const kids: CategoryShare[] = []; let cur = current.get(root.id) ?? 0; let prev = previous.get(root.id) ?? 0; const walk = (list: ReportCategory[], depth: number, seen: Set) => { for (const c of sorted(list)) { if (seen.has(c.id)) continue; seen.add(c.id); const a = current.get(c.id) ?? 0; const p = previous.get(c.id) ?? 0; cur += a; prev += p; const sub = children.get(c.id) ?? []; const before = kids.length; kids.push(share(c.id, c.name, depth, a, p)); walk(sub, depth + 1, seen); // Kendisi ve altı boşsa satırı çıkar. if (a === 0 && p === 0 && kids.length === before + 1) kids.pop(); } }; walk(children.get(root.id) ?? [], 1, new Set([root.id])); if (cur === 0 && prev === 0) continue; rows.push({ ...share(root.id, root.name, 0, cur, prev), ownMinor: current.get(root.id) ?? 0, children: kids, }); } rows.sort((a, b) => b.amountMinor - a.amountMinor || a.name.localeCompare(b.name, "tr")); return { totalMinor, prevTotalMinor, rows }; }