Files
butcem/src/shared/budget.ts
T
suphicakir 21b3715d35 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>
2026-11-06 01:22:38 +03:00

238 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { addDays, addMonths } from "./recurrence";
/**
* Aylık bütçe hesap motoru (Bölüm 6.9). Tutarlar kuruş, tarihler İstanbul günü "YYYY-MM-DD".
*
* Bütçe ayı hane ayarı `budgetMonthStartDay` ile başlar (Faz 0): başlangıç günü 15 ise
* "2026-11" dönemi 15 Kasım – 14 Aralık'tır; 1 ise takvim ayıdır.
*
* Limitler (Faz 10 kararı): her gider kategorisine verilebilir. Üst kategori limiti alt
* kategorilerin harcamasını da kapsar; alt kategori limiti üstün içinde bir alt sınırdır ve
* toplam bütçede iki kez sayılmaz.
*/
export const WARNING_BP = 8_000;
export const OVER_BP = 10_000;
/** "YYYY-MM" dönemi: ilk ve son gün (ikisi de dahil). */
export function budgetPeriod(month: string, startDay = 1): { from: string; to: string } {
const from = `${month}-${String(startDay).padStart(2, "0")}`;
const to = addDays(addMonths(from, 1, startDay), -1);
return { from, to };
}
/** Tarihin düştüğü bütçe ayı. */
export function periodOfDate(date: string, startDay = 1): string {
const day = Number(date.slice(8, 10));
const month = date.slice(0, 7);
return day >= startDay ? month : addMonths(`${month}-01`, -1).slice(0, 7);
}
export function shiftMonth(month: string, n: number): string {
return addMonths(`${month}-01`, n).slice(0, 7);
}
/** İki gün arasındaki gün sayısı (ikisi de dahil). */
export function daysBetween(from: string, to: string): number {
const ms = Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`);
return Math.round(ms / 86_400_000) + 1;
}
export type BudgetStatus = "none" | "ok" | "warning" | "over";
export function statusOf(actualMinor: number, limitMinor: number | null): BudgetStatus {
if (limitMinor === null) return "none";
if (limitMinor <= 0) return actualMinor > 0 ? "over" : "ok";
const bp = (actualMinor * 10_000) / limitMinor;
return bp >= OVER_BP ? "over" : bp >= WARNING_BP ? "warning" : "ok";
}
export type BudgetCategoryInput = {
id: string;
parentId: string | null;
name: string;
order: number;
};
/** Kategori bazında kendi (alt kategoriler hariç) tutarlar. */
export type CategoryAmounts = {
/** Düzenli işlem veya taksitten gelen gerçekleşmiş gider (tempo tahminine katılmaz). */
fixedMinor: number;
/** Diğer gerçekleşmiş gider. */
variableMinor: number;
/** Dönemin kalanındaki planlı (henüz gerçekleşmemiş) gider. */
plannedMinor: number;
/** Önceki dönemin değişken gideri (tahmin için taban hız; yoksa 0). */
baselineMinor?: number;
};
export type BudgetLine = {
categoryId: string;
name: string;
depth: number;
limitMinor: number | null;
/** Kendi limiti yok ama bir üst kategorinin limiti kapsıyor. */
covered: boolean;
/** Alt kategoriler dahil gerçekleşen. */
actualMinor: number;
plannedMinor: number;
forecastMinor: number;
remainingMinor: number | null;
ratioBp: number | null;
status: BudgetStatus;
/** Tahmin limiti aşıyor (henüz aşılmamış olsa da). */
forecastOver: boolean;
};
export type BudgetTotals = {
limitMinor: number;
actualMinor: number;
plannedMinor: number;
forecastMinor: number;
remainingMinor: number;
/** Hiçbir limitin kapsamadığı gerçekleşen gider. */
unbudgetedMinor: number;
ratioBp: number | null;
status: BudgetStatus;
forecastOver: boolean;
};
/**
* Ay sonu tahmini: sabit giderler olduğu gibi + kalan planlı giderler + değişken gider ve kalan
* günler için günlük hız tahmini.
*
* 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,
baselineDays?: number,
): number {
const remaining = Math.max(0, totalDays - Math.max(0, elapsedDays));
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: {
categories: BudgetCategoryInput[];
limits: Map<string, number>;
amounts: Map<string, CategoryAmounts>;
elapsedDays: 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 } {
const { categories, limits, amounts, elapsedDays, totalDays, baselineDays } = input;
const byId = new Map(categories.map((c) => [c.id, c]));
const children = new Map<string, BudgetCategoryInput[]>();
const roots: BudgetCategoryInput[] = [];
for (const c of categories) {
if (c.parentId && byId.has(c.parentId)) {
const list = children.get(c.parentId) ?? [];
list.push(c);
children.set(c.parentId, list);
} else roots.push(c);
}
const sort = (list: BudgetCategoryInput[]) =>
list.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, "tr"));
const zero: CategoryAmounts = {
fixedMinor: 0,
variableMinor: 0,
plannedMinor: 0,
baselineMinor: 0,
};
// Alt ağaç toplamları.
const subtree = new Map<string, CategoryAmounts>();
const sumOf = (c: BudgetCategoryInput, seen: Set<string>): CategoryAmounts => {
if (seen.has(c.id)) return zero;
seen.add(c.id);
const own = amounts.get(c.id) ?? zero;
const total = { ...own, baselineMinor: own.baselineMinor ?? 0 };
for (const k of children.get(c.id) ?? []) {
const s = sumOf(k, seen);
total.fixedMinor += s.fixedMinor;
total.variableMinor += s.variableMinor;
total.plannedMinor += s.plannedMinor;
total.baselineMinor += s.baselineMinor ?? 0;
}
subtree.set(c.id, total);
return total;
};
const seen = new Set<string>();
for (const r of roots) sumOf(r, seen);
const lines: BudgetLine[] = [];
let limitTotal = 0;
let unbudgeted = 0;
const walk = (list: BudgetCategoryInput[], depth: number, covered: boolean) => {
for (const c of sort(list)) {
const limit = limits.get(c.id) ?? null;
const s = subtree.get(c.id) ?? zero;
const own = amounts.get(c.id) ?? zero;
if (limit !== null && !covered) limitTotal += limit;
const isCovered = covered || limit !== null;
if (!isCovered) unbudgeted += own.fixedMinor + own.variableMinor;
const actual = s.fixedMinor + s.variableMinor;
const fc = forecast(s, elapsedDays, totalDays, baselineDays);
if (limit !== null || actual > 0 || s.plannedMinor > 0) {
lines.push({
categoryId: c.id,
name: c.name,
depth,
limitMinor: limit,
covered: limit === null && covered,
actualMinor: actual,
plannedMinor: s.plannedMinor,
forecastMinor: fc,
remainingMinor: limit === null ? null : limit - actual,
ratioBp: limit && limit > 0 ? Math.round((actual * 10_000) / limit) : null,
status: statusOf(actual, limit),
forecastOver: limit !== null && fc > limit,
});
}
walk(children.get(c.id) ?? [], depth + 1, isCovered);
}
};
walk(roots, 0, false);
let actual = 0;
let planned = 0;
let fc = 0;
for (const r of roots) {
const s = subtree.get(r.id) ?? zero;
actual += s.fixedMinor + s.variableMinor;
planned += s.plannedMinor;
fc += forecast(s, elapsedDays, totalDays, baselineDays);
}
const hasLimit = limits.size > 0;
return {
lines,
totals: {
limitMinor: limitTotal,
actualMinor: actual,
plannedMinor: planned,
forecastMinor: fc,
remainingMinor: limitTotal - actual,
unbudgetedMinor: unbudgeted,
ratioBp: limitTotal > 0 ? Math.round((actual * 10_000) / limitTotal) : null,
status: hasLimit ? statusOf(actual, limitTotal) : "none",
forecastOver: hasLimit && fc > limitTotal,
},
};
}
/** Limit önerisi için yukarı yuvarlama (100 TL'lik adım). */
export function roundUpLimit(minor: number, stepMinor = 100_00): number {
return Math.ceil(minor / stepMinor) * stepMinor;
}