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
+51
View File
@@ -381,6 +381,57 @@ export async function loadTypeOffers(
return out; return out;
} }
/** Bir ürün tipinin tüm fiyat kayıtları, ürün tipi biriminde birim fiyatla (dönüşümsüzler hariç). */
export async function loadTypePriceHistory(
householdId: Types.ObjectId,
productTypeId: Types.ObjectId,
): Promise<{ date: string; productId: string; storeId: string; unitPriceMinor: number }[]> {
const [products, type] = await Promise.all([
ProductModel.find({ householdId, productTypeId })
.select({ packageQuantity: 1, packageUnit: 1, typeQuantity: 1 })
.lean<
{
_id: Types.ObjectId;
packageQuantity: number;
packageUnit: string;
typeQuantity: number | null;
}[]
>(),
ProductTypeModel.findOne({ householdId, _id: productTypeId })
.select({ unit: 1 })
.lean<{ unit: string }>(),
]);
if (!type) return [];
const inType = new Map(
products.map((p) => [
String(p._id),
packageInTypeUnit({
packageQuantity: p.packageQuantity,
packageUnit: p.packageUnit,
typeUnit: type.unit,
typeQuantity: p.typeQuantity,
}),
]),
);
const prices = await loadPrices(
householdId,
products.map((p) => p._id),
);
return prices.flatMap((r) => {
const q = inType.get(String(r.productId));
return q
? [
{
date: r.date,
productId: String(r.productId),
storeId: String(r.storeId),
unitPriceMinor: unitPrice(r.priceMinor, q),
},
]
: [];
});
}
/** /**
* Ürün tipi başına son bilinen birim fiyat (en yeni geçerli fiyat kaydı; tüm marka/mağazalar). * Ürün tipi başına son bilinen birim fiyat (en yeni geçerli fiyat kaydı; tüm marka/mağazalar).
* Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır. * Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır.
+130
View File
@@ -0,0 +1,130 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { completeShopping, getCompletionSheet } from "@/features/shopping/completion";
import { AccountModel } from "@/models/account";
import { ProductTypeModel } from "@/models/product-type";
import { ShoppingListModel } from "@/models/shopping-list";
import { todayIstanbul } from "@/shared/dates";
import { formatMoneyInput } from "@/shared/money";
import { shoppingCompletionSchema } from "@/shared/schemas/shopping";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
getAssetsReport,
getContactsReport,
getShoppingReport,
getUnitPriceReport,
getWasteReport,
} from "./extra";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let zeynep = "";
let fatma = "";
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
zeynep = ids.user("zeynep");
fatma = ids.user("fatma");
});
afterAll(teardownDb);
describe("cari ve aynî", () => {
it("bakiyeli cariler büyükten küçüğe; toplamlar; açık aynî borçlar; başka hane göremez", async () => {
const r = (await getContactsReport(ayse, cakir))!;
expect(r.contacts.length).toBeGreaterThan(0);
expect(r.contacts.every((c) => c.balanceMinor !== 0 && !c.archived)).toBe(true);
const abs = r.contacts.map((c) => Math.abs(c.balanceMinor));
expect(abs).toEqual([...abs].sort((a, b) => b - a));
expect(r.inKind.length).toBeGreaterThan(0);
expect(r.inKind.every((d) => d.remainingQuantity > 0)).toBe(true);
expect(await getContactsReport(fatma, cakir)).toBeNull();
});
});
describe("birikim", () => {
it("varlık kâr/zarar ve dönem raporu", async () => {
const r = (await getAssetsReport(ayse, cakir, "6"))!;
expect(r.items.length).toBeGreaterThan(0);
expect(r.period.from < r.period.to).toBe(true);
expect(r.range).toBe("6");
});
});
describe("alışveriş", () => {
it("mağaza bazlı harcama alıcı adıyla eşleşir; tamamlanan liste plan/gerçek satırı verir", async () => {
const before = (await getShoppingReport(ayse, cakir, 6))!;
expect(before.lists).toEqual([]);
expect(before.stores.map((s) => s.name)).toEqual(expect.arrayContaining(["Migros", "BİM"]));
expect(before.stores.reduce((a, s) => a + s.amountMinor, 0)).toBe(before.storesTotalMinor);
// Haftalık marketten yalnızca süt alınır, kalanlar bırakılır.
const weekly = String((await ShoppingListModel.findOne({ seedRef: "haftalik" }).lean())!._id);
const sheet = (await getCompletionSheet(ayse, cakir, weekly))!;
const milk = sheet.items.find((i) => i.label === "Süt")!;
const plan = sheet.planned[milk.id]!;
const account = String((await AccountModel.findOne({ name: "Ziraat Maaş" }).lean())!._id);
const done = await completeShopping(
ayse,
cakir,
weekly,
shoppingCompletionSchema.parse({
date: todayIstanbul(),
lines: [
{
itemId: milk.id,
productId: plan.productId,
storeId: plan.storeId,
packages: String(plan.packages),
amount: formatMoneyInput(plan.priceMinor * plan.packages),
},
],
stores: [{ storeId: plan.storeId, accountId: account, attachmentIds: [] }],
leftover: { action: "drop" },
}),
);
expect(done.ok).toBe(true);
const after = (await getShoppingReport(ayse, cakir, 3))!;
expect(after.lists).toHaveLength(1);
const row = after.lists[0]!;
expect(row.summary.counts.same).toBe(1);
expect(row.summary.actualMinor).toBe(plan.priceMinor * plan.packages);
expect(after.listTotals.diffMinor).toBe(row.summary.diffMinor);
const store = after.stores.find((s) => s.fromListsMinor > 0)!;
expect(store.fromListsMinor).toBe(plan.priceMinor * plan.packages);
});
});
describe("birim fiyat geçmişi", () => {
it("fiyatı olan tipler; seçilen tipte 12 ay noktası ve mağaza en düşükleri", async () => {
const r = (await getUnitPriceReport(ayse, cakir))!;
expect(r.types.length).toBeGreaterThan(5);
expect(r.months).toHaveLength(12);
const toz = String((await ProductTypeModel.findOne({ seedRef: "toz-deterjan" }).lean())!._id);
const d = (await getUnitPriceReport(ayse, cakir, toz))!;
expect(d.selected!.id).toBe(toz);
expect(d.selected!.points).toHaveLength(12);
const priced = d.selected!.points.filter((p) => p.count > 0);
expect(priced.length).toBeGreaterThan(3);
for (const p of priced) expect(p.minMinor!).toBeLessThanOrEqual(p.avgMinor!);
const mins = d.selected!.stores.map((s) => s.minMinor);
expect(mins).toEqual([...mins].sort((a, b) => a - b));
});
});
describe("fire / zarar", () => {
it("fire satırları, oran ve nedenler; izleyici de okuyabilir", async () => {
const r = (await getWasteReport(zeynep, cakir, 12))!;
expect(r.rows.length).toBeGreaterThan(0);
for (const row of r.rows) {
const sum = Object.values(row.byReason).reduce((a, b) => a + b, 0);
expect(sum).toBe(row.wasted);
if (row.purchased > 0) expect(row.wastePct).not.toBeNull();
}
const priced = r.rows.reduce((a, x) => a + (x.costMinor ?? 0), 0);
expect(priced).toBe(r.totalCostMinor);
expect(await getWasteReport(fatma, cakir, 12)).toBeNull();
});
});
+482
View File
@@ -0,0 +1,482 @@
import "server-only";
import { Types } from "mongoose";
import {
getPeriodReport,
listAssets,
type AssetView,
type ReportRange,
} from "@/features/assets/service";
import { listContacts, type ContactView } from "@/features/contacts/service";
import { listInKind } from "@/features/in-kind/service";
import { latestTypeUnitPrices, loadTypePriceHistory } from "@/features/prices/service";
import { storeLabel } from "@/features/stores/service";
import { TransactionModel } from "@/models/transaction";
import { ProductPriceModel } from "@/models/product-price";
import { ProductTypeModel } from "@/models/product-type";
import { ShoppingListModel } from "@/models/shopping-list";
import { StockMovementModel } from "@/models/stock-movement";
import { StoreModel } from "@/models/store";
import { formatAssetQuantity, type AssetKey, type InKindDirection } from "@/shared/assets";
import { budgetPeriod, periodOfDate, shiftMonth } from "@/shared/budget";
import { istanbulDayStart } from "@/shared/dates";
import type { PeriodReport } from "@/shared/portfolio";
import {
matchStoreName,
monthlyUnitPrices,
sharePct,
type ReportPeriod,
type UnitPricePoint,
} from "@/shared/reports";
import { foldTr, compareTr } from "@/shared/search";
import type { ShoppingCompletion } from "@/shared/shopping";
import { compareShopping, type ComparisonSummary } from "@/shared/shopping-compare";
import { WASTE_REASONS, type WasteReason } from "@/shared/stock";
import { base, type Base } from "./service";
/**
* Faz 14b raporları: cari ve aynî borçlar, birikim kâr/zarar, alışveriş (liste vs. gerçekleşen,
* mağaza bazlı harcama), birim fiyat geçmişi, fire/zarar. Okuma: viewer; gizlilik kuralları
* kaynak servislerle aynı (gizli cari/varlık/hesap görünmez).
*/
const DAY_MS = 86_400_000;
/** İçinde bulunulan dahil son N bütçe ayı ve tarih aralığı. */
function periodRange(b: Base, months: number) {
const list = Array.from({ length: months }, (_, i) => shiftMonth(b.currentMonth, i - months + 1));
return {
months: list,
from: budgetPeriod(list[0]!, b.s.startDay).from,
to: budgetPeriod(list.at(-1)!, b.s.startDay).to,
};
}
// --- Cari özet ve aynî borçlar ---
export type InKindRow = {
id: string;
contactId: string;
contactName: string;
direction: InKindDirection;
assetKey: AssetKey;
/** Kalan miktar ("12,5 gr 22 ayar"). */
remainingLabel: string;
remainingQuantity: number;
valueMinor: number | null;
date: string;
};
export type ContactsReport = {
contacts: ContactView[];
totals: { owesMeMinor: number; iOweMinor: number };
inKind: InKindRow[];
inKindTotals: { receivableMinor: number; debtMinor: number; unpriced: number };
};
export async function getContactsReport(
userId: string,
householdId: string,
): Promise<ContactsReport | null> {
const [list, inKind] = await Promise.all([
listContacts(userId, householdId),
listInKind(userId, householdId),
]);
if (!list || !inKind) return null;
const open = inKind.filter((d) => !d.closedAt && d.progress.remainingQuantity > 0);
const totals = { receivableMinor: 0, debtMinor: 0, unpriced: 0 };
for (const d of open) {
const v = d.progress.currentValueMinor;
if (v === null) totals.unpriced++;
else if (d.direction === "receivable") totals.receivableMinor += v;
else totals.debtMinor += v;
}
return {
contacts: list.contacts
.filter((c) => !c.archived && c.balanceMinor !== 0)
.sort((a, b) => Math.abs(b.balanceMinor) - Math.abs(a.balanceMinor)),
totals: list.totals,
inKind: open.map((d) => ({
id: d.id,
contactId: d.contactId,
contactName: d.contactName,
direction: d.direction,
assetKey: d.assetKey,
remainingLabel: formatAssetQuantity(d.assetKey, d.progress.remainingQuantity),
remainingQuantity: d.progress.remainingQuantity,
valueMinor: d.progress.currentValueMinor,
date: d.date,
})),
inKindTotals: totals,
};
}
// --- Birikim kâr/zarar ---
export type AssetsReport = {
items: AssetView[];
totals: {
valueMinor: number;
costMinor: number;
unrealizedMinor: number;
realizedMinor: number;
missingPrice: number;
};
period: PeriodReport & { excluded: number };
range: ReportRange;
};
export async function getAssetsReport(
userId: string,
householdId: string,
range: ReportRange,
): Promise<AssetsReport | null> {
const [assets, period] = await Promise.all([
listAssets(userId, householdId),
getPeriodReport(userId, householdId, range),
]);
if (!assets || !period) return null;
return {
items: assets.items.filter((a) => a.summary.quantity > 0 || a.summary.realizedMinor !== 0),
totals: assets.totals,
period,
range,
};
}
// --- Alışveriş: liste vs. gerçekleşen, mağaza bazlı harcama ---
export type CompletedListRow = {
id: string;
name: string;
date: string;
summary: ComparisonSummary;
stores: string[];
};
export type StoreSpendRow = {
name: string;
amountMinor: number;
count: number;
/** Alışverişi tamamla ile gelen kısım. */
fromListsMinor: number;
sharePct: number;
};
export type ShoppingReport = {
period: ReportPeriod;
from: string;
to: string;
lists: CompletedListRow[];
listTotals: ComparisonSummary["reasons"] & {
plannedMinor: number;
actualMinor: number;
diffMinor: number;
};
stores: StoreSpendRow[];
storesTotalMinor: number;
};
export async function getShoppingReport(
userId: string,
householdId: string,
period: ReportPeriod,
): Promise<ShoppingReport | null> {
const b = await base(userId, householdId);
if (!b) return null;
const range = periodRange(b, period);
const hid = b.ctx.m.householdId;
const [lists, storeDocs, txs] = await Promise.all([
ShoppingListModel.find({
householdId: hid,
deletedAt: null,
status: "completed",
"completion.date": { $gte: range.from, $lte: range.to },
})
.select({ name: 1, completion: 1 })
.lean<{ _id: Types.ObjectId; name: string; completion: ShoppingCompletion }[]>(),
StoreModel.find({ householdId: hid })
.select({ name: 1, branch: 1 })
.lean<{ name: string; branch: string }[]>(),
TransactionModel.find({
householdId: hid,
type: "expense",
status: "posted",
deletedAt: null,
date: {
$gte: istanbulDayStart(range.from)!,
$lt: new Date(istanbulDayStart(range.to)!.getTime() + DAY_MS),
},
...(!b.s.includePrivate && b.ctx.hiddenAccountIds.length
? { accountId: { $nin: b.ctx.hiddenAccountIds } }
: {}),
})
.select({ payeeName: 1, amountMinor: 1, shoppingListId: 1 })
.lean<{ payeeName: string; amountMinor: number; shoppingListId: Types.ObjectId | null }[]>(),
]);
const rows: CompletedListRow[] = lists
.map((l) => ({
id: String(l._id),
name: l.name,
date: l.completion.date,
stores: l.completion.stores.map((s) => s.label),
summary: compareShopping(
l.completion.rows.map((r) => ({
match: r.match,
plan: r.plan,
actual:
r.amountMinor === null
? null
: { productId: r.productId, packages: r.packages, amountMinor: r.amountMinor },
})),
),
}))
.sort((a, c) => (a.date < c.date ? 1 : -1));
const listTotals = {
plannedMinor: 0,
actualMinor: 0,
diffMinor: 0,
price: 0,
quantity: 0,
substitute: 0,
skipped: 0,
extra: 0,
unplanned: 0,
};
for (const r of rows) {
listTotals.plannedMinor += r.summary.plannedMinor;
listTotals.actualMinor += r.summary.actualMinor;
listTotals.diffMinor += r.summary.diffMinor;
for (const k of ["price", "quantity", "substitute", "skipped", "extra", "unplanned"] as const)
listTotals[k] += r.summary.reasons[k];
}
// Mağaza adı bazında (şubeler birlikte): gider alıcısı mağaza adıyla başlıyorsa.
const names = [...new Set(storeDocs.map((s) => s.name))];
const byStore = new Map<string, StoreSpendRow>();
for (const t of txs) {
const name = matchStoreName(t.payeeName, names, foldTr);
if (!name) continue;
const row = byStore.get(name) ?? {
name,
amountMinor: 0,
count: 0,
fromListsMinor: 0,
sharePct: 0,
};
row.amountMinor += t.amountMinor;
row.count++;
if (t.shoppingListId) row.fromListsMinor += t.amountMinor;
byStore.set(name, row);
}
const storesTotal = [...byStore.values()].reduce((a, r) => a + r.amountMinor, 0);
const stores = [...byStore.values()]
.map((r) => ({ ...r, sharePct: sharePct(r.amountMinor, storesTotal) }))
.sort((a, c) => c.amountMinor - a.amountMinor);
return {
period,
from: range.from,
to: range.to,
lists: rows,
listTotals,
stores,
storesTotalMinor: storesTotal,
};
}
// --- Birim fiyat geçmişi ---
export type UnitPriceReport = {
types: { id: string; name: string; unit: string; records: number }[];
selected: {
id: string;
name: string;
unit: string;
points: UnitPricePoint[];
/** Mağaza başına dönemdeki en düşük birim fiyat. */
stores: { id: string; label: string; minMinor: number; lastDate: string }[];
} | null;
months: string[];
};
export async function getUnitPriceReport(
userId: string,
householdId: string,
typeId?: string,
): Promise<UnitPriceReport | null> {
const b = await base(userId, householdId);
if (!b) return null;
const hid = b.ctx.m.householdId;
const range = periodRange(b, 12);
const [types, counts] = await Promise.all([
ProductTypeModel.find({ householdId: hid, archivedAt: null })
.select({ name: 1, unit: 1 })
.lean<{ _id: Types.ObjectId; name: string; unit: string }[]>(),
ProductPriceModel.aggregate<{ _id: Types.ObjectId; n: number }>([
{ $match: { householdId: hid, deletedAt: null } },
{ $lookup: { from: "products", localField: "productId", foreignField: "_id", as: "p" } },
{ $unwind: "$p" },
{ $group: { _id: "$p.productTypeId", n: { $sum: 1 } } },
]),
]);
const count = new Map(counts.map((c) => [String(c._id), c.n]));
const options = types
.map((x) => ({
id: String(x._id),
name: x.name,
unit: x.unit,
records: count.get(String(x._id)) ?? 0,
}))
.filter((x) => x.records > 0)
.sort((a, c) => compareTr(a.name, c.name));
const chosen = options.find((x) => x.id === typeId) ?? options[0];
if (!chosen) return { types: options, selected: null, months: range.months };
const [history, storeDocs] = await Promise.all([
loadTypePriceHistory(hid, new Types.ObjectId(chosen.id)),
StoreModel.find({ householdId: hid })
.select({ name: 1, branch: 1 })
.lean<{ _id: Types.ObjectId; name: string; branch: string }[]>(),
]);
const inRange = history.filter((h) => h.date >= range.from && h.date <= range.to);
const points = monthlyUnitPrices(
inRange.map((h) => ({
month: periodOfDate(h.date, b.s.startDay),
unitPriceMinor: h.unitPriceMinor,
})),
range.months,
);
const labelOf = new Map(storeDocs.map((s) => [String(s._id), storeLabel(s)]));
const perStore = new Map<string, { minMinor: number; lastDate: string }>();
for (const h of inRange) {
const cur = perStore.get(h.storeId);
perStore.set(h.storeId, {
minMinor: cur ? Math.min(cur.minMinor, h.unitPriceMinor) : h.unitPriceMinor,
lastDate: cur && cur.lastDate > h.date ? cur.lastDate : h.date,
});
}
return {
types: options,
months: range.months,
selected: {
id: chosen.id,
name: chosen.name,
unit: chosen.unit,
points,
stores: [...perStore.entries()]
.map(([id, v]) => ({ id, label: labelOf.get(id) ?? "—", ...v }))
.sort((a, c) => a.minMinor - c.minMinor),
},
};
}
// --- Fire / zarar ---
export type WasteRow = {
productTypeId: string;
name: string;
unit: string;
wasted: number;
purchased: number;
/** Fire / alım (yüzde); alım yoksa null. */
wastePct: number | null;
byReason: Record<WasteReason, number>;
/** Tahmini maliyet: fire × son bilinen birim fiyat (fiyat yoksa null). */
costMinor: number | null;
};
export type WasteReport = {
period: ReportPeriod;
from: string;
to: string;
rows: WasteRow[];
/** Neden başına fire satırı sayısı ve tahmini maliyet. */
reasons: Record<WasteReason, { count: number; costMinor: number }>;
totalCostMinor: number;
unpriced: number;
};
export async function getWasteReport(
userId: string,
householdId: string,
period: ReportPeriod,
): Promise<WasteReport | null> {
const b = await base(userId, householdId);
if (!b) return null;
const range = periodRange(b, period);
const hid = b.ctx.m.householdId;
const moves = await StockMovementModel.find({
householdId: hid,
deletedAt: null,
kind: { $in: ["waste", "purchase"] },
date: { $gte: range.from, $lte: range.to },
})
.select({ productTypeId: 1, kind: 1, quantity: 1, wasteReason: 1 })
.lean<
{
productTypeId: Types.ObjectId;
kind: "waste" | "purchase";
quantity: number;
wasteReason: WasteReason | null;
}[]
>();
const wasteTypeIds = [
...new Set(moves.filter((m) => m.kind === "waste").map((m) => String(m.productTypeId))),
].map((id) => new Types.ObjectId(id));
const [types, prices] = await Promise.all([
ProductTypeModel.find({ householdId: hid, _id: { $in: wasteTypeIds } })
.select({ name: 1, unit: 1 })
.lean<{ _id: Types.ObjectId; name: string; unit: string }[]>(),
latestTypeUnitPrices(hid, wasteTypeIds),
]);
const emptyReasons = () =>
Object.fromEntries(WASTE_REASONS.map((r) => [r, 0])) as Record<WasteReason, number>;
const rows = new Map<string, WasteRow>();
for (const t of types)
rows.set(String(t._id), {
productTypeId: String(t._id),
name: t.name,
unit: t.unit,
wasted: 0,
purchased: 0,
wastePct: null,
byReason: emptyReasons(),
costMinor: null,
});
const reasons = Object.fromEntries(
WASTE_REASONS.map((r) => [r, { count: 0, costMinor: 0 }]),
) as WasteReport["reasons"];
for (const m of moves) {
const row = rows.get(String(m.productTypeId));
if (!row) continue;
if (m.kind === "purchase") {
row.purchased += m.quantity;
continue;
}
const reason = m.wasteReason ?? "other";
row.wasted += m.quantity;
row.byReason[reason] += m.quantity;
reasons[reason].count++;
const price = prices.get(row.productTypeId);
if (price) reasons[reason].costMinor += Math.round((m.quantity * price.unitPriceMinor) / 1000);
}
let total = 0;
let unpriced = 0;
const list = [...rows.values()].map((r) => {
const price = prices.get(r.productTypeId);
const costMinor = price ? Math.round((r.wasted * price.unitPriceMinor) / 1000) : null;
if (costMinor === null) unpriced++;
else total += costMinor;
return { ...r, costMinor, wastePct: r.purchased > 0 ? sharePct(r.wasted, r.purchased) : null };
});
list.sort((a, c) => (c.costMinor ?? -1) - (a.costMinor ?? -1) || compareTr(a.name, c.name));
return {
period,
from: range.from,
to: range.to,
rows: list,
reasons,
totalCostMinor: total,
unpriced,
};
}
+3 -3
View File
@@ -28,9 +28,9 @@ import { categoryBreakdown, savingsRate, sharePct, type CategoryBreakdown } from
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
const ORPHAN = "__none__"; const ORPHAN = "__none__";
type Base = { ctx: Ctx; s: Settings; today: string; currentMonth: string }; export type Base = { ctx: Ctx; s: Settings; today: string; currentMonth: string };
async function base(userId: string, householdId: string): Promise<Base | null> { export async function base(userId: string, householdId: string): Promise<Base | null> {
const ctx = await loadContext(userId, householdId, "viewer"); const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null; if (!ctx) return null;
const s = await settingsOf(ctx.m.householdId); const s = await settingsOf(ctx.m.householdId);
@@ -61,7 +61,7 @@ export type MonthInfo = {
currentMonth: string; currentMonth: string;
}; };
const monthInfo = (b: Base, month: string): MonthInfo => ({ export const monthInfo = (b: Base, month: string): MonthInfo => ({
month, month,
...budgetPeriod(month, b.s.startDay), ...budgetPeriod(month, b.s.startDay),
current: month === b.currentMonth, current: month === b.currentMonth,
+160
View File
@@ -1900,6 +1900,26 @@ export const tr = {
title: "Bütçe ve gerçekleşen", title: "Bütçe ve gerçekleşen",
description: "Kategori bazında limit ve harcama, son 6 ayın karşılaştırması.", description: "Kategori bazında limit ve harcama, son 6 ayın karşılaştırması.",
}, },
contacts: {
title: "Cari ve aynî borçlar",
description: "Kim kime ne kadar borçlu; açık altın/döviz borç ve alacakları.",
},
assets: {
title: "Birikim kâr/zarar",
description: "Varlık başına maliyet, değer ve kâr/zarar; dönem özeti.",
},
shopping: {
title: "Alışveriş",
description: "Liste ve gerçekleşen karşılaştırması, mağaza bazlı harcama.",
},
unitPrice: {
title: "Birim fiyat geçmişi",
description: "Ürün tipi başına aylık en düşük ve ortalama birim fiyat.",
},
waste: {
title: "Fire / zarar",
description: "Fire miktarı, alıma oranı, nedenleri ve tahmini maliyet.",
},
}, },
}, },
categories: { categories: {
@@ -1969,12 +1989,128 @@ export const tr = {
noLimit: "Bütçe yok", noLimit: "Bütçe yok",
covered: "üst kategoride", covered: "üst kategoride",
}, },
periods: { 3: "Son 3 ay", 6: "Son 6 ay", 12: "Son 12 ay" } as Record<number, string>,
periodLabel: "Dönem",
rangeText: (from: string, to: string) => `${from} – ${to}`,
contacts: {
title: "Cari ve aynî borçlar",
description:
"Kim kime ne kadar borçlu; açık altın/döviz borç ve alacakları güncel değeriyle.",
owesMe: "Bana borçlu",
iOwe: "Ben borçluyum",
contact: "Cari",
balance: "Bakiye",
direction: "Yön",
due: "Vade",
noContacts: "Bakiyesi olan cari yok.",
inKindTitle: "Açık aynî borç ve alacaklar",
csvContacts: "Cariler CSV",
csvInKind: "Aynî CSV",
inKindHint: "Kalan miktarın güncel fiyatla TL karşılığı (alacak alış, borç satış fiyatıyla).",
remaining: "Kalan",
value: "Güncel değer",
since: "Tarih",
noInKind: "Açık aynî kayıt yok.",
receivable: "Alacak",
debt: "Borç",
unpriced: (n: number) => `${n} kaydın fiyatı yok; toplama katılmadı.`,
dueStates: { overdue: "Vadesi geçti", upcoming: "Vadesi yaklaşıyor", none: "" } as Record<
string,
string
>,
},
assets: {
title: "Birikim kâr/zarar",
description:
"Varlık başına maliyet, güncel değer (alış fiyatı) ve kâr/zarar; seçilen dönemin özeti.",
ranges: { "3": "Son 3 ay", "6": "Son 6 ay", "12": "Son 12 ay", tum: "Tümü" } as Record<
string,
string
>,
asset: "Varlık",
quantity: "Miktar",
cost: "Maliyet",
value: "Güncel değer",
unrealized: "Gerçekleşmemiş K/Z",
realized: "Gerçekleşen K/Z",
noPrice: "Fiyat yok",
periodTitle: "Dönem özeti",
startValue: "Dönem başı değer",
invested: "Yatırılan",
proceeds: "Satıştan alınan",
endValue: "Dönem sonu değer",
profit: "Dönem kârı",
excluded: (n: number) => `${n} varlığın fiyatı olmadığı için dönem toplamına katılmadı.`,
empty: "Varlık yok.",
total: "Toplam",
},
shopping: {
title: "Alışveriş raporu",
description: "Tamamlanan listelerde plan ve gerçekleşen; mağaza bazlı harcama.",
listsTitle: "Liste ve gerçekleşen",
listsEmpty: "Bu dönemde tamamlanan alışveriş yok.",
list: "Liste",
date: "Tarih",
planned: "Planlanan",
actual: "Gerçekleşen",
diff: "Fark",
counts: (same: number, sub: number, skipped: number, extra: number) =>
`${same} aynı · ${sub} muadil · ${skipped} alınmadı · ${extra} ekstra`,
storesTitle: "Mağaza bazlı harcama",
storesHint: "Alıcısı mağaza adıyla başlayan gerçekleşmiş giderler (şubeler birlikte).",
store: "Mağaza",
amount: "Tutar",
count: "İşlem",
share: "Oran",
fromLists: "Listelerden",
storesEmpty: "Bu dönemde mağaza adıyla eşleşen gider yok.",
total: "Toplam",
csvLists: "Listeler CSV",
csvStores: "Mağazalar CSV",
},
unitPrice: {
title: "Birim fiyat geçmişi",
description: "Ürün tipi başına son 12 bütçe ayında en düşük ve ortalama birim fiyat.",
type: "Ürün tipi",
noTypes: "Henüz fiyat kaydı yok.",
min: "En düşük",
avg: "Ortalama",
month: "Ay",
count: "Kayıt",
chartLabel: (name: string) => `${name}: aylık en düşük ve ortalama birim fiyat`,
storesTitle: "Mağazalarda dönemin en düşük birim fiyatı",
store: "Mağaza",
last: "Son kayıt",
records: (n: number) => `${n} kayıt`,
},
waste: {
title: "Fire / zarar",
description: "Ürün tipi başına fire miktarı, alıma oranı, nedenleri ve tahmini maliyeti.",
total: "Tahmini toplam maliyet",
product: "Ürün tipi",
wasted: "Fire",
purchased: "Alım",
ratio: "Fire / alım",
cost: "Tahmini maliyet",
reasonsTitle: "Nedenler",
count: (n: number) => `${n} kayıt`,
empty: "Bu dönemde fire kaydı yok.",
unpriced: (n: number) => `${n} ürün tipinin fiyatı yok; maliyete katılmadı.`,
costHint: "Fire miktarı × ürün tipinin son bilinen birim fiyatı.",
},
csvFiles: { csvFiles: {
categories: (month: string) => `butcem-kategori-dagilimi-${month}.csv`, categories: (month: string) => `butcem-kategori-dagilimi-${month}.csv`,
trend: (date: string) => `butcem-aylik-trend-${date}.csv`, trend: (date: string) => `butcem-aylik-trend-${date}.csv`,
people: (month: string) => `butcem-kisi-bazli-${month}.csv`, people: (month: string) => `butcem-kisi-bazli-${month}.csv`,
accounts: (month: string) => `butcem-hesap-bazli-${month}.csv`, accounts: (month: string) => `butcem-hesap-bazli-${month}.csv`,
budget: (month: string) => `butcem-butce-gerceklesen-${month}.csv`, budget: (month: string) => `butcem-butce-gerceklesen-${month}.csv`,
contacts: (date: string) => `butcem-cari-${date}.csv`,
inKind: (date: string) => `butcem-ayni-${date}.csv`,
assets: (date: string) => `butcem-birikim-${date}.csv`,
lists: (date: string) => `butcem-alisveris-listeleri-${date}.csv`,
stores: (date: string) => `butcem-magaza-harcama-${date}.csv`,
unitPrice: (name: string, date: string) => `butcem-birim-fiyat-${name}-${date}.csv`,
waste: (date: string) => `butcem-fire-${date}.csv`,
}, },
csvHeaders: { csvHeaders: {
categories: [ categories: [
@@ -1998,6 +2134,30 @@ export const tr = {
"İşlem sayısı", "İşlem sayısı",
], ],
budget: ["Kategori", "Limit", "Gerçekleşen", "Kalan", "Kullanım (%)"], budget: ["Kategori", "Limit", "Gerçekleşen", "Kalan", "Kullanım (%)"],
contacts: ["Cari", "Bakiye (+ bana borçlu, − ben borçluyum)", "Vade"],
inKind: ["Cari", "Yön", "Kalan", "Güncel değer", "Tarih"],
assets: [
"Varlık",
"Miktar",
"Maliyet",
"Güncel değer",
"Gerçekleşmemiş K/Z",
"Gerçekleşen K/Z",
],
lists: [
"Tarih",
"Liste",
"Planlanan",
"Gerçekleşen",
"Fark",
"Aynı",
"Muadil",
"Alınmadı",
"Ekstra",
],
stores: ["Mağaza", "Tutar", "İşlem", "Oran (%)", "Listelerden"],
unitPrice: ["Ay", "En düşük birim fiyat", "Ortalama birim fiyat", "Kayıt"],
waste: ["Ürün tipi", "Birim", "Fire", "Alım", "Fire / alım (%)", "Tahmini maliyet"],
}, },
}, },
dashboard: { dashboard: {
+39 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { categoryBreakdown, changePct, savingsRate, sharePct } from "./reports"; import {
categoryBreakdown,
changePct,
matchStoreName,
monthlyUnitPrices,
savingsRate,
sharePct,
} from "./reports";
describe("oranlar", () => { describe("oranlar", () => {
it("toplam gidere oran bir ondalıklı (35.000 içinde 5.000 → %14,3)", () => { 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); 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")); rows.sort((a, b) => b.amountMinor - a.amountMinor || a.name.localeCompare(b.name, "tr"));
return { totalMinor, prevTotalMinor, rows }; 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,
};
});
}