feat(budget): gelir planı ve günlük plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dailyPlan, distribute, patternWeights } from "./daily-plan";
|
||||
|
||||
describe("dağıtım", () => {
|
||||
it("toplam korunur, kalan kuruşlar en büyük kesirlere", () => {
|
||||
expect(distribute(100, [1, 1, 1])).toEqual([34, 33, 33]);
|
||||
expect(distribute(10, [1, 0, 3])).toEqual([3, 0, 7]);
|
||||
expect(distribute(5, [0, 0])).toEqual([3, 2]);
|
||||
expect(distribute(0, [1, 2])).toEqual([0, 0]);
|
||||
expect(distribute(7, [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("önceki ayın örüntüsü gün sayısına uyarlanır", () => {
|
||||
expect(patternWeights([5, 0, 2], 4)).toEqual([5, 0, 2, 2]);
|
||||
expect(patternWeights([5, 0, 2, 1], 2)).toEqual([5, 0]);
|
||||
expect(patternWeights([], 2)).toEqual([1, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("günlük plan", () => {
|
||||
// Aralık: 31 gün. Bütçe 31.000 + kira 10.000 (1'inde) + fatura 3.100 (15'inde).
|
||||
const base = {
|
||||
from: "2026-12-01",
|
||||
to: "2026-12-31",
|
||||
budgetMinor: 44_100_00,
|
||||
fixedByDay: new Map([
|
||||
["2026-12-01", 10_000_00],
|
||||
["2026-12-15", 3_100_00],
|
||||
]),
|
||||
};
|
||||
|
||||
it("sabit giderler kendi gününe, değişken bütçe eşit; kümülatif ve yüzde", () => {
|
||||
const p = dailyPlan({
|
||||
...base,
|
||||
today: "2026-12-13",
|
||||
actualByDay: new Map([
|
||||
["2026-12-01", 10_500_00],
|
||||
["2026-12-05", 9_000_00],
|
||||
["2026-12-13", 2_000_00],
|
||||
]),
|
||||
});
|
||||
expect(p.variableMinor).toBe(31_000_00);
|
||||
expect(p.days[0]!.plannedMinor).toBe(11_000_00);
|
||||
expect(p.days[1]!.plannedMinor).toBe(1_000_00);
|
||||
expect(p.days[14]!.plannedMinor).toBe(4_100_00);
|
||||
expect(p.days.at(-1)!.cumPlannedMinor).toBe(44_100_00);
|
||||
expect(p.todayIndex).toBe(12);
|
||||
// 13/31 gün → %42; 21.500 / 44.100 → %49
|
||||
expect(p.monthPct).toBe(42);
|
||||
expect(p.spentPct).toBe(49);
|
||||
expect(p.plannedToDateMinor).toBe(23_000_00);
|
||||
expect(p.actualToDateMinor).toBe(21_500_00);
|
||||
// Bugün: 1.000 + (12 günlük plan 22.000 − harcanan 19.500 = 2.500)
|
||||
expect(p.todayAllowanceMinor).toBe(3_500_00);
|
||||
});
|
||||
|
||||
it("sabit giderler bütçeyi aşarsa değişken 0; dönem dışı bugün; ağırlıklı dağıtım", () => {
|
||||
const over = dailyPlan({
|
||||
...base,
|
||||
budgetMinor: 5_000_00,
|
||||
today: "2027-01-05",
|
||||
actualByDay: new Map(),
|
||||
});
|
||||
expect(over.variableMinor).toBe(0);
|
||||
expect(over.todayIndex).toBe(-1);
|
||||
expect(over.monthPct).toBe(100);
|
||||
const weights = Array.from({ length: 31 }, (_, i) => (i % 7 === 5 ? 3 : 1));
|
||||
const w = dailyPlan({ ...base, today: "2026-11-20", actualByDay: new Map(), weights });
|
||||
expect(w.monthPct).toBe(0);
|
||||
expect(w.days[5]!.plannedMinor).toBeGreaterThan(w.days[6]!.plannedMinor);
|
||||
expect(w.days.at(-1)!.cumPlannedMinor).toBe(44_100_00);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { daysBetween } from "./budget";
|
||||
import { addDays } from "./recurrence";
|
||||
|
||||
/**
|
||||
* Günlük plan (Bölüm 6.9). Ayın bütçesi günlere yayılır: belirli tarihe bağlı giderler (düzenli
|
||||
* işlemler, taksitler) kendi günlerine; kalan değişken bütçe günlere eşit (varsayılan) veya önceki
|
||||
* ayın günlük harcama örüntüsüne göre (hane ayarı) dağıtılır. Tutarlar kuruş.
|
||||
*/
|
||||
|
||||
/** `total`ı ağırlıklara göre tamsayı paylara böler; toplam tam olarak `total` olur. */
|
||||
export function distribute(total: number, weights: number[]): number[] {
|
||||
const n = weights.length;
|
||||
if (n === 0) return [];
|
||||
const sum = weights.reduce((a, w) => a + Math.max(0, w), 0);
|
||||
const w = sum > 0 ? weights.map((x) => Math.max(0, x)) : weights.map(() => 1);
|
||||
const wsum = sum > 0 ? sum : n;
|
||||
const exact = w.map((x) => (total * x) / wsum);
|
||||
const out = exact.map(Math.floor);
|
||||
let rest = total - out.reduce((a, b) => a + b, 0);
|
||||
// En büyük kalanlara birer kuruş (eşitlikte erken gün önce).
|
||||
const order = exact
|
||||
.map((x, i) => ({ i, frac: x - Math.floor(x) }))
|
||||
.sort((a, b) => b.frac - a.frac || a.i - b.i);
|
||||
for (let k = 0; rest > 0 && k < order.length; k++, rest--) out[order[k]!.i]! += 1;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Önceki dönemin günlük değişken harcamasını bu dönemin gün sayısına uyarlar (gün sırasıyla). */
|
||||
export function patternWeights(previous: number[], totalDays: number): number[] {
|
||||
if (!previous.length) return Array.from({ length: totalDays }, () => 1);
|
||||
return Array.from({ length: totalDays }, (_, i) => previous[Math.min(i, previous.length - 1)]!);
|
||||
}
|
||||
|
||||
export type PlanDay = {
|
||||
date: string;
|
||||
/** O günün planı: sabit giderler + değişken pay. */
|
||||
plannedMinor: number;
|
||||
fixedMinor: number;
|
||||
actualMinor: number;
|
||||
cumPlannedMinor: number;
|
||||
cumActualMinor: number;
|
||||
};
|
||||
|
||||
export type DailyPlan = {
|
||||
days: PlanDay[];
|
||||
budgetMinor: number;
|
||||
fixedMinor: number;
|
||||
variableMinor: number;
|
||||
/** Bugünün sırası (0'dan); dönem dışındaysa -1. */
|
||||
todayIndex: number;
|
||||
/** Ayın geçen kısmı ve bütçenin harcanan kısmı (yüzde, tamsayı). */
|
||||
monthPct: number;
|
||||
spentPct: number | null;
|
||||
/** Bugüne kadar planlanan ve gerçekleşen (kümülatif). */
|
||||
plannedToDateMinor: number;
|
||||
actualToDateMinor: number;
|
||||
/** Bugün için kalan: bugünün planı + önceki günlerden kalan/aşan fark. */
|
||||
todayAllowanceMinor: number;
|
||||
};
|
||||
|
||||
export function dailyPlan(input: {
|
||||
from: string;
|
||||
to: string;
|
||||
today: string;
|
||||
budgetMinor: number;
|
||||
/** Tarihe bağlı (sabit) giderler, güne göre. */
|
||||
fixedByDay: Map<string, number>;
|
||||
/** Gerçekleşen tüm giderler, güne göre. */
|
||||
actualByDay: Map<string, number>;
|
||||
/** Değişken bütçe ağırlıkları (gün sayısı kadar); verilmezse eşit. */
|
||||
weights?: number[];
|
||||
}): DailyPlan {
|
||||
const total = daysBetween(input.from, input.to);
|
||||
const dates = Array.from({ length: total }, (_, i) => addDays(input.from, i));
|
||||
let fixed = 0;
|
||||
for (const d of dates) fixed += input.fixedByDay.get(d) ?? 0;
|
||||
const variable = Math.max(0, input.budgetMinor - fixed);
|
||||
const shares = distribute(variable, input.weights ?? dates.map(() => 1));
|
||||
let cumP = 0;
|
||||
let cumA = 0;
|
||||
const days = dates.map((date, i): PlanDay => {
|
||||
const f = input.fixedByDay.get(date) ?? 0;
|
||||
const planned = f + shares[i]!;
|
||||
const actual = input.actualByDay.get(date) ?? 0;
|
||||
cumP += planned;
|
||||
cumA += actual;
|
||||
return {
|
||||
date,
|
||||
plannedMinor: planned,
|
||||
fixedMinor: f,
|
||||
actualMinor: actual,
|
||||
cumPlannedMinor: cumP,
|
||||
cumActualMinor: cumA,
|
||||
};
|
||||
});
|
||||
const todayIndex =
|
||||
input.today < input.from || input.today > input.to
|
||||
? -1
|
||||
: daysBetween(input.from, input.today) - 1;
|
||||
const passed = input.today < input.from ? 0 : input.today > input.to ? total : todayIndex + 1;
|
||||
const upTo = passed > 0 ? days[passed - 1]! : null;
|
||||
const before = todayIndex > 0 ? days[todayIndex - 1]! : null;
|
||||
const todayDay = todayIndex >= 0 ? days[todayIndex]! : null;
|
||||
const actualToDate = upTo?.cumActualMinor ?? 0;
|
||||
return {
|
||||
days,
|
||||
budgetMinor: input.budgetMinor,
|
||||
fixedMinor: fixed,
|
||||
variableMinor: variable,
|
||||
todayIndex,
|
||||
monthPct: Math.round((passed * 100) / total),
|
||||
spentPct: input.budgetMinor > 0 ? Math.round((actualToDate * 100) / input.budgetMinor) : null,
|
||||
plannedToDateMinor: upTo?.cumPlannedMinor ?? 0,
|
||||
actualToDateMinor: actualToDate,
|
||||
todayAllowanceMinor: todayDay
|
||||
? todayDay.plannedMinor + (before ? before.cumPlannedMinor - before.cumActualMinor : 0)
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
@@ -27,3 +27,20 @@ export const budgetLimitsSchema = z.object({
|
||||
),
|
||||
});
|
||||
export type BudgetLimitsForm = z.output<typeof budgetLimitsSchema>;
|
||||
|
||||
/** Gelir planı: elle eklenen beklenen gelirler (liste tümüyle değiştirilir). */
|
||||
export const incomeItemsSchema = z.object({
|
||||
month: budgetMonthSchema,
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().min(1, tr.common.required).max(80, tr.validation.nameMax),
|
||||
amount: nonNegativeMoneySchema.refine(
|
||||
(n) => n > 0,
|
||||
tr.transactions.validation.amountPositive,
|
||||
),
|
||||
}),
|
||||
)
|
||||
.max(30),
|
||||
});
|
||||
export type IncomeItemsForm = z.output<typeof incomeItemsSchema>;
|
||||
|
||||
Reference in New Issue
Block a user