feat(reports): cari, birikim, alışveriş, birim fiyat ve fire rapor servisleri

- mağaza adı eşleşmesi ve aylık birim fiyat özeti (saf, birim testli)
- tamamlanan listelerin plan/gerçekleşen özeti, mağaza bazlı harcama
- ürün tipi fiyat geçmişi yükleyicisi; fire oranı ve tahmini maliyet; testler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 00:53:02 +03:00
parent d9a22551e1
commit 8623c5a02e
7 changed files with 920 additions and 4 deletions
+39 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { categoryBreakdown, changePct, savingsRate, sharePct } from "./reports";
import {
categoryBreakdown,
changePct,
matchStoreName,
monthlyUnitPrices,
savingsRate,
sharePct,
} from "./reports";
describe("oranlar", () => {
it("toplam gidere oran bir ondalıklı (35.000 içinde 5.000 → %14,3)", () => {
@@ -60,3 +67,34 @@ describe("categoryBreakdown", () => {
expect(b.rows.find((r) => r.id === "ulasim")!.changeMinor).toBe(-1_000_00);
});
});
describe("matchStoreName", () => {
const fold = (s: string) => s.toLocaleLowerCase("tr-TR");
const names = ["Şok", "Migros", "Migros Jet", "A101"];
it("katlanmış başlangıç eşleşmesi, en uzun ad, kelime sınırı", () => {
expect(matchStoreName("Şok · Rasimpaşa", names, fold)).toBe("Şok");
expect(matchStoreName("ŞOK MARKET", names, fold)).toBe("Şok");
expect(matchStoreName("Migros Jet Moda", names, fold)).toBe("Migros Jet");
expect(matchStoreName("A101x", names, fold)).toBeNull();
expect(matchStoreName("Ev sahibi", names, fold)).toBeNull();
expect(matchStoreName("", names, fold)).toBeNull();
});
});
describe("monthlyUnitPrices", () => {
it("ay başına en düşük ve ortalama; kayıt yoksa boş", () => {
const out = monthlyUnitPrices(
[
{ month: "2026-10", unitPriceMinor: 100 },
{ month: "2026-10", unitPriceMinor: 140 },
{ month: "2026-11", unitPriceMinor: 90 },
],
["2026-09", "2026-10", "2026-11"],
);
expect(out).toEqual([
{ month: "2026-09", minMinor: null, avgMinor: null, count: 0 },
{ month: "2026-10", minMinor: 100, avgMinor: 120, count: 2 },
{ month: "2026-11", minMinor: 90, avgMinor: 90, count: 1 },
]);
});
});
+55
View File
@@ -118,3 +118,58 @@ export function categoryBreakdown(
rows.sort((a, b) => b.amountMinor - a.amountMinor || a.name.localeCompare(b.name, "tr"));
return { totalMinor, prevTotalMinor, rows };
}
// --- Alışveriş raporları ---
/** Rapor dönemi: içinde bulunulan dahil son N bütçe ayı. */
export const REPORT_PERIODS = [3, 6, 12] as const;
export type ReportPeriod = (typeof REPORT_PERIODS)[number];
export const parsePeriod = (x: unknown): ReportPeriod =>
REPORT_PERIODS.includes(Number(x) as ReportPeriod) ? (Number(x) as ReportPeriod) : 6;
/**
* Gider alıcısını mağaza adına eşler: Türkçe katlanmış alıcı adı mağaza adıyla başlıyorsa
* (en uzun eşleşme). "Şok · Rasimpaşa" ve "ŞOK MARKET" → "Şok". Eşleşme yoksa null.
*/
export function matchStoreName(
payee: string,
storeNames: readonly string[],
fold: (s: string) => string,
): string | null {
const p = fold(payee).trim();
if (!p) return null;
let best: string | null = null;
for (const name of storeNames) {
const n = fold(name).trim();
if (!n || !p.startsWith(n)) continue;
// Kelime sınırı: "a101x" "a101" ile eşleşmesin.
const next = p.charAt(n.length);
if (next && /[\p{L}\p{N}]/u.test(next)) continue;
if (!best || n.length > fold(best).length) best = name;
}
return best;
}
export type UnitPricePoint = {
month: string;
minMinor: number | null;
avgMinor: number | null;
count: number;
};
/** Ay başına en düşük ve ortalama birim fiyat (kayıt yoksa boş). Birim fiyat kesirli kuruş. */
export function monthlyUnitPrices(
records: readonly { month: string; unitPriceMinor: number }[],
months: readonly string[],
): UnitPricePoint[] {
return months.map((month) => {
const xs = records.filter((r) => r.month === month).map((r) => r.unitPriceMinor);
return {
month,
minMinor: xs.length ? Math.min(...xs) : null,
avgMinor: xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null,
count: xs.length,
};
});
}