diff --git a/src/features/prices/service.ts b/src/features/prices/service.ts index 2003a52..659a56d 100644 --- a/src/features/prices/service.ts +++ b/src/features/prices/service.ts @@ -381,6 +381,57 @@ export async function loadTypeOffers( 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). * Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır. diff --git a/src/features/reports/extra.int.test.ts b/src/features/reports/extra.int.test.ts new file mode 100644 index 0000000..eabbef9 --- /dev/null +++ b/src/features/reports/extra.int.test.ts @@ -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>; +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(); + }); +}); diff --git a/src/features/reports/extra.ts b/src/features/reports/extra.ts new file mode 100644 index 0000000..e512aef --- /dev/null +++ b/src/features/reports/extra.ts @@ -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 { + 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 { + 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 { + 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(); + 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 { + 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(); + 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; + /** 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; + totalCostMinor: number; + unpriced: number; +}; + +export async function getWasteReport( + userId: string, + householdId: string, + period: ReportPeriod, +): Promise { + 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; + const rows = new Map(); + 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, + }; +} diff --git a/src/features/reports/service.ts b/src/features/reports/service.ts index 166c90f..0d118f2 100644 --- a/src/features/reports/service.ts +++ b/src/features/reports/service.ts @@ -28,9 +28,9 @@ import { categoryBreakdown, savingsRate, sharePct, type CategoryBreakdown } from const DAY_MS = 86_400_000; 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 { +export async function base(userId: string, householdId: string): Promise { const ctx = await loadContext(userId, householdId, "viewer"); if (!ctx) return null; const s = await settingsOf(ctx.m.householdId); @@ -61,7 +61,7 @@ export type MonthInfo = { currentMonth: string; }; -const monthInfo = (b: Base, month: string): MonthInfo => ({ +export const monthInfo = (b: Base, month: string): MonthInfo => ({ month, ...budgetPeriod(month, b.s.startDay), current: month === b.currentMonth, diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index 472c9f0..1bcf04e 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -1900,6 +1900,26 @@ export const tr = { title: "Bütçe ve gerçekleşen", 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: { @@ -1969,12 +1989,128 @@ export const tr = { noLimit: "Bütçe yok", covered: "üst kategoride", }, + periods: { 3: "Son 3 ay", 6: "Son 6 ay", 12: "Son 12 ay" } as Record, + 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: { categories: (month: string) => `butcem-kategori-dagilimi-${month}.csv`, trend: (date: string) => `butcem-aylik-trend-${date}.csv`, people: (month: string) => `butcem-kisi-bazli-${month}.csv`, accounts: (month: string) => `butcem-hesap-bazli-${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: { categories: [ @@ -1998,6 +2134,30 @@ export const tr = { "İşlem sayısı", ], 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: { diff --git a/src/shared/reports.test.ts b/src/shared/reports.test.ts index 54a24b0..a7de12b 100644 --- a/src/shared/reports.test.ts +++ b/src/shared/reports.test.ts @@ -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 }, + ]); + }); +}); diff --git a/src/shared/reports.ts b/src/shared/reports.ts index 76ed794..2158799 100644 --- a/src/shared/reports.ts +++ b/src/shared/reports.ts @@ -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, + }; + }); +}