feat(kakeibo): dört harcama grubu, banka maliyeti ve geri alınabilir migration

- shared/kakeibo: grup devralma, banka maliyeti, kayıt bazında grup, grup özeti (saf, testli)
- kategori ve işlem/kalem modellerine kakeiboGroup ve bankCost; form şemaları
- migration 016 varsayılanları doldurur; npm run migrate -- --down <ad> ile geri alma
- Kakeibo rapor servisi: tutarlar bütçeyle aynı kaynaktan, grup değiştirmeler taşınır

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 04:51:35 +03:00
parent 18c892bdcd
commit afc6da1606
17 changed files with 831 additions and 13 deletions
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import {
classify,
classifyAmounts,
resolveKakeibo,
summarizeGroups,
type KakeiboCategory,
} from "./kakeibo";
const cat = (
id: string,
parentId: string | null,
kakeiboGroup: KakeiboCategory["kakeiboGroup"] = null,
bankCost = false,
): KakeiboCategory => ({ id, parentId, kakeiboGroup, bankCost });
describe("resolveKakeibo", () => {
const map = resolveKakeibo([
cat("market", null, "ZORUNLU"),
cat("gida", "market"),
cat("bakim", "market", "ISTEK"),
cat("banka", null, null, true),
cat("faiz", "banka"),
cat("diger", null),
// Döngü: kendine üst (bozuk veri) sonsuz döngüye girmemeli.
cat("dongu", "dongu"),
]);
it("alt kategori grubu üstten devralır; kendi değeri önceliklidir", () => {
expect(map.get("gida")).toEqual({ group: "ZORUNLU", inherited: true, bankCost: false });
expect(map.get("bakim")).toEqual({ group: "ISTEK", inherited: false, bankCost: false });
expect(map.get("market")!.inherited).toBe(false);
});
it("banka maliyeti alt kategorilere geçer; grupsuz ve döngülü kategori güvenli", () => {
expect(map.get("faiz")!.bankCost).toBe(true);
expect(map.get("diger")).toEqual({ group: null, inherited: false, bankCost: false });
expect(map.get("dongu")!.group).toBeNull();
});
});
describe("classify", () => {
const map = resolveKakeibo([cat("market", null, "ZORUNLU"), cat("faiz", null, null, true)]);
it("işlem bazında grup kategoriyi ezer; banka maliyeti her zaman banka maliyeti", () => {
expect(classify(map.get("market"), null)).toBe("ZORUNLU");
expect(classify(map.get("market"), "ISTEK")).toBe("ISTEK");
expect(classify(map.get("faiz"), "ISTEK")).toBe("BANK_COST");
expect(classify(undefined, null)).toBe("UNGROUPED");
expect(classify(undefined, "KULTUR")).toBe("KULTUR");
});
});
describe("summarizeGroups", () => {
it("banka maliyeti harcamaya ve paylara girmez; grupsuz harcamaya girer", () => {
const s = summarizeGroups([
{ cls: "ZORUNLU", amountMinor: 4_600 },
{ cls: "ISTEK", amountMinor: 3_900 },
{ cls: "KULTUR", amountMinor: 150 },
{ cls: "BEKLENMEYEN", amountMinor: 1_250 },
{ cls: "UNGROUPED", amountMinor: 100 },
{ cls: "BANK_COST", amountMinor: 999 },
]);
expect(s.spendingMinor).toBe(10_000);
expect(s.bankCostMinor).toBe(999);
expect(s.shares).toEqual({ ZORUNLU: 46, ISTEK: 39, KULTUR: 1.5, BEKLENMEYEN: 12.5 });
expect(s.ungroupedShare).toBe(1);
});
it("harcama yoksa paylar 0 (sıfıra bölme yok)", () => {
const s = summarizeGroups([{ cls: "BANK_COST", amountMinor: 50 }]);
expect(s.spendingMinor).toBe(0);
expect(s.shares.ZORUNLU).toBe(0);
expect(s.ungroupedShare).toBe(0);
});
});
describe("classifyAmounts", () => {
const resolved = resolveKakeibo([
cat("market", null, "ZORUNLU"),
cat("faiz", null, null, true),
cat("diger", null),
]);
it("değiştirilen tutar seçilen gruba taşınır, kategori toplamı korunur", () => {
const out = classifyAmounts(
new Map([
["market", 10_000],
["faiz", 500],
["diger", 300],
]),
[
{ categoryId: "market", group: "ISTEK", amountMinor: 2_500 },
// Banka maliyetinde grup değiştirme etkisiz.
{ categoryId: "faiz", group: "ISTEK", amountMinor: 500 },
// Kategori toplamını aşan değiştirme kırpılır.
{ categoryId: "diger", group: "KULTUR", amountMinor: 900 },
],
resolved,
);
expect(out).toEqual([
{ categoryId: "market", cls: "ISTEK", amountMinor: 2_500 },
{ categoryId: "market", cls: "ZORUNLU", amountMinor: 7_500 },
{ categoryId: "faiz", cls: "BANK_COST", amountMinor: 500 },
{ categoryId: "diger", cls: "KULTUR", amountMinor: 300 },
]);
const s = summarizeGroups(out);
expect(s.spendingMinor + s.bankCostMinor).toBe(10_800);
});
});