diff --git a/CHANGELOG.md b/CHANGELOG.md index bbefafb..91acb4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [Yayınlanmamış] +## [0.14.0] - 2026-11-07 + +Faz 14: dashboard ana grafiği ve raporlar (14a ve 14b alt fazları). + +### Eklendi — Faz 14b: cari, birikim, alışveriş, birim fiyat ve fire raporları + +- Cari ve aynî borçlar (`/raporlar/cari`): bakiyesi olan cariler (bana borçlu / ben borçluyum, vade durumu), açık aynî borç ve alacaklar kalan miktar ve güncel TL değeriyle; toplamlar. +- Birikim kâr/zarar (`/raporlar/birikim`): varlık başına miktar, maliyet, güncel değer, gerçekleşmemiş ve gerçekleşen K/Z; son 3/6/12 ay veya tümü için dönem özeti (başlangıç değeri, yatırılan, satıştan alınan, bitiş değeri, dönem kârı). +- Alışveriş (`/raporlar/alisveris`): tamamlanan listelerde planlanan / gerçekleşen / fark, eşleşme sayıları ve farkın nedenleri; mağaza bazlı harcama (alıcısı mağaza adıyla başlayan giderler, şubeler birlikte, "Alışverişi tamamla"dan gelen kısım ayrıca). +- Birim fiyat geçmişi (`/raporlar/birim-fiyat`): ürün tipi seçimi, son 12 bütçe ayında aylık en düşük ve ortalama birim fiyat (grafik ve tablo), mağazalarda dönemin en düşük birim fiyatı. +- Fire / zarar (`/raporlar/fire`): ürün tipi başına fire, alım, fire/alım oranı, nedenlere göre dağılım ve tahmini maliyet (fire × son bilinen birim fiyat). +- Hepsinde CSV dışa aktarma; rapor ana sayfasında yeni rapor kartları. +- Testler: mağaza adı eşleşmesi ve aylık birim fiyat özeti (birim); cari/aynî, birikim, alışveriş (tamamlanan liste dahil), birim fiyat ve fire raporları (entegrasyon). + ### Eklendi — Faz 14a: dashboard ana grafiği ve finans raporları - Dashboard ana çizgi grafiği: Planlanan gelir (yeşil), Planlanan gider / bütçe (mavi, kesikli), Gerçekleşen gider (kırmızı). Periyotlar: Haftalık (bu hafta, günlük), Aylık (bütçe ayı, günlük), Altı aylık ve Yıllık (bütçe ayları). Kümülatif / dönemsel görünüm; seçim URL'de (`?periyot=…&gorunum=…`). Bütçesi olmayan dönemde bütçe çizgisi boş, gelecek günlerde gerçekleşen çizilmez; grafiğin altında tablo görünümü. diff --git a/docs/PROMPT.md b/docs/PROMPT.md index e747612..8927ced 100644 --- a/docs/PROMPT.md +++ b/docs/PROMPT.md @@ -325,6 +325,7 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em - **Faz 11 kararları (2026-11-06):** 11a stok kategorileri, ürün tipleri, hareketler, SKT ve %20 eşik; 11b periyodik sayım, fire ayarlı öneri ve tüketim analizi; `v0.11.0` 11b sonunda. Eşiğin altındaki ürünler Faz 11'de "Alınması önerilenler" listesinde gösterilir (koleksiyon yok); Faz 13'te aktif alışveriş listesine onayla/reddet önerisi olarak bağlanır. SKT alım hareketinde (isteğe bağlı) girilir; tüketim ve fire FIFO ile en eski alımdan düşülür. - **Faz 12 kararları (2026-11-06):** 12a mağazalar ve ürünler (marka/paket, görseller), 12b fiyat kayıtları, birim fiyat ve fiyat geçmişi; `v0.12.0` 12b sonunda. Mağazalar, ürünler ve fiyatlar hane bazlıdır (hane içinde ortak, başka hane görmez). Paket birimi ürün tipinin birimine kendiliğinden çevrilemiyorsa ürün kaydında dönüşüm zorunludur ("5 kg = 33 yıkama"); her fiyat ürün tipinin biriminde karşılaştırılır. - **Faz 13 kararları (2026-11-06):** 13a alışveriş listeleri, stok eşiği önerileri (onayla/reddet), akıllı mağaza önerisi ve alışveriş modu; 13b alışveriş sonrası giriş, karşılaştırma raporu ve "Alışverişi tamamla"; `v0.13.0` 13b sonunda. Birden çok mağazadan alışverişte mağaza başına ayrı bölünmüş gider (her mağaza için ayrı hesap ve fiş). Tamamlanınca liste kapanır ve rapor sabitlenir; alınmayanlar başka aktif listeye taşınır, yeni listeye alınır ya da bırakılır. Reddedilen öneri yeni alım girilene kadar gösterilmez. Markası belirtilmeyen ürün tipi kalemi (ör. pazardan domates) tipin biriminde stoğa işlenir, fiyat kaydı oluşmaz. +- **Faz 14 kararları (2026-11-07):** 14a dashboard ana grafiği (Haftalık/Aylık/Altı aylık/Yıllık, kümülatif/dönemsel) ve finans raporları (kategori dağılımı, aylık trend, kişi ve hesap bazlı, bütçe vs. gerçekleşen); 14b 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; `v0.14.0` 14b sonunda. Raporlar `/raporlar` altında ayrı sayfalar; dönem ve filtreler URL'de, her raporda CSV. "Ay" bütçe ayıdır; gider kuralları bütçeyle aynı (taksit modu, gizli hesap ayarı). Kişi bazlı harcama gideri kaydeden üyeye göre; mağaza bazlı harcama gider alıcısının mağaza adıyla başlamasına göre (şubeler birlikte). - **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir. --- diff --git a/package-lock.json b/package-lock.json index 5bfb9d5..9418a6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "butcem", - "version": "0.13.0", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "butcem", - "version": "0.13.0", + "version": "0.14.0", "dependencies": { "@hookform/resolvers": "^7.0.2", "@tanstack/react-table": "^9.3.1", diff --git a/package.json b/package.json index a5ac940..c06e0d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "butcem", - "version": "0.13.0", + "version": "0.14.0", "private": true, "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "engines": { diff --git a/src/app/(app)/raporlar/alisveris/csv/route.ts b/src/app/(app)/raporlar/alisveris/csv/route.ts new file mode 100644 index 0000000..6914822 --- /dev/null +++ b/src/app/(app)/raporlar/alisveris/csv/route.ts @@ -0,0 +1,47 @@ +import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv"; +import { getShoppingReport } from "@/features/reports/extra"; +import { tr } from "@/i18n/tr"; +import { csvMoney, toCsv } from "@/shared/csv"; +import { todayIstanbul } from "@/shared/dates"; +import { parsePeriod } from "@/shared/reports"; + +/** GET /raporlar/alisveris/csv?donem=3|6|12&tablo=listeler|magazalar */ +export async function GET(request: Request) { + const c = await csvContext(request); + if (!c) return unauthorized(); + const r = await getShoppingReport(c.userId, c.householdId, parsePeriod(c.params.get("donem"))); + if (!r) return forbidden(); + const h = tr.reports.csvHeaders; + const today = todayIstanbul(); + if (c.params.get("tablo") === "magazalar") + return csvResponse( + toCsv( + h.stores, + r.stores.map((s) => [ + s.name, + csvMoney(s.amountMinor), + s.count, + csvPct(s.sharePct), + csvMoney(s.fromListsMinor), + ]), + ), + tr.reports.csvFiles.stores(today), + ); + return csvResponse( + toCsv( + h.lists, + r.lists.map((l) => [ + l.date, + l.name, + csvMoney(l.summary.plannedMinor), + csvMoney(l.summary.actualMinor), + csvMoney(l.summary.diffMinor), + l.summary.counts.same, + l.summary.counts.substitute, + l.summary.counts.skipped, + l.summary.counts.extra, + ]), + ), + tr.reports.csvFiles.lists(today), + ); +} diff --git a/src/app/(app)/raporlar/alisveris/page.tsx b/src/app/(app)/raporlar/alisveris/page.tsx new file mode 100644 index 0000000..17ae2e3 --- /dev/null +++ b/src/app/(app)/raporlar/alisveris/page.tsx @@ -0,0 +1,57 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { + CsvButton, + RangeNav, + ReportFrame, + ToolsEnd, +} from "@/features/reports/components/report-frame"; +import { ShoppingReportView } from "@/features/reports/components/shopping-waste-view"; +import { getShoppingReport } from "@/features/reports/extra"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; +import { parsePeriod, REPORT_PERIODS } from "@/shared/reports"; + +const t = tr.reports.shopping; +export const metadata: Metadata = { title: t.title }; + +/** /raporlar/alisveris?donem=3|6|12 — liste vs. gerçekleşen, mağaza bazlı harcama. */ +export default async function ShoppingReportPage({ + searchParams, +}: PageProps<"/raporlar/alisveris">) { + const { user, household } = await requireActiveHousehold("viewer"); + const period = parsePeriod((await searchParams).donem); + const data = await getShoppingReport(user.id, household.id, period); + if (!data) notFound(); + return ( + + ({ + value: String(p), + label: tr.reports.periods[p]!, + }))} + /> + + + + + + } + > + + + ); +} diff --git a/src/app/(app)/raporlar/birikim/csv/route.ts b/src/app/(app)/raporlar/birikim/csv/route.ts new file mode 100644 index 0000000..075c41a --- /dev/null +++ b/src/app/(app)/raporlar/birikim/csv/route.ts @@ -0,0 +1,31 @@ +import { REPORT_RANGES, type ReportRange } from "@/features/assets/service"; +import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv"; +import { getAssetsReport } from "@/features/reports/extra"; +import { tr } from "@/i18n/tr"; +import { formatAssetQuantity } from "@/shared/assets"; +import { csvMoney, toCsv } from "@/shared/csv"; +import { todayIstanbul } from "@/shared/dates"; + +/** GET /raporlar/birikim/csv — varlık başına maliyet, değer ve kâr/zarar. */ +export async function GET(request: Request) { + const c = await csvContext(request); + if (!c) return unauthorized(); + const q = c.params.get("donem"); + const range: ReportRange = REPORT_RANGES.includes(q as ReportRange) ? (q as ReportRange) : "6"; + const r = await getAssetsReport(c.userId, c.householdId, range); + if (!r) return forbidden(); + return csvResponse( + toCsv( + tr.reports.csvHeaders.assets, + r.items.map((a) => [ + a.name || a.label, + formatAssetQuantity(a.assetKey, a.summary.quantity), + csvMoney(a.summary.costMinor), + a.summary.currentValueMinor === null ? "" : csvMoney(a.summary.currentValueMinor), + a.summary.unrealizedMinor === null ? "" : csvMoney(a.summary.unrealizedMinor), + csvMoney(a.summary.realizedMinor), + ]), + ), + tr.reports.csvFiles.assets(todayIstanbul()), + ); +} diff --git a/src/app/(app)/raporlar/birikim/page.tsx b/src/app/(app)/raporlar/birikim/page.tsx new file mode 100644 index 0000000..2ef1736 --- /dev/null +++ b/src/app/(app)/raporlar/birikim/page.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { AssetsReportView } from "@/features/reports/components/contacts-assets-view"; +import { + CsvButton, + RangeNav, + ReportFrame, + ToolsEnd, +} from "@/features/reports/components/report-frame"; +import { getAssetsReport } from "@/features/reports/extra"; +import { REPORT_RANGES, type ReportRange } from "@/features/assets/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +const t = tr.reports.assets; +export const metadata: Metadata = { title: t.title }; + +/** /raporlar/birikim?donem=3|6|12|tum — varlık kâr/zarar ve dönem özeti. */ +export default async function AssetsReportPage({ searchParams }: PageProps<"/raporlar/birikim">) { + const { user, household } = await requireActiveHousehold("viewer"); + const q = (await searchParams).donem; + const range: ReportRange = REPORT_RANGES.includes(q as ReportRange) ? (q as ReportRange) : "6"; + const data = await getAssetsReport(user.id, household.id, range); + if (!data) notFound(); + return ( + + ({ value: r, label: t.ranges[r]! }))} + /> + + + + + } + > + + + ); +} diff --git a/src/app/(app)/raporlar/birim-fiyat/csv/route.ts b/src/app/(app)/raporlar/birim-fiyat/csv/route.ts new file mode 100644 index 0000000..a1cfa14 --- /dev/null +++ b/src/app/(app)/raporlar/birim-fiyat/csv/route.ts @@ -0,0 +1,30 @@ +import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv"; +import { getUnitPriceReport } from "@/features/reports/extra"; +import { tr } from "@/i18n/tr"; +import { csvMoney, toCsv } from "@/shared/csv"; +import { todayIstanbul } from "@/shared/dates"; +import { unitPriceBase } from "@/shared/prices"; + +/** GET /raporlar/birim-fiyat/csv?tip=<ürün tipi> — ay başına en düşük ve ortalama (gösterim biriminde). */ +export async function GET(request: Request) { + const c = await csvContext(request); + if (!c) return unauthorized(); + const r = await getUnitPriceReport(c.userId, c.householdId, c.params.get("tip") ?? undefined); + if (!r?.selected) return forbidden(); + const sel = r.selected; + const base = unitPriceBase(sel.unit); + const money = (x: number | null) => (x === null ? "" : csvMoney(Math.round(x * base.factor))); + const header = tr.reports.csvHeaders.unitPrice.map((h, i) => + i === 1 || i === 2 ? `${h} (TL/${base.unit})` : h, + ); + return csvResponse( + toCsv( + header, + sel.points.map((p) => [p.month, money(p.minMinor), money(p.avgMinor), p.count]), + ), + tr.reports.csvFiles.unitPrice( + sel.name.toLocaleLowerCase("tr-TR").replace(/\s+/g, "-"), + todayIstanbul(), + ), + ); +} diff --git a/src/app/(app)/raporlar/birim-fiyat/page.tsx b/src/app/(app)/raporlar/birim-fiyat/page.tsx new file mode 100644 index 0000000..7240a95 --- /dev/null +++ b/src/app/(app)/raporlar/birim-fiyat/page.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { CsvButton, ReportFrame, ToolsEnd } from "@/features/reports/components/report-frame"; +import { UnitPriceView } from "@/features/reports/components/unit-price-view"; +import { getUnitPriceReport } from "@/features/reports/extra"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +const t = tr.reports.unitPrice; +export const metadata: Metadata = { title: t.title }; + +/** /raporlar/birim-fiyat?tip=<ürün tipi> — aylık en düşük ve ortalama birim fiyat. */ +export default async function UnitPriceReportPage({ + searchParams, +}: PageProps<"/raporlar/birim-fiyat">) { + const { user, household } = await requireActiveHousehold("viewer"); + const tip = (await searchParams).tip; + const data = await getUnitPriceReport( + user.id, + household.id, + typeof tip === "string" ? tip : undefined, + ); + if (!data) notFound(); + return ( + + + + ) : undefined + } + > + + + ); +} diff --git a/src/app/(app)/raporlar/cari/csv/route.ts b/src/app/(app)/raporlar/cari/csv/route.ts new file mode 100644 index 0000000..dd372e5 --- /dev/null +++ b/src/app/(app)/raporlar/cari/csv/route.ts @@ -0,0 +1,36 @@ +import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv"; +import { getContactsReport } from "@/features/reports/extra"; +import { tr } from "@/i18n/tr"; +import { csvMoney, toCsv } from "@/shared/csv"; +import { todayIstanbul } from "@/shared/dates"; + +/** GET /raporlar/cari/csv?tablo=cari|ayni */ +export async function GET(request: Request) { + const c = await csvContext(request); + if (!c) return unauthorized(); + const r = await getContactsReport(c.userId, c.householdId); + if (!r) return forbidden(); + const h = tr.reports.csvHeaders; + const today = todayIstanbul(); + if (c.params.get("tablo") === "ayni") + return csvResponse( + toCsv( + h.inKind, + r.inKind.map((d) => [ + d.contactName, + d.direction === "receivable" ? tr.reports.contacts.receivable : tr.reports.contacts.debt, + d.remainingLabel, + d.valueMinor === null ? "" : csvMoney(d.valueMinor), + d.date, + ]), + ), + tr.reports.csvFiles.inKind(today), + ); + return csvResponse( + toCsv( + h.contacts, + r.contacts.map((x) => [x.name, csvMoney(x.balanceMinor), x.dueDate ?? ""]), + ), + tr.reports.csvFiles.contacts(today), + ); +} diff --git a/src/app/(app)/raporlar/cari/page.tsx b/src/app/(app)/raporlar/cari/page.tsx new file mode 100644 index 0000000..ccd3965 --- /dev/null +++ b/src/app/(app)/raporlar/cari/page.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { ContactsReportView } from "@/features/reports/components/contacts-assets-view"; +import { CsvButton, ReportFrame, ToolsEnd } from "@/features/reports/components/report-frame"; +import { getContactsReport } from "@/features/reports/extra"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +const t = tr.reports.contacts; +export const metadata: Metadata = { title: t.title }; + +/** /raporlar/cari — cari bakiyeler ve açık aynî borç/alacaklar. */ +export default async function ContactsReportPage() { + const { user, household } = await requireActiveHousehold("viewer"); + const data = await getContactsReport(user.id, household.id); + if (!data) notFound(); + return ( + + + + + } + > + + + ); +} diff --git a/src/app/(app)/raporlar/fire/csv/route.ts b/src/app/(app)/raporlar/fire/csv/route.ts new file mode 100644 index 0000000..37920f3 --- /dev/null +++ b/src/app/(app)/raporlar/fire/csv/route.ts @@ -0,0 +1,29 @@ +import { csvContext, csvPct, csvResponse, forbidden, unauthorized } from "@/features/reports/csv"; +import { getWasteReport } from "@/features/reports/extra"; +import { tr } from "@/i18n/tr"; +import { csvMoney, toCsv } from "@/shared/csv"; +import { todayIstanbul } from "@/shared/dates"; +import { quantityInput } from "@/shared/stock"; +import { parsePeriod } from "@/shared/reports"; + +/** GET /raporlar/fire/csv?donem=3|6|12 */ +export async function GET(request: Request) { + const c = await csvContext(request); + if (!c) return unauthorized(); + const r = await getWasteReport(c.userId, c.householdId, parsePeriod(c.params.get("donem"))); + if (!r) return forbidden(); + return csvResponse( + toCsv( + tr.reports.csvHeaders.waste, + r.rows.map((x) => [ + x.name, + x.unit, + quantityInput(x.wasted), + quantityInput(x.purchased), + csvPct(x.wastePct), + x.costMinor === null ? "" : csvMoney(x.costMinor), + ]), + ), + tr.reports.csvFiles.waste(todayIstanbul()), + ); +} diff --git a/src/app/(app)/raporlar/fire/page.tsx b/src/app/(app)/raporlar/fire/page.tsx new file mode 100644 index 0000000..83eafe0 --- /dev/null +++ b/src/app/(app)/raporlar/fire/page.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { + CsvButton, + RangeNav, + ReportFrame, + ToolsEnd, +} from "@/features/reports/components/report-frame"; +import { WasteReportView } from "@/features/reports/components/shopping-waste-view"; +import { getWasteReport } from "@/features/reports/extra"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; +import { parsePeriod, REPORT_PERIODS } from "@/shared/reports"; + +const t = tr.reports.waste; +export const metadata: Metadata = { title: t.title }; + +/** /raporlar/fire?donem=3|6|12 — fire/zarar raporu. */ +export default async function WasteReportPage({ searchParams }: PageProps<"/raporlar/fire">) { + const { user, household } = await requireActiveHousehold("viewer"); + const period = parsePeriod((await searchParams).donem); + const data = await getWasteReport(user.id, household.id, period); + if (!data) notFound(); + return ( + + ({ + value: String(p), + label: tr.reports.periods[p]!, + }))} + /> + + + + + } + > + + + ); +} diff --git a/src/app/(app)/raporlar/page.tsx b/src/app/(app)/raporlar/page.tsx index 6a06ea7..9bc4ae8 100644 --- a/src/app/(app)/raporlar/page.tsx +++ b/src/app/(app)/raporlar/page.tsx @@ -1,4 +1,15 @@ -import { ChartPie, ChevronRight, Target, TrendingUp, Users } from "lucide-react"; +import { + ChartPie, + ChevronRight, + Coins, + HandCoins, + ShoppingCart, + Tags, + Target, + Trash2, + TrendingUp, + Users, +} from "lucide-react"; import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; @@ -18,6 +29,11 @@ const REPORTS = [ { href: "/raporlar/trend", key: "trend", icon: TrendingUp }, { href: "/raporlar/kisi-hesap", key: "people", icon: Users }, { href: "/raporlar/butce", key: "budget", icon: Target }, + { href: "/raporlar/cari", key: "contacts", icon: HandCoins }, + { href: "/raporlar/birikim", key: "assets", icon: Coins }, + { href: "/raporlar/alisveris", key: "shopping", icon: ShoppingCart }, + { href: "/raporlar/birim-fiyat", key: "unitPrice", icon: Tags }, + { href: "/raporlar/fire", key: "waste", icon: Trash2 }, ] as const; /** /raporlar — bu bütçe ayının özeti ve raporlar. */ diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index fd2012e..88ecae5 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -28,7 +28,7 @@ export type NavItem = { /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Alt fazlar ondalıkla: 6.1 = Faz 6a. */ -export const CURRENT_PHASE = 14.1; +export const CURRENT_PHASE = 14.2; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; 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/components/contacts-assets-view.tsx b/src/features/reports/components/contacts-assets-view.tsx new file mode 100644 index 0000000..020acc7 --- /dev/null +++ b/src/features/reports/components/contacts-assets-view.tsx @@ -0,0 +1,250 @@ +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { formatMoney } from "@/shared/format"; +import { formatAssetQuantity } from "@/shared/assets"; +import type { AssetsReport, ContactsReport } from "../extra"; +import { dayLabel } from "./report-frame"; + +const signed = (n: number) => `${n > 0 ? "+" : n < 0 ? "−" : ""}${formatMoney(Math.abs(n))}`; +const bp = (n: number | null) => + n === null + ? "" + : ` (${n < 0 ? "−" : "+"}%${(Math.abs(n) / 100).toLocaleString("tr-TR", { maximumFractionDigits: 1 })})`; + +function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +export function ContactsReportView({ data }: { data: ContactsReport }) { + const t = tr.reports.contacts; + return ( +
+
+ + + + +
+ +
+

+ {t.contact} +

+ {data.contacts.length ? ( +
+ + + + + + + + + + + {data.contacts.map((c) => ( + + + + + + + ))} + +
{t.contact}{t.direction}{t.balance}{t.due}
+ + {c.name} + + {c.balanceMinor > 0 ? t.owesMe : t.iOwe} 0 ? "text-income" : "text-expense", + )} + > + {formatMoney(Math.abs(c.balanceMinor))} + + {c.dueDate ? dayLabel(c.dueDate) : "—"} + {t.dueStates[c.due] ? ( + + {t.dueStates[c.due]} + + ) : null} +
+
+ ) : ( +

+ {t.noContacts} +

+ )} +
+ +
+
+

+ {t.inKindTitle} +

+

{t.inKindHint}

+
+ {data.inKind.length ? ( +
+ + + + + + + + + + + + {data.inKind.map((d) => ( + + + + + + + + ))} + +
{t.contact}{t.direction}{t.remaining}{t.value}{t.since}
+ + {d.contactName} + + + {d.direction === "receivable" ? t.receivable : t.debt} + {d.remainingLabel} + {d.valueMinor === null ? "—" : formatMoney(d.valueMinor)} + {dayLabel(d.date)}
+
+ ) : ( +

+ {t.noInKind} +

+ )} + {data.inKindTotals.unpriced ? ( +

{t.unpriced(data.inKindTotals.unpriced)}

+ ) : null} +
+
+ ); +} + +export function AssetsReportView({ data }: { data: AssetsReport }) { + const t = tr.reports.assets; + const p = data.period; + const tone = (n: number | null) => + n === null ? "" : n < 0 ? "text-expense" : n > 0 ? "text-income" : ""; + return ( +
+
+

+ {t.periodTitle}: {dayLabel(p.from)} – {dayLabel(p.to)} +

+
+ + + + + +
+ {p.excluded ? ( +

{t.excluded(p.excluded)}

+ ) : null} +
+ + {data.items.length ? ( +
+ + + + + + + + + + + + + {data.items.map((a) => ( + + + + + + + + + ))} + + + + + + + + + + +
{t.asset}{t.quantity}{t.cost}{t.value}{t.unrealized}{t.realized}
+ + {a.name || a.label} + + + {formatAssetQuantity(a.assetKey, a.summary.quantity)} + {formatMoney(a.summary.costMinor)} + {a.summary.currentValueMinor === null + ? t.noPrice + : formatMoney(a.summary.currentValueMinor)} + + {a.summary.unrealizedMinor === null + ? "—" + : `${signed(a.summary.unrealizedMinor)}${bp(a.summary.unrealizedBp)}`} + + {signed(a.summary.realizedMinor)} +
{t.total} + {formatMoney(data.totals.costMinor)}{formatMoney(data.totals.valueMinor)} + {signed(data.totals.unrealizedMinor)} + + {signed(data.totals.realizedMinor)} +
+
+ ) : ( +

+ {t.empty} +

+ )} +
+ ); +} diff --git a/src/features/reports/components/report-frame.tsx b/src/features/reports/components/report-frame.tsx index 8eaf5c3..ac10999 100644 --- a/src/features/reports/components/report-frame.tsx +++ b/src/features/reports/components/report-frame.tsx @@ -125,3 +125,44 @@ export function Change({ ); } + +/** Dönem seçici (?donem=…): seçenekler bağlantı, seçili olan vurgulu. */ +export function RangeNav({ + path, + param = "donem", + options, + value, + label, +}: { + path: string; + param?: string; + options: { value: string; label: string }[]; + value: string; + label: string; +}) { + return ( + + ); +} + +const dayFmt = new Intl.DateTimeFormat("tr-TR", { + day: "numeric", + month: "short", + year: "numeric", +}); +export const dayLabel = (ymd: string) => dayFmt.format(new Date(`${ymd}T12:00:00+03:00`)); diff --git a/src/features/reports/components/shopping-waste-view.tsx b/src/features/reports/components/shopping-waste-view.tsx new file mode 100644 index 0000000..ac953e6 --- /dev/null +++ b/src/features/reports/components/shopping-waste-view.tsx @@ -0,0 +1,234 @@ +import Link from "next/link"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { formatMoney } from "@/shared/format"; +import { WASTE_REASONS } from "@/shared/stock"; +import { formatStock } from "@/shared/stock"; +import type { ShoppingReport, WasteReport } from "../extra"; +import { dayLabel, formatPct } from "./report-frame"; + +const signed = (n: number) => `${n > 0 ? "+" : n < 0 ? "−" : ""}${formatMoney(Math.abs(n))}`; +const empty = (text: string) => ( +

+ {text} +

+); + +export function ShoppingReportView({ data }: { data: ShoppingReport }) { + const t = tr.reports.shopping; + const reasons = tr.shopping.report.reasons; + const reasonRows = ( + ["price", "quantity", "substitute", "skipped", "extra", "unplanned"] as const + ).filter((k) => data.listTotals[k] !== 0); + return ( +
+
+

+ {t.listsTitle} +

+ {data.lists.length ? ( + <> +
+ + + + + + + + + + + + {data.lists.map((l) => ( + + + + + + + + ))} + + + + + + + + + +
{t.date}{t.list}{t.planned}{t.actual}{t.diff}
{dayLabel(l.date)} + + {l.name} + + + {t.counts( + l.summary.counts.same, + l.summary.counts.substitute, + l.summary.counts.skipped, + l.summary.counts.extra, + )} + {l.stores.length ? ` · ${l.stores.join(", ")}` : ""} + + + {formatMoney(l.summary.plannedMinor)} + + {formatMoney(l.summary.actualMinor)} + 0 + ? "text-expense" + : l.summary.diffMinor < 0 + ? "text-income" + : "", + )} + > + {signed(l.summary.diffMinor)} +
+ {t.total} + + {formatMoney(data.listTotals.plannedMinor)} + + {formatMoney(data.listTotals.actualMinor)} + {signed(data.listTotals.diffMinor)}
+
+ {reasonRows.length ? ( + + ) : null} + + ) : ( + empty(t.listsEmpty) + )} +
+ +
+
+

+ {t.storesTitle} +

+

{t.storesHint}

+
+ {data.stores.length ? ( + + ) : ( + empty(t.storesEmpty) + )} +
+
+ ); +} + +export function WasteReportView({ data }: { data: WasteReport }) { + const t = tr.reports.waste; + const reasonText = tr.stock.wasteReasons; + if (!data.rows.length) return empty(t.empty); + return ( +
+
+
+

{t.total}

+

+ {formatMoney(data.totalCostMinor)} +

+

{t.costHint}

+
+
+

{t.reasonsTitle}

+
    + {WASTE_REASONS.filter((r) => data.reasons[r].count).map((r) => ( +
  • + + {reasonText[r]}{" "} + + ({t.count(data.reasons[r].count)}) + + + {formatMoney(data.reasons[r].costMinor)} +
  • + ))} +
+
+
+
+ + + + + + + + + + + + {data.rows.map((r) => ( + + + + + + + + ))} + +
{t.product}{t.wasted}{t.purchased}{t.ratio}{t.cost}
+ + {r.name} + + + {WASTE_REASONS.filter((x) => r.byReason[x]) + .map((x) => `${reasonText[x]}: ${formatStock(r.byReason[x], r.unit)}`) + .join(" · ")} + + {formatStock(r.wasted, r.unit)}{formatStock(r.purchased, r.unit)}= 15 && "font-medium text-expense", + )} + > + {formatPct(r.wastePct)} + + {r.costMinor === null ? "—" : formatMoney(r.costMinor)} +
+
+ {data.unpriced ? ( +

{t.unpriced(data.unpriced)}

+ ) : null} +
+ ); +} diff --git a/src/features/reports/components/unit-price-view.tsx b/src/features/reports/components/unit-price-view.tsx new file mode 100644 index 0000000..728af98 --- /dev/null +++ b/src/features/reports/components/unit-price-view.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { tr } from "@/i18n/tr"; +import { formatUnitPrice, unitPriceBase } from "@/shared/prices"; +import type { UnitPriceReport } from "../extra"; +import { dayLabel, monthLabel, monthShort } from "./report-frame"; + +const t = tr.reports.unitPrice; +const config = { + min: { label: t.min, color: "var(--income)" }, + avg: { label: t.avg, color: "var(--budget)" }, +} satisfies ChartConfig; + +/** Ürün tipi seçimi (URL'de) ve aylık en düşük / ortalama birim fiyat. */ +export function UnitPriceView({ data }: { data: UnitPriceReport }) { + const router = useRouter(); + const sel = data.selected; + if (!sel) + return ( +

+ {t.noTypes} +

+ ); + const base = unitPriceBase(sel.unit); + // Gösterim biriminde TL (g → kg, ml → lt). + const tl = (minor: number | null) => (minor === null ? null : (minor * base.factor) / 100); + const chart = sel.points.map((p) => ({ + month: p.month, + label: monthShort(p.month), + min: tl(p.minMinor), + avg: tl(p.avgMinor), + })); + const fmt = (minor: number | null) => (minor === null ? "—" : formatUnitPrice(minor, sel.unit)); + + return ( +
+
+ + +
+ +
+ + + + + + `₺${v.toLocaleString("tr-TR", { maximumFractionDigits: 2 })}` + } + /> + { + const m = payload?.[0]?.payload?.month as string | undefined; + return m ? monthLabel(m) : ""; + }} + formatter={(value, name) => ( +
+ + {config[name as keyof typeof config]?.label} + + + {formatUnitPrice((Number(value) * 100) / base.factor, sel.unit)} + +
+ )} + /> + } + /> + } /> + {(["min", "avg"] as const).map((k) => ( + + ))} +
+
+ + + + + + + + + + + {[...sel.points].reverse().map((p) => ( + + + + + + + ))} + +
{t.month}{t.min}{t.avg}{t.count}
{monthLabel(p.month)}{fmt(p.minMinor)}{fmt(p.avgMinor)}{p.count}
+
+ + {sel.stores.length ? ( +
+

+ {t.storesTitle} +

+ + + + + + + + + + {sel.stores.map((s) => ( + + + + + + ))} + +
{t.store}{t.min}{t.last}
{s.label}{fmt(s.minMinor)}{dayLabel(s.lastDate)}
+
+ ) : null} +
+ ); +} 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, + }; + }); +}