21b3715d35
$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>
238 lines
8.4 KiB
TypeScript
238 lines
8.4 KiB
TypeScript
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;
|
||
}
|