fix(budget): sabit gider ayrımı ve taban hızlı ay sonu tahmini

$ne ile eksik alan yolu her işlemi sabit sayıyordu; $ifNull ile düzeltildi.
Tahmin, bu ayın hızını önceki dönemin değişken hızıyla karıştırır.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 01:22:38 +03:00
parent b5078d9251
commit 21b3715d35
4 changed files with 105 additions and 22 deletions
+24
View File
@@ -206,6 +206,30 @@ describe("aylık bütçe", () => {
expect((await line("aidat", shiftMonth(M, 1)))!.actualMinor).toBe(1_000_00); expect((await line("aidat", shiftMonth(M, 1)))!.actualMinor).toBe(1_000_00);
}); });
it("tahmin: değişken gider tempoyla, sabit gider olduğu gibi (gelecek ayda değişken sayılmaz)", async () => {
const F = shiftMonth(todayIstanbul().slice(0, 7), 24);
const f = budgetPeriod(F);
await expense({ date: f.from, amountMinor: 900_00, category: cat.keyif });
await expense({ date: f.from, amountMinor: 4_000_00, category: cat.kira, recurring: true });
const keyif = (await line("keyif", F))!;
expect(keyif.actualMinor).toBe(900_00);
// Henüz başlamamış dönem, geçmiş yok: gerçekleşen değişken olduğu gibi, tempo eklenmez.
expect(keyif.forecastMinor).toBe(900_00);
expect((await line("kira", F))!.forecastMinor).toBe(4_000_00);
});
it("içinde bulunulan ayda değişken gider tempoyla büyür, sabit gider büyümez", async () => {
const today = todayIstanbul();
const cur = today.slice(0, 7);
if (budgetPeriod(cur).to === today) return; // ayın son günü: kalan gün yok
await expense({ date: today, amountMinor: 1_000_00, category: cat.keyif });
await expense({ date: today, amountMinor: 2_000_00, category: cat.kira, recurring: true });
const keyif = (await line("keyif", cur))!;
expect(keyif.actualMinor).toBe(1_000_00);
expect(keyif.forecastMinor).toBeGreaterThan(1_000_00);
expect((await line("kira", cur))!.forecastMinor).toBe(2_000_00);
});
it("bütçe ayı başlangıç günü dönemi kaydırır", async () => { it("bütçe ayı başlangıç günü dönemi kaydırır", async () => {
await HouseholdModel.updateOne( await HouseholdModel.updateOne(
{ _id: cakir }, { _id: cakir },
+25 -4
View File
@@ -34,18 +34,20 @@ import type { BudgetLimitsForm } from "@/shared/schemas/budget";
const t = tr.budget; const t = tr.budget;
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
type Settings = { export type Settings = {
startDay: number; startDay: number;
installmentMode: "per_installment" | "purchase_month"; installmentMode: "per_installment" | "purchase_month";
includePrivate: boolean; includePrivate: boolean;
dailyMode: "even" | "previous_month_pattern";
}; };
async function settingsOf(householdId: Types.ObjectId): Promise<Settings> { export async function settingsOf(householdId: Types.ObjectId): Promise<Settings> {
const h = await HouseholdModel.findById(householdId).select({ settings: 1 }).lean(); const h = await HouseholdModel.findById(householdId).select({ settings: 1 }).lean();
return { return {
startDay: h?.settings?.budgetMonthStartDay ?? 1, startDay: h?.settings?.budgetMonthStartDay ?? 1,
installmentMode: h?.settings?.installmentBudgetMode ?? "per_installment", installmentMode: h?.settings?.installmentBudgetMode ?? "per_installment",
includePrivate: h?.settings?.includePrivateAccountsInTotals ?? true, includePrivate: h?.settings?.includePrivateAccountsInTotals ?? true,
dailyMode: h?.settings?.dailyPlanMode ?? "even",
}; };
} }
@@ -70,7 +72,10 @@ export async function expenseAmounts(
$project: { $project: {
status: 1, status: 1,
fixed: { fixed: {
$or: [{ $ne: ["$recurringRuleId", null] }, { $ne: ["$installment.planId", null] }], $or: [
{ $ne: [{ $ifNull: ["$recurringRuleId", null] }, null] },
{ $ne: [{ $ifNull: ["$installment.planId", null] }, null] },
],
}, },
items: { items: {
$cond: [ $cond: [
@@ -205,17 +210,32 @@ export async function getBudget(
expenseAmounts(ctx, s, period.from, period.to), expenseAmounts(ctx, s, period.from, period.to),
]); ]);
const limits = new Map((doc?.limits ?? []).map((l) => [String(l.categoryId), l.amountMinor])); const limits = new Map((doc?.limits ?? []).map((l) => [String(l.categoryId), l.amountMinor]));
// Taban hız (bitmemiş aylar için): önceki dönemin değişken gideri, kategori bazında.
let baselineDays: number | undefined;
if (elapsedDays < totalDays) {
const prev = budgetPeriod(shiftMonth(m, -1), s.startDay);
const prevAmounts = await expenseAmounts(ctx, s, prev.from, prev.to);
let any = 0;
for (const [id, a] of prevAmounts) {
if (a.variableMinor <= 0) continue;
any += a.variableMinor;
const cur = amounts.get(id) ?? { fixedMinor: 0, variableMinor: 0, plannedMinor: 0 };
amounts.set(id, { ...cur, baselineMinor: a.variableMinor });
}
if (any > 0) baselineDays = daysBetween(prev.from, prev.to);
}
const cats = expenseCategories(ctx); const cats = expenseCategories(ctx);
// Arşivli kategori yalnızca limiti veya harcaması varsa görünür. // Arşivli kategori yalnızca limiti veya harcaması varsa görünür.
const visibleCats = cats.filter((c) => !c.archived || limits.has(c.id) || amounts.has(c.id)); const visibleCats = cats.filter((c) => !c.archived || limits.has(c.id) || amounts.has(c.id));
const known = new Set(visibleCats.map((c) => c.id)); const known = new Set(visibleCats.map((c) => c.id));
// Kategorisiz / bilinmeyen kategorideki gider "Kategorisiz" satırında toplanır. // Kategorisiz / bilinmeyen kategorideki gider "Kategorisiz" satırında toplanır.
const orphan = { fixedMinor: 0, variableMinor: 0, plannedMinor: 0 }; const orphan = { fixedMinor: 0, variableMinor: 0, plannedMinor: 0, baselineMinor: 0 };
for (const [id, a] of amounts) for (const [id, a] of amounts)
if (!known.has(id)) { if (!known.has(id)) {
orphan.fixedMinor += a.fixedMinor; orphan.fixedMinor += a.fixedMinor;
orphan.variableMinor += a.variableMinor; orphan.variableMinor += a.variableMinor;
orphan.plannedMinor += a.plannedMinor; orphan.plannedMinor += a.plannedMinor;
orphan.baselineMinor += a.baselineMinor ?? 0;
} }
const ORPHAN = "__none__"; const ORPHAN = "__none__";
const inputCats = visibleCats.map((c) => ({ const inputCats = visibleCats.map((c) => ({
@@ -234,6 +254,7 @@ export async function getBudget(
amounts, amounts,
elapsedDays, elapsedDays,
totalDays, totalDays,
baselineDays,
}); });
const editorCats = cats.filter((c) => !c.archived); const editorCats = cats.filter((c) => !c.archived);
// Düzenleyici için ağaç sırası ve derinlik (tüm etkin kategoriler, limit 0 ile). // Düzenleyici için ağaç sırası ve derinlik (tüm etkin kategoriler, limit 0 ile).
+20 -2
View File
@@ -40,13 +40,31 @@ describe("bütçe dönemi", () => {
variableMinor: 3_000_00, variableMinor: 3_000_00,
plannedMinor: 500_00, plannedMinor: 500_00,
}; };
// 10/30 gün geçti: değişken 3.000 → 9.000 // Geçmiş yok: 10/30 gün geçti, değişken 3.000 → 9.000
expect(forecast(a, 10, 30)).toBe(10_500_00); expect(forecast(a, 10, 30)).toBe(10_500_00);
expect(forecast(a, 30, 30)).toBe(4_500_00); expect(forecast(a, 30, 30)).toBe(4_500_00);
expect(forecast(a, 0, 30)).toBe(1_500_00); // Henüz başlamamış dönem: gerçekleşmiş değişken olduğu gibi, tempo yok.
expect(forecast(a, 0, 30)).toBe(4_500_00);
expect(roundUpLimit(1_234_56)).toBe(1_300_00); expect(roundUpLimit(1_234_56)).toBe(1_300_00);
expect(roundUpLimit(1_200_00)).toBe(1_200_00); expect(roundUpLimit(1_200_00)).toBe(1_200_00);
}); });
it("taban hız: önceki ayın temposu ay başında ağır basar", () => {
// Ayın 2'sinde 6.000'lik tek harcama; önceki ay 30 günde 9.000 (günde 300).
const a: CategoryAmounts = {
fixedMinor: 0,
variableMinor: 6_000_00,
plannedMinor: 0,
baselineMinor: 9_000_00,
};
// Yalnızca bu ay: 6.000 × 30/2 = 90.000.
expect(forecast(a, 2, 30)).toBe(90_000_00);
// Karışım: hız = 2/30 × 3.000 + 28/30 × 300 = 480 → 6.000 + 28 × 480
expect(forecast(a, 2, 30, 30)).toBe(6_000_00 + 28 * 480_00);
// Gelecek ay: yalnızca taban; geçmiş ay: gerçekleşen.
expect(forecast({ ...a, variableMinor: 0 }, 0, 30, 30)).toBe(9_000_00);
expect(forecast(a, 30, 30, 30)).toBe(6_000_00);
});
}); });
describe("bütçe toplama", () => { describe("bütçe toplama", () => {
+36 -16
View File
@@ -62,6 +62,8 @@ export type CategoryAmounts = {
variableMinor: number; variableMinor: number;
/** Dönemin kalanındaki planlı (henüz gerçekleşmemiş) gider. */ /** Dönemin kalanındaki planlı (henüz gerçekleşmemiş) gider. */
plannedMinor: number; plannedMinor: number;
/** Önceki dönemin değişken gideri (tahmin için taban hız; yoksa 0). */
baselineMinor?: number;
}; };
export type BudgetLine = { export type BudgetLine = {
@@ -96,18 +98,28 @@ export type BudgetTotals = {
}; };
/** /**
* Ay sonu tahmini: sabit giderler olduğu gibi + kalan planlı giderler + değişken giderin * Ay sonu tahmini: sabit giderler olduğu gibi + kalan planlı giderler + değişken gider ve kalan
* şimdiye kadarki günlük hızıyla ay sonuna yansıtılması. Geçmiş ayda tahmin = gerçekleşen; * günler için günlük hız tahmini.
* gelecek ayda yalnızca planlı. *
* Günlük hız, bu ayın hızı ile önceki dönemin değişken harcama hızının (taban) karışımıdır; ay
* ilerledikçe bu ayın ağırlığı artar (geçen gün / toplam gün). Böylece ay başındaki tek bir büyük
* harcama tüm aya yayılmaz. Taban yoksa (`baselineDays` verilmemiş: hanenin geçmişi yok) yalnızca
* bu ayın hızı kullanılır. Geçmiş ayda tahmin = gerçekleşen.
*/ */
export function forecast(a: CategoryAmounts, elapsedDays: number, totalDays: number): number { export function forecast(
const variable = a: CategoryAmounts,
elapsedDays <= 0 elapsedDays: number,
? 0 totalDays: number,
: elapsedDays >= totalDays baselineDays?: number,
? a.variableMinor ): number {
: Math.round((a.variableMinor * totalDays) / elapsedDays); const remaining = Math.max(0, totalDays - Math.max(0, elapsedDays));
return a.fixedMinor + a.plannedMinor + variable; const current = elapsedDays > 0 ? a.variableMinor / elapsedDays : 0;
let pace = current;
if (baselineDays && baselineDays > 0) {
const w = Math.min(1, Math.max(0, elapsedDays) / totalDays);
pace = w * current + (1 - w) * ((a.baselineMinor ?? 0) / baselineDays);
}
return a.fixedMinor + a.plannedMinor + a.variableMinor + Math.round(pace * remaining);
} }
export function computeBudget(input: { export function computeBudget(input: {
@@ -116,8 +128,10 @@ export function computeBudget(input: {
amounts: Map<string, CategoryAmounts>; amounts: Map<string, CategoryAmounts>;
elapsedDays: number; elapsedDays: number;
totalDays: number; totalDays: number;
/** Önceki dönemin gün sayısı (taban hız için); hanenin geçmişi yoksa verilmez. */
baselineDays?: number;
}): { lines: BudgetLine[]; totals: BudgetTotals } { }): { lines: BudgetLine[]; totals: BudgetTotals } {
const { categories, limits, amounts, elapsedDays, totalDays } = input; const { categories, limits, amounts, elapsedDays, totalDays, baselineDays } = input;
const byId = new Map(categories.map((c) => [c.id, c])); const byId = new Map(categories.map((c) => [c.id, c]));
const children = new Map<string, BudgetCategoryInput[]>(); const children = new Map<string, BudgetCategoryInput[]>();
const roots: BudgetCategoryInput[] = []; const roots: BudgetCategoryInput[] = [];
@@ -130,7 +144,12 @@ export function computeBudget(input: {
} }
const sort = (list: BudgetCategoryInput[]) => const sort = (list: BudgetCategoryInput[]) =>
list.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, "tr")); list.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, "tr"));
const zero: CategoryAmounts = { fixedMinor: 0, variableMinor: 0, plannedMinor: 0 }; const zero: CategoryAmounts = {
fixedMinor: 0,
variableMinor: 0,
plannedMinor: 0,
baselineMinor: 0,
};
// Alt ağaç toplamları. // Alt ağaç toplamları.
const subtree = new Map<string, CategoryAmounts>(); const subtree = new Map<string, CategoryAmounts>();
@@ -138,12 +157,13 @@ export function computeBudget(input: {
if (seen.has(c.id)) return zero; if (seen.has(c.id)) return zero;
seen.add(c.id); seen.add(c.id);
const own = amounts.get(c.id) ?? zero; const own = amounts.get(c.id) ?? zero;
const total = { ...own }; const total = { ...own, baselineMinor: own.baselineMinor ?? 0 };
for (const k of children.get(c.id) ?? []) { for (const k of children.get(c.id) ?? []) {
const s = sumOf(k, seen); const s = sumOf(k, seen);
total.fixedMinor += s.fixedMinor; total.fixedMinor += s.fixedMinor;
total.variableMinor += s.variableMinor; total.variableMinor += s.variableMinor;
total.plannedMinor += s.plannedMinor; total.plannedMinor += s.plannedMinor;
total.baselineMinor += s.baselineMinor ?? 0;
} }
subtree.set(c.id, total); subtree.set(c.id, total);
return total; return total;
@@ -163,7 +183,7 @@ export function computeBudget(input: {
const isCovered = covered || limit !== null; const isCovered = covered || limit !== null;
if (!isCovered) unbudgeted += own.fixedMinor + own.variableMinor; if (!isCovered) unbudgeted += own.fixedMinor + own.variableMinor;
const actual = s.fixedMinor + s.variableMinor; const actual = s.fixedMinor + s.variableMinor;
const fc = forecast(s, elapsedDays, totalDays); const fc = forecast(s, elapsedDays, totalDays, baselineDays);
if (limit !== null || actual > 0 || s.plannedMinor > 0) { if (limit !== null || actual > 0 || s.plannedMinor > 0) {
lines.push({ lines.push({
categoryId: c.id, categoryId: c.id,
@@ -192,7 +212,7 @@ export function computeBudget(input: {
const s = subtree.get(r.id) ?? zero; const s = subtree.get(r.id) ?? zero;
actual += s.fixedMinor + s.variableMinor; actual += s.fixedMinor + s.variableMinor;
planned += s.plannedMinor; planned += s.plannedMinor;
fc += forecast(s, elapsedDays, totalDays); fc += forecast(s, elapsedDays, totalDays, baselineDays);
} }
const hasLimit = limits.size > 0; const hasLimit = limits.size > 0;
return { return {