feat(reports): rapor ve dashboard grafiği motorları

- periyot aralıkları (haftalık, aylık, altı aylık, yıllık) ve kümülatif dönüşüm
- kategori payı, alt kategori kırılımı, önceki aya göre değişim, tasarruf oranı
- kategori, trend, kişi/hesap ve bütçe raporu servisleri; gizli hesap kuralları; testler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 00:29:25 +03:00
parent d77a2efcb8
commit bc2a8a3bde
9 changed files with 1050 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { cumulative, overviewRange, weekday } from "./overview";
describe("overviewRange", () => {
it("haftalık: Pazartesi–Pazar, bugünü içerir", () => {
// 2026-11-07 Cumartesi.
expect(weekday("2026-11-07")).toBe(5);
const r = overviewRange("week", "2026-11-07");
expect(r).toMatchObject({ granularity: "day", from: "2026-11-02", to: "2026-11-08" });
expect(r.buckets).toHaveLength(7);
// Ay sınırını aşan hafta.
expect(overviewRange("week", "2026-12-01").from).toBe("2026-11-30");
});
it("aylık: bütçe ayının günleri (başlangıç günü 15)", () => {
const r = overviewRange("month", "2026-11-07", 15);
expect(r.from).toBe("2026-10-15");
expect(r.to).toBe("2026-11-14");
expect(r.buckets).toHaveLength(31);
expect(overviewRange("month", "2026-11-07").buckets).toHaveLength(30);
});
it("altı aylık ve yıllık: bu ay dahil son 6 / 12 bütçe ayı", () => {
const half = overviewRange("half", "2026-11-07");
expect(half).toMatchObject({ granularity: "month", from: "2026-06-01", to: "2026-11-30" });
expect(half.buckets).toEqual([
"2026-06",
"2026-07",
"2026-08",
"2026-09",
"2026-10",
"2026-11",
]);
const year = overviewRange("year", "2026-01-20", 15);
expect(year.buckets[0]).toBe("2025-02");
expect(year.buckets.at(-1)).toBe("2026-01");
});
});
describe("cumulative", () => {
it("seriler ayrı birikir; boş nokta boş kalır, toplamı bozmaz", () => {
const out = cumulative([
{ key: "a", plannedIncome: 100, budget: 50, actual: 10 },
{ key: "b", plannedIncome: 0, budget: null, actual: 20 },
{ key: "c", plannedIncome: 50, budget: 30, actual: null },
]);
expect(out.map((p) => [p.plannedIncome, p.budget, p.actual])).toEqual([
[100, 50, 10],
[100, null, 30],
[150, 80, null],
]);
});
});
+76
View File
@@ -0,0 +1,76 @@
import { budgetPeriod, daysBetween, periodOfDate, shiftMonth } from "./budget";
import { addDays } from "./recurrence";
/**
* Dashboard ana grafiği (Bölüm 6.13): Planlanan Gelir, Planlanan Gider (Bütçe), Gerçekleşen Gider.
* Saf fonksiyonlar: periyot aralığı ve kümülatif dönüşüm.
*
* Periyotlar: Haftalık (bu hafta, Pazartesi–Pazar, günlük), Aylık (içinde bulunulan bütçe ayı,
* günlük), Altı Aylık ve Yıllık (bu bütçe ayı dahil son 6 / 12 bütçe ayı, aylık).
*/
export const OVERVIEW_PERIODS = ["week", "month", "half", "year"] as const;
export type OverviewPeriod = (typeof OVERVIEW_PERIODS)[number];
export type OverviewRange = {
granularity: "day" | "month";
/** Günlükte "YYYY-MM-DD", aylıkta bütçe ayı "YYYY-MM". */
buckets: string[];
from: string;
to: string;
};
/** "YYYY-MM-DD" → haftanın günü (0 = Pazartesi … 6 = Pazar). */
export function weekday(ymd: string): number {
const d = new Date(`${ymd}T12:00:00Z`).getUTCDay();
return (d + 6) % 7;
}
export function overviewRange(period: OverviewPeriod, today: string, startDay = 1): OverviewRange {
if (period === "week") {
const from = addDays(today, -weekday(today));
const buckets = Array.from({ length: 7 }, (_, i) => addDays(from, i));
return { granularity: "day", buckets, from, to: buckets[6]! };
}
const current = periodOfDate(today, startDay);
if (period === "month") {
const { from, to } = budgetPeriod(current, startDay);
const buckets = Array.from({ length: daysBetween(from, to) }, (_, i) => addDays(from, i));
return { granularity: "day", buckets, from, to };
}
const n = period === "half" ? 6 : 12;
const buckets = Array.from({ length: n }, (_, i) => shiftMonth(current, i - n + 1));
return {
granularity: "month",
buckets,
from: budgetPeriod(buckets[0]!, startDay).from,
to: budgetPeriod(buckets.at(-1)!, startDay).to,
};
}
export type OverviewPoint = {
key: string;
plannedIncome: number | null;
budget: number | null;
actual: number | null;
};
const SERIES = ["plannedIncome", "budget", "actual"] as const;
/**
* Kümülatif görünüm: her seri kendi içinde birikir. Boş (null) nokta boş kalır ama birikime 0
* katar; böylece bütçesi olmayan ay ya da gelecek günler çizgiyi koparır, toplamı bozmaz.
*/
export function cumulative(points: readonly OverviewPoint[]): OverviewPoint[] {
const run = { plannedIncome: 0, budget: 0, actual: 0 };
return points.map((p) => {
const out: OverviewPoint = { ...p };
for (const k of SERIES) {
const v = p[k];
if (v === null) continue;
run[k] += v;
out[k] = run[k];
}
return out;
});
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { categoryBreakdown, changePct, savingsRate, sharePct } from "./reports";
describe("oranlar", () => {
it("toplam gidere oran bir ondalıklı (35.000 içinde 5.000 → %14,3)", () => {
expect(sharePct(5_000_00, 35_000_00)).toBe(14.3);
expect(sharePct(1, 0)).toBe(0);
});
it("önceki aya göre değişim; önceki 0 ise null", () => {
expect(changePct(120, 100)).toBe(20);
expect(changePct(90, 120)).toBe(-25);
expect(changePct(50, 0)).toBeNull();
});
it("tasarruf oranı; gelir yoksa null, gider gelirden fazlaysa negatif", () => {
expect(savingsRate(50_000_00, 35_000_00)).toBe(30);
expect(savingsRate(10_000_00, 12_000_00)).toBe(-20);
expect(savingsRate(0, 100)).toBeNull();
});
});
describe("categoryBreakdown", () => {
const cats = [
{ id: "fatura", parentId: null, name: "Faturalar", order: 1 },
{ id: "elektrik", parentId: "fatura", name: "Elektrik", order: 1 },
{ id: "su", parentId: "fatura", name: "Su", order: 2 },
{ id: "market", parentId: null, name: "Market", order: 2 },
{ id: "gida", parentId: "market", name: "Gıda", order: 1 },
{ id: "ulasim", parentId: null, name: "Ulaşım", order: 3 },
];
it("üst kategori alt ağacı toplar; oranlar toplam gidere göre; boşlar gizli", () => {
const current = new Map([
["elektrik", 3_000_00],
["su", 2_000_00],
["gida", 25_000_00],
["market", 5_000_00],
]);
const previous = new Map([
["elektrik", 2_500_00],
["gida", 20_000_00],
["ulasim", 1_000_00],
]);
const b = categoryBreakdown(cats, current, previous);
expect(b.totalMinor).toBe(35_000_00);
expect(b.prevTotalMinor).toBe(23_500_00);
expect(b.rows.map((r) => [r.id, r.amountMinor, r.sharePct])).toEqual([
["market", 30_000_00, 85.7],
["fatura", 5_000_00, 14.3],
// Bu ay harcama yok ama geçen ay vardı: değişimi göstermek için kalır.
["ulasim", 0, 0],
]);
const fatura = b.rows.find((r) => r.id === "fatura")!;
expect(fatura.children.map((c) => [c.id, c.amountMinor, c.changePct])).toEqual([
["elektrik", 3_000_00, 20],
["su", 2_000_00, null],
]);
const market = b.rows.find((r) => r.id === "market")!;
expect(market.ownMinor).toBe(5_000_00);
expect(market.changePct).toBe(50);
expect(b.rows.find((r) => r.id === "ulasim")!.changeMinor).toBe(-1_000_00);
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* 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<string, number>,
previous: ReadonlyMap<string, number>,
): CategoryBreakdown {
const byId = new Map(categories.map((c) => [c.id, c]));
const children = new Map<string, ReportCategory[]>();
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<string>) => {
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 };
}