feat: Faz 14b - cari, birikim, alışveriş, birim fiyat ve fire raporları

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 00:53:39 +03:00
27 changed files with 2053 additions and 9 deletions
+14
View File
@@ -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ü.
+1
View File
@@ -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.
---
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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": {
@@ -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),
);
}
+57
View File
@@ -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 (
<ReportFrame
title={t.title}
description={t.description}
tools={
<>
<RangeNav
path="/raporlar/alisveris"
label={tr.reports.periodLabel}
value={String(period)}
options={REPORT_PERIODS.map((p) => ({
value: String(p),
label: tr.reports.periods[p]!,
}))}
/>
<ToolsEnd>
<CsvButton
href={`/raporlar/alisveris/csv?donem=${period}&tablo=listeler`}
label={t.csvLists}
/>
<CsvButton
href={`/raporlar/alisveris/csv?donem=${period}&tablo=magazalar`}
label={t.csvStores}
/>
</ToolsEnd>
</>
}
>
<ShoppingReportView data={data} />
</ReportFrame>
);
}
@@ -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()),
);
}
+46
View File
@@ -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 (
<ReportFrame
title={t.title}
description={t.description}
tools={
<>
<RangeNav
path="/raporlar/birikim"
label={tr.reports.periodLabel}
value={range}
options={REPORT_RANGES.map((r) => ({ value: r, label: t.ranges[r]! }))}
/>
<ToolsEnd>
<CsvButton href="/raporlar/birikim/csv" />
</ToolsEnd>
</>
}
>
<AssetsReportView data={data} />
</ReportFrame>
);
}
@@ -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(),
),
);
}
@@ -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 (
<ReportFrame
title={t.title}
description={t.description}
tools={
data.selected ? (
<ToolsEnd>
<CsvButton href={`/raporlar/birim-fiyat/csv?tip=${data.selected.id}`} />
</ToolsEnd>
) : undefined
}
>
<UnitPriceView data={data} />
</ReportFrame>
);
}
+36
View File
@@ -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),
);
}
+31
View File
@@ -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 (
<ReportFrame
title={t.title}
description={t.description}
tools={
<ToolsEnd>
<CsvButton href="/raporlar/cari/csv?tablo=cari" label={t.csvContacts} />
<CsvButton href="/raporlar/cari/csv?tablo=ayni" label={t.csvInKind} />
</ToolsEnd>
}
>
<ContactsReportView data={data} />
</ReportFrame>
);
}
+29
View File
@@ -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()),
);
}
+48
View File
@@ -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 (
<ReportFrame
title={t.title}
description={t.description}
tools={
<>
<RangeNav
path="/raporlar/fire"
label={tr.reports.periodLabel}
value={String(period)}
options={REPORT_PERIODS.map((p) => ({
value: String(p),
label: tr.reports.periods[p]!,
}))}
/>
<ToolsEnd>
<CsvButton href={`/raporlar/fire/csv?donem=${period}`} />
</ToolsEnd>
</>
}
>
<WasteReportView data={data} />
</ReportFrame>
);
}
+17 -1
View File
@@ -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. */
+1 -1
View File
@@ -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;
+51
View File
@@ -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.
@@ -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 (
<div className="rounded-xl border bg-card p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className={cn("text-lg font-semibold tabular-nums", tone)}>{value}</p>
</div>
);
}
export function ContactsReportView({ data }: { data: ContactsReport }) {
const t = tr.reports.contacts;
return (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<Stat label={t.owesMe} value={formatMoney(data.totals.owesMeMinor)} tone="text-income" />
<Stat label={t.iOwe} value={formatMoney(data.totals.iOweMinor)} tone="text-expense" />
<Stat
label={`${t.receivable} (${t.value.toLocaleLowerCase("tr-TR")})`}
value={formatMoney(data.inKindTotals.receivableMinor)}
/>
<Stat
label={`${t.debt} (${t.value.toLocaleLowerCase("tr-TR")})`}
value={formatMoney(data.inKindTotals.debtMinor)}
/>
</div>
<section aria-labelledby="cari-h" className="flex flex-col gap-2">
<h2 id="cari-h" className="font-medium">
{t.contact}
</h2>
{data.contacts.length ? (
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[28rem] text-sm tabular-nums">
<thead className="border-b text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-normal">{t.contact}</th>
<th className="px-3 py-2 text-left font-normal">{t.direction}</th>
<th className="px-3 py-2 text-right font-normal">{t.balance}</th>
<th className="px-3 py-2 text-right font-normal">{t.due}</th>
</tr>
</thead>
<tbody className="divide-y">
{data.contacts.map((c) => (
<tr key={c.id}>
<td className="px-3 py-1.5">
<Link href={`/cari/${c.id}`} className="hover:underline">
{c.name}
</Link>
</td>
<td className="px-3 py-1.5">{c.balanceMinor > 0 ? t.owesMe : t.iOwe}</td>
<td
className={cn(
"px-3 py-1.5 text-right font-medium",
c.balanceMinor > 0 ? "text-income" : "text-expense",
)}
>
{formatMoney(Math.abs(c.balanceMinor))}
</td>
<td className="px-3 py-1.5 text-right">
{c.dueDate ? dayLabel(c.dueDate) : "—"}
{t.dueStates[c.due] ? (
<Badge
variant="outline"
className={cn(
"ml-2",
c.due === "overdue" && "border-expense/50 text-expense",
)}
>
{t.dueStates[c.due]}
</Badge>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.noContacts}
</p>
)}
</section>
<section aria-labelledby="ayni-h" className="flex flex-col gap-2">
<div>
<h2 id="ayni-h" className="font-medium">
{t.inKindTitle}
</h2>
<p className="text-xs text-muted-foreground">{t.inKindHint}</p>
</div>
{data.inKind.length ? (
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[32rem] text-sm tabular-nums">
<thead className="border-b text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-normal">{t.contact}</th>
<th className="px-3 py-2 text-left font-normal">{t.direction}</th>
<th className="px-3 py-2 text-right font-normal">{t.remaining}</th>
<th className="px-3 py-2 text-right font-normal">{t.value}</th>
<th className="px-3 py-2 text-right font-normal">{t.since}</th>
</tr>
</thead>
<tbody className="divide-y">
{data.inKind.map((d) => (
<tr key={d.id}>
<td className="px-3 py-1.5">
<Link href={`/cari/${d.contactId}`} className="hover:underline">
{d.contactName}
</Link>
</td>
<td className="px-3 py-1.5">
{d.direction === "receivable" ? t.receivable : t.debt}
</td>
<td className="px-3 py-1.5 text-right">{d.remainingLabel}</td>
<td className="px-3 py-1.5 text-right">
{d.valueMinor === null ? "—" : formatMoney(d.valueMinor)}
</td>
<td className="px-3 py-1.5 text-right">{dayLabel(d.date)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.noInKind}
</p>
)}
{data.inKindTotals.unpriced ? (
<p className="text-xs text-muted-foreground">{t.unpriced(data.inKindTotals.unpriced)}</p>
) : null}
</section>
</div>
);
}
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 (
<div className="flex flex-col gap-6">
<section aria-labelledby="period-h" className="flex flex-col gap-2">
<h2 id="period-h" className="font-medium">
{t.periodTitle}: {dayLabel(p.from)} – {dayLabel(p.to)}
</h2>
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<Stat
label={t.startValue}
value={p.startValueMinor === null ? "—" : formatMoney(p.startValueMinor)}
/>
<Stat label={t.invested} value={formatMoney(p.investedMinor)} />
<Stat label={t.proceeds} value={formatMoney(p.proceedsMinor)} />
<Stat
label={t.endValue}
value={p.endValueMinor === null ? "—" : formatMoney(p.endValueMinor)}
/>
<Stat
label={t.profit}
value={p.profitMinor === null ? "—" : `${signed(p.profitMinor)}${bp(p.profitBp)}`}
tone={tone(p.profitMinor)}
/>
</div>
{p.excluded ? (
<p className="text-xs text-muted-foreground">{t.excluded(p.excluded)}</p>
) : null}
</section>
{data.items.length ? (
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[40rem] text-sm tabular-nums">
<thead className="border-b text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-normal">{t.asset}</th>
<th className="px-3 py-2 text-right font-normal">{t.quantity}</th>
<th className="px-3 py-2 text-right font-normal">{t.cost}</th>
<th className="px-3 py-2 text-right font-normal">{t.value}</th>
<th className="px-3 py-2 text-right font-normal">{t.unrealized}</th>
<th className="px-3 py-2 text-right font-normal">{t.realized}</th>
</tr>
</thead>
<tbody className="divide-y">
{data.items.map((a) => (
<tr key={a.id}>
<td className="px-3 py-1.5">
<Link href={`/birikim/${a.id}`} className="hover:underline">
{a.name || a.label}
</Link>
</td>
<td className="px-3 py-1.5 text-right">
{formatAssetQuantity(a.assetKey, a.summary.quantity)}
</td>
<td className="px-3 py-1.5 text-right">{formatMoney(a.summary.costMinor)}</td>
<td className="px-3 py-1.5 text-right">
{a.summary.currentValueMinor === null
? t.noPrice
: formatMoney(a.summary.currentValueMinor)}
</td>
<td className={cn("px-3 py-1.5 text-right", tone(a.summary.unrealizedMinor))}>
{a.summary.unrealizedMinor === null
? "—"
: `${signed(a.summary.unrealizedMinor)}${bp(a.summary.unrealizedBp)}`}
</td>
<td className={cn("px-3 py-1.5 text-right", tone(a.summary.realizedMinor))}>
{signed(a.summary.realizedMinor)}
</td>
</tr>
))}
</tbody>
<tfoot className="border-t font-medium">
<tr>
<td className="px-3 py-2">{t.total}</td>
<td />
<td className="px-3 py-2 text-right">{formatMoney(data.totals.costMinor)}</td>
<td className="px-3 py-2 text-right">{formatMoney(data.totals.valueMinor)}</td>
<td className={cn("px-3 py-2 text-right", tone(data.totals.unrealizedMinor))}>
{signed(data.totals.unrealizedMinor)}
</td>
<td className={cn("px-3 py-2 text-right", tone(data.totals.realizedMinor))}>
{signed(data.totals.realizedMinor)}
</td>
</tr>
</tfoot>
</table>
</div>
) : (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.empty}
</p>
)}
</div>
);
}
@@ -125,3 +125,44 @@ export function Change({
</span>
);
}
/** 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 (
<nav aria-label={label} className="flex rounded-lg bg-muted p-0.5">
{options.map((o) => (
<Link
key={o.value}
href={`${path}?${param}=${o.value}`}
aria-current={o.value === value ? "page" : undefined}
className={
o.value === value
? "rounded-md bg-background px-2.5 py-1 text-sm font-medium shadow-sm"
: "rounded-md px-2.5 py-1 text-sm text-muted-foreground hover:text-foreground"
}
>
{o.label}
</Link>
))}
</nav>
);
}
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`));
@@ -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) => (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{text}
</p>
);
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 (
<div className="flex flex-col gap-6">
<section aria-labelledby="lists-h" className="flex flex-col gap-2">
<h2 id="lists-h" className="font-medium">
{t.listsTitle}
</h2>
{data.lists.length ? (
<>
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[36rem] text-sm tabular-nums">
<thead className="border-b text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-normal">{t.date}</th>
<th className="px-3 py-2 text-left font-normal">{t.list}</th>
<th className="px-3 py-2 text-right font-normal">{t.planned}</th>
<th className="px-3 py-2 text-right font-normal">{t.actual}</th>
<th className="px-3 py-2 text-right font-normal">{t.diff}</th>
</tr>
</thead>
<tbody className="divide-y">
{data.lists.map((l) => (
<tr key={l.id}>
<td className="px-3 py-1.5 whitespace-nowrap">{dayLabel(l.date)}</td>
<td className="px-3 py-1.5">
<Link href={`/alisveris/${l.id}`} className="font-medium hover:underline">
{l.name}
</Link>
<span className="block text-xs text-muted-foreground">
{t.counts(
l.summary.counts.same,
l.summary.counts.substitute,
l.summary.counts.skipped,
l.summary.counts.extra,
)}
{l.stores.length ? ` · ${l.stores.join(", ")}` : ""}
</span>
</td>
<td className="px-3 py-1.5 text-right">
{formatMoney(l.summary.plannedMinor)}
</td>
<td className="px-3 py-1.5 text-right">
{formatMoney(l.summary.actualMinor)}
</td>
<td
className={cn(
"px-3 py-1.5 text-right",
l.summary.diffMinor > 0
? "text-expense"
: l.summary.diffMinor < 0
? "text-income"
: "",
)}
>
{signed(l.summary.diffMinor)}
</td>
</tr>
))}
</tbody>
<tfoot className="border-t font-medium">
<tr>
<td className="px-3 py-2" colSpan={2}>
{t.total}
</td>
<td className="px-3 py-2 text-right">
{formatMoney(data.listTotals.plannedMinor)}
</td>
<td className="px-3 py-2 text-right">
{formatMoney(data.listTotals.actualMinor)}
</td>
<td className="px-3 py-2 text-right">{signed(data.listTotals.diffMinor)}</td>
</tr>
</tfoot>
</table>
</div>
{reasonRows.length ? (
<ul className="flex flex-col gap-1 rounded-xl border bg-card p-3 text-sm">
{reasonRows.map((k) => (
<li key={k} className="flex justify-between gap-3">
<span>{reasons[k]}</span>
<span className="tabular-nums">{signed(data.listTotals[k])}</span>
</li>
))}
</ul>
) : null}
</>
) : (
empty(t.listsEmpty)
)}
</section>
<section aria-labelledby="stores-h" className="flex flex-col gap-2">
<div>
<h2 id="stores-h" className="font-medium">
{t.storesTitle}
</h2>
<p className="text-xs text-muted-foreground">{t.storesHint}</p>
</div>
{data.stores.length ? (
<ul className="divide-y rounded-xl border bg-card">
{data.stores.map((s) => (
<li key={s.name} className="flex flex-col gap-1.5 p-3 text-sm">
<div className="flex items-baseline gap-3">
<span className="min-w-0 flex-1 truncate font-medium">{s.name}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{s.count} {t.count.toLocaleLowerCase("tr-TR")}
{s.fromListsMinor ? ` · ${t.fromLists} ${formatMoney(s.fromListsMinor)}` : ""}
</span>
<span className="w-28 text-right tabular-nums">{formatMoney(s.amountMinor)}</span>
<span className="w-14 text-right text-muted-foreground tabular-nums">
{formatPct(s.sharePct)}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden>
<div
className="h-full rounded-full bg-budget"
style={{ width: `${s.sharePct}%` }}
/>
</div>
</li>
))}
<li className="flex justify-between p-3 text-sm font-medium">
<span>{t.total}</span>
<span className="tabular-nums">{formatMoney(data.storesTotalMinor)}</span>
</li>
</ul>
) : (
empty(t.storesEmpty)
)}
</section>
</div>
);
}
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 (
<div className="flex flex-col gap-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,14rem)_minmax(0,1fr)]">
<div className="rounded-xl border bg-card p-3">
<p className="text-xs text-muted-foreground">{t.total}</p>
<p className="text-lg font-semibold text-expense tabular-nums">
{formatMoney(data.totalCostMinor)}
</p>
<p className="text-[11px] text-muted-foreground">{t.costHint}</p>
</div>
<div className="rounded-xl border bg-card p-3">
<h2 className="mb-1 text-xs text-muted-foreground">{t.reasonsTitle}</h2>
<ul className="flex flex-col gap-1 text-sm">
{WASTE_REASONS.filter((r) => data.reasons[r].count).map((r) => (
<li key={r} className="flex justify-between gap-3">
<span>
{reasonText[r]}{" "}
<span className="text-xs text-muted-foreground">
({t.count(data.reasons[r].count)})
</span>
</span>
<span className="tabular-nums">{formatMoney(data.reasons[r].costMinor)}</span>
</li>
))}
</ul>
</div>
</div>
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[36rem] text-sm tabular-nums">
<thead className="border-b text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-normal">{t.product}</th>
<th className="px-3 py-2 text-right font-normal">{t.wasted}</th>
<th className="px-3 py-2 text-right font-normal">{t.purchased}</th>
<th className="px-3 py-2 text-right font-normal">{t.ratio}</th>
<th className="px-3 py-2 text-right font-normal">{t.cost}</th>
</tr>
</thead>
<tbody className="divide-y">
{data.rows.map((r) => (
<tr key={r.productTypeId}>
<td className="px-3 py-1.5">
<Link href={`/stok/${r.productTypeId}`} className="hover:underline">
{r.name}
</Link>
<span className="block text-xs text-muted-foreground">
{WASTE_REASONS.filter((x) => r.byReason[x])
.map((x) => `${reasonText[x]}: ${formatStock(r.byReason[x], r.unit)}`)
.join(" · ")}
</span>
</td>
<td className="px-3 py-1.5 text-right">{formatStock(r.wasted, r.unit)}</td>
<td className="px-3 py-1.5 text-right">{formatStock(r.purchased, r.unit)}</td>
<td
className={cn(
"px-3 py-1.5 text-right",
(r.wastePct ?? 0) >= 15 && "font-medium text-expense",
)}
>
{formatPct(r.wastePct)}
</td>
<td className="px-3 py-1.5 text-right">
{r.costMinor === null ? "—" : formatMoney(r.costMinor)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{data.unpriced ? (
<p className="text-xs text-muted-foreground">{t.unpriced(data.unpriced)}</p>
) : null}
</div>
);
}
@@ -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 (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.noTypes}
</p>
);
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 (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<label htmlFor="up-type" className="text-sm text-muted-foreground">
{t.type}
</label>
<Select
value={sel.id}
onValueChange={(id) => router.replace(`/raporlar/birim-fiyat?tip=${id}`)}
>
<SelectTrigger id="up-type" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-72">
{data.types.map((x) => (
<SelectItem key={x.id} value={x.id}>
{x.name}{" "}
<span className="text-xs text-muted-foreground">({t.records(x.records)})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-xl border bg-card p-4">
<ChartContainer
config={config}
className="aspect-auto h-60 w-full"
role="img"
aria-label={t.chartLabel(sel.name)}
>
<LineChart data={chart} margin={{ left: 4, right: 8, top: 8 }}>
<CartesianGrid vertical={false} />
<XAxis dataKey="label" tickLine={false} axisLine={false} tickMargin={8} />
<YAxis
tickLine={false}
axisLine={false}
width={56}
tickFormatter={(v: number) =>
`₺${v.toLocaleString("tr-TR", { maximumFractionDigits: 2 })}`
}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const m = payload?.[0]?.payload?.month as string | undefined;
return m ? monthLabel(m) : "";
}}
formatter={(value, name) => (
<div className="flex w-full items-center justify-between gap-3">
<span className="text-muted-foreground">
{config[name as keyof typeof config]?.label}
</span>
<span className="font-medium text-foreground tabular-nums">
{formatUnitPrice((Number(value) * 100) / base.factor, sel.unit)}
</span>
</div>
)}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{(["min", "avg"] as const).map((k) => (
<Line
key={k}
dataKey={k}
stroke={`var(--color-${k})`}
strokeWidth={2}
strokeDasharray={k === "avg" ? "6 3" : undefined}
dot={{ r: 3 }}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
</ChartContainer>
<table className="mt-3 w-full text-sm tabular-nums">
<thead className="text-xs text-muted-foreground">
<tr>
<th className="py-1 text-left font-normal">{t.month}</th>
<th className="py-1 text-right font-normal">{t.min}</th>
<th className="py-1 text-right font-normal">{t.avg}</th>
<th className="py-1 text-right font-normal">{t.count}</th>
</tr>
</thead>
<tbody className="divide-y">
{[...sel.points].reverse().map((p) => (
<tr key={p.month}>
<td className="py-1 capitalize">{monthLabel(p.month)}</td>
<td className="py-1 text-right">{fmt(p.minMinor)}</td>
<td className="py-1 text-right">{fmt(p.avgMinor)}</td>
<td className="py-1 text-right">{p.count}</td>
</tr>
))}
</tbody>
</table>
</div>
{sel.stores.length ? (
<section className="rounded-xl border bg-card p-4" aria-labelledby="up-stores">
<h2 id="up-stores" className="mb-2 text-sm font-medium">
{t.storesTitle}
</h2>
<table className="w-full text-sm tabular-nums">
<thead className="text-xs text-muted-foreground">
<tr>
<th className="py-1 text-left font-normal">{t.store}</th>
<th className="py-1 text-right font-normal">{t.min}</th>
<th className="py-1 text-right font-normal">{t.last}</th>
</tr>
</thead>
<tbody className="divide-y">
{sel.stores.map((s) => (
<tr key={s.id}>
<td className="py-1">{s.label}</td>
<td className="py-1 text-right">{fmt(s.minMinor)}</td>
<td className="py-1 text-right">{dayLabel(s.lastDate)}</td>
</tr>
))}
</tbody>
</table>
</section>
) : null}
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { completeShopping, getCompletionSheet } from "@/features/shopping/completion";
import { AccountModel } from "@/models/account";
import { ProductTypeModel } from "@/models/product-type";
import { ShoppingListModel } from "@/models/shopping-list";
import { todayIstanbul } from "@/shared/dates";
import { formatMoneyInput } from "@/shared/money";
import { shoppingCompletionSchema } from "@/shared/schemas/shopping";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
getAssetsReport,
getContactsReport,
getShoppingReport,
getUnitPriceReport,
getWasteReport,
} from "./extra";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let zeynep = "";
let fatma = "";
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
zeynep = ids.user("zeynep");
fatma = ids.user("fatma");
});
afterAll(teardownDb);
describe("cari ve aynî", () => {
it("bakiyeli cariler büyükten küçüğe; toplamlar; açık aynî borçlar; başka hane göremez", async () => {
const r = (await getContactsReport(ayse, cakir))!;
expect(r.contacts.length).toBeGreaterThan(0);
expect(r.contacts.every((c) => c.balanceMinor !== 0 && !c.archived)).toBe(true);
const abs = r.contacts.map((c) => Math.abs(c.balanceMinor));
expect(abs).toEqual([...abs].sort((a, b) => b - a));
expect(r.inKind.length).toBeGreaterThan(0);
expect(r.inKind.every((d) => d.remainingQuantity > 0)).toBe(true);
expect(await getContactsReport(fatma, cakir)).toBeNull();
});
});
describe("birikim", () => {
it("varlık kâr/zarar ve dönem raporu", async () => {
const r = (await getAssetsReport(ayse, cakir, "6"))!;
expect(r.items.length).toBeGreaterThan(0);
expect(r.period.from < r.period.to).toBe(true);
expect(r.range).toBe("6");
});
});
describe("alışveriş", () => {
it("mağaza bazlı harcama alıcı adıyla eşleşir; tamamlanan liste plan/gerçek satırı verir", async () => {
const before = (await getShoppingReport(ayse, cakir, 6))!;
expect(before.lists).toEqual([]);
expect(before.stores.map((s) => s.name)).toEqual(expect.arrayContaining(["Migros", "BİM"]));
expect(before.stores.reduce((a, s) => a + s.amountMinor, 0)).toBe(before.storesTotalMinor);
// Haftalık marketten yalnızca süt alınır, kalanlar bırakılır.
const weekly = String((await ShoppingListModel.findOne({ seedRef: "haftalik" }).lean())!._id);
const sheet = (await getCompletionSheet(ayse, cakir, weekly))!;
const milk = sheet.items.find((i) => i.label === "Süt")!;
const plan = sheet.planned[milk.id]!;
const account = String((await AccountModel.findOne({ name: "Ziraat Maaş" }).lean())!._id);
const done = await completeShopping(
ayse,
cakir,
weekly,
shoppingCompletionSchema.parse({
date: todayIstanbul(),
lines: [
{
itemId: milk.id,
productId: plan.productId,
storeId: plan.storeId,
packages: String(plan.packages),
amount: formatMoneyInput(plan.priceMinor * plan.packages),
},
],
stores: [{ storeId: plan.storeId, accountId: account, attachmentIds: [] }],
leftover: { action: "drop" },
}),
);
expect(done.ok).toBe(true);
const after = (await getShoppingReport(ayse, cakir, 3))!;
expect(after.lists).toHaveLength(1);
const row = after.lists[0]!;
expect(row.summary.counts.same).toBe(1);
expect(row.summary.actualMinor).toBe(plan.priceMinor * plan.packages);
expect(after.listTotals.diffMinor).toBe(row.summary.diffMinor);
const store = after.stores.find((s) => s.fromListsMinor > 0)!;
expect(store.fromListsMinor).toBe(plan.priceMinor * plan.packages);
});
});
describe("birim fiyat geçmişi", () => {
it("fiyatı olan tipler; seçilen tipte 12 ay noktası ve mağaza en düşükleri", async () => {
const r = (await getUnitPriceReport(ayse, cakir))!;
expect(r.types.length).toBeGreaterThan(5);
expect(r.months).toHaveLength(12);
const toz = String((await ProductTypeModel.findOne({ seedRef: "toz-deterjan" }).lean())!._id);
const d = (await getUnitPriceReport(ayse, cakir, toz))!;
expect(d.selected!.id).toBe(toz);
expect(d.selected!.points).toHaveLength(12);
const priced = d.selected!.points.filter((p) => p.count > 0);
expect(priced.length).toBeGreaterThan(3);
for (const p of priced) expect(p.minMinor!).toBeLessThanOrEqual(p.avgMinor!);
const mins = d.selected!.stores.map((s) => s.minMinor);
expect(mins).toEqual([...mins].sort((a, b) => a - b));
});
});
describe("fire / zarar", () => {
it("fire satırları, oran ve nedenler; izleyici de okuyabilir", async () => {
const r = (await getWasteReport(zeynep, cakir, 12))!;
expect(r.rows.length).toBeGreaterThan(0);
for (const row of r.rows) {
const sum = Object.values(row.byReason).reduce((a, b) => a + b, 0);
expect(sum).toBe(row.wasted);
if (row.purchased > 0) expect(row.wastePct).not.toBeNull();
}
const priced = r.rows.reduce((a, x) => a + (x.costMinor ?? 0), 0);
expect(priced).toBe(r.totalCostMinor);
expect(await getWasteReport(fatma, cakir, 12)).toBeNull();
});
});
+482
View File
@@ -0,0 +1,482 @@
import "server-only";
import { Types } from "mongoose";
import {
getPeriodReport,
listAssets,
type AssetView,
type ReportRange,
} from "@/features/assets/service";
import { listContacts, type ContactView } from "@/features/contacts/service";
import { listInKind } from "@/features/in-kind/service";
import { latestTypeUnitPrices, loadTypePriceHistory } from "@/features/prices/service";
import { storeLabel } from "@/features/stores/service";
import { TransactionModel } from "@/models/transaction";
import { ProductPriceModel } from "@/models/product-price";
import { ProductTypeModel } from "@/models/product-type";
import { ShoppingListModel } from "@/models/shopping-list";
import { StockMovementModel } from "@/models/stock-movement";
import { StoreModel } from "@/models/store";
import { formatAssetQuantity, type AssetKey, type InKindDirection } from "@/shared/assets";
import { budgetPeriod, periodOfDate, shiftMonth } from "@/shared/budget";
import { istanbulDayStart } from "@/shared/dates";
import type { PeriodReport } from "@/shared/portfolio";
import {
matchStoreName,
monthlyUnitPrices,
sharePct,
type ReportPeriod,
type UnitPricePoint,
} from "@/shared/reports";
import { foldTr, compareTr } from "@/shared/search";
import type { ShoppingCompletion } from "@/shared/shopping";
import { compareShopping, type ComparisonSummary } from "@/shared/shopping-compare";
import { WASTE_REASONS, type WasteReason } from "@/shared/stock";
import { base, type Base } from "./service";
/**
* Faz 14b raporları: cari ve aynî borçlar, birikim kâr/zarar, alışveriş (liste vs. gerçekleşen,
* mağaza bazlı harcama), birim fiyat geçmişi, fire/zarar. Okuma: viewer; gizlilik kuralları
* kaynak servislerle aynı (gizli cari/varlık/hesap görünmez).
*/
const DAY_MS = 86_400_000;
/** İçinde bulunulan dahil son N bütçe ayı ve tarih aralığı. */
function periodRange(b: Base, months: number) {
const list = Array.from({ length: months }, (_, i) => shiftMonth(b.currentMonth, i - months + 1));
return {
months: list,
from: budgetPeriod(list[0]!, b.s.startDay).from,
to: budgetPeriod(list.at(-1)!, b.s.startDay).to,
};
}
// --- Cari özet ve aynî borçlar ---
export type InKindRow = {
id: string;
contactId: string;
contactName: string;
direction: InKindDirection;
assetKey: AssetKey;
/** Kalan miktar ("12,5 gr 22 ayar"). */
remainingLabel: string;
remainingQuantity: number;
valueMinor: number | null;
date: string;
};
export type ContactsReport = {
contacts: ContactView[];
totals: { owesMeMinor: number; iOweMinor: number };
inKind: InKindRow[];
inKindTotals: { receivableMinor: number; debtMinor: number; unpriced: number };
};
export async function getContactsReport(
userId: string,
householdId: string,
): Promise<ContactsReport | null> {
const [list, inKind] = await Promise.all([
listContacts(userId, householdId),
listInKind(userId, householdId),
]);
if (!list || !inKind) return null;
const open = inKind.filter((d) => !d.closedAt && d.progress.remainingQuantity > 0);
const totals = { receivableMinor: 0, debtMinor: 0, unpriced: 0 };
for (const d of open) {
const v = d.progress.currentValueMinor;
if (v === null) totals.unpriced++;
else if (d.direction === "receivable") totals.receivableMinor += v;
else totals.debtMinor += v;
}
return {
contacts: list.contacts
.filter((c) => !c.archived && c.balanceMinor !== 0)
.sort((a, b) => Math.abs(b.balanceMinor) - Math.abs(a.balanceMinor)),
totals: list.totals,
inKind: open.map((d) => ({
id: d.id,
contactId: d.contactId,
contactName: d.contactName,
direction: d.direction,
assetKey: d.assetKey,
remainingLabel: formatAssetQuantity(d.assetKey, d.progress.remainingQuantity),
remainingQuantity: d.progress.remainingQuantity,
valueMinor: d.progress.currentValueMinor,
date: d.date,
})),
inKindTotals: totals,
};
}
// --- Birikim kâr/zarar ---
export type AssetsReport = {
items: AssetView[];
totals: {
valueMinor: number;
costMinor: number;
unrealizedMinor: number;
realizedMinor: number;
missingPrice: number;
};
period: PeriodReport & { excluded: number };
range: ReportRange;
};
export async function getAssetsReport(
userId: string,
householdId: string,
range: ReportRange,
): Promise<AssetsReport | null> {
const [assets, period] = await Promise.all([
listAssets(userId, householdId),
getPeriodReport(userId, householdId, range),
]);
if (!assets || !period) return null;
return {
items: assets.items.filter((a) => a.summary.quantity > 0 || a.summary.realizedMinor !== 0),
totals: assets.totals,
period,
range,
};
}
// --- Alışveriş: liste vs. gerçekleşen, mağaza bazlı harcama ---
export type CompletedListRow = {
id: string;
name: string;
date: string;
summary: ComparisonSummary;
stores: string[];
};
export type StoreSpendRow = {
name: string;
amountMinor: number;
count: number;
/** Alışverişi tamamla ile gelen kısım. */
fromListsMinor: number;
sharePct: number;
};
export type ShoppingReport = {
period: ReportPeriod;
from: string;
to: string;
lists: CompletedListRow[];
listTotals: ComparisonSummary["reasons"] & {
plannedMinor: number;
actualMinor: number;
diffMinor: number;
};
stores: StoreSpendRow[];
storesTotalMinor: number;
};
export async function getShoppingReport(
userId: string,
householdId: string,
period: ReportPeriod,
): Promise<ShoppingReport | null> {
const b = await base(userId, householdId);
if (!b) return null;
const range = periodRange(b, period);
const hid = b.ctx.m.householdId;
const [lists, storeDocs, txs] = await Promise.all([
ShoppingListModel.find({
householdId: hid,
deletedAt: null,
status: "completed",
"completion.date": { $gte: range.from, $lte: range.to },
})
.select({ name: 1, completion: 1 })
.lean<{ _id: Types.ObjectId; name: string; completion: ShoppingCompletion }[]>(),
StoreModel.find({ householdId: hid })
.select({ name: 1, branch: 1 })
.lean<{ name: string; branch: string }[]>(),
TransactionModel.find({
householdId: hid,
type: "expense",
status: "posted",
deletedAt: null,
date: {
$gte: istanbulDayStart(range.from)!,
$lt: new Date(istanbulDayStart(range.to)!.getTime() + DAY_MS),
},
...(!b.s.includePrivate && b.ctx.hiddenAccountIds.length
? { accountId: { $nin: b.ctx.hiddenAccountIds } }
: {}),
})
.select({ payeeName: 1, amountMinor: 1, shoppingListId: 1 })
.lean<{ payeeName: string; amountMinor: number; shoppingListId: Types.ObjectId | null }[]>(),
]);
const rows: CompletedListRow[] = lists
.map((l) => ({
id: String(l._id),
name: l.name,
date: l.completion.date,
stores: l.completion.stores.map((s) => s.label),
summary: compareShopping(
l.completion.rows.map((r) => ({
match: r.match,
plan: r.plan,
actual:
r.amountMinor === null
? null
: { productId: r.productId, packages: r.packages, amountMinor: r.amountMinor },
})),
),
}))
.sort((a, c) => (a.date < c.date ? 1 : -1));
const listTotals = {
plannedMinor: 0,
actualMinor: 0,
diffMinor: 0,
price: 0,
quantity: 0,
substitute: 0,
skipped: 0,
extra: 0,
unplanned: 0,
};
for (const r of rows) {
listTotals.plannedMinor += r.summary.plannedMinor;
listTotals.actualMinor += r.summary.actualMinor;
listTotals.diffMinor += r.summary.diffMinor;
for (const k of ["price", "quantity", "substitute", "skipped", "extra", "unplanned"] as const)
listTotals[k] += r.summary.reasons[k];
}
// Mağaza adı bazında (şubeler birlikte): gider alıcısı mağaza adıyla başlıyorsa.
const names = [...new Set(storeDocs.map((s) => s.name))];
const byStore = new Map<string, StoreSpendRow>();
for (const t of txs) {
const name = matchStoreName(t.payeeName, names, foldTr);
if (!name) continue;
const row = byStore.get(name) ?? {
name,
amountMinor: 0,
count: 0,
fromListsMinor: 0,
sharePct: 0,
};
row.amountMinor += t.amountMinor;
row.count++;
if (t.shoppingListId) row.fromListsMinor += t.amountMinor;
byStore.set(name, row);
}
const storesTotal = [...byStore.values()].reduce((a, r) => a + r.amountMinor, 0);
const stores = [...byStore.values()]
.map((r) => ({ ...r, sharePct: sharePct(r.amountMinor, storesTotal) }))
.sort((a, c) => c.amountMinor - a.amountMinor);
return {
period,
from: range.from,
to: range.to,
lists: rows,
listTotals,
stores,
storesTotalMinor: storesTotal,
};
}
// --- Birim fiyat geçmişi ---
export type UnitPriceReport = {
types: { id: string; name: string; unit: string; records: number }[];
selected: {
id: string;
name: string;
unit: string;
points: UnitPricePoint[];
/** Mağaza başına dönemdeki en düşük birim fiyat. */
stores: { id: string; label: string; minMinor: number; lastDate: string }[];
} | null;
months: string[];
};
export async function getUnitPriceReport(
userId: string,
householdId: string,
typeId?: string,
): Promise<UnitPriceReport | null> {
const b = await base(userId, householdId);
if (!b) return null;
const hid = b.ctx.m.householdId;
const range = periodRange(b, 12);
const [types, counts] = await Promise.all([
ProductTypeModel.find({ householdId: hid, archivedAt: null })
.select({ name: 1, unit: 1 })
.lean<{ _id: Types.ObjectId; name: string; unit: string }[]>(),
ProductPriceModel.aggregate<{ _id: Types.ObjectId; n: number }>([
{ $match: { householdId: hid, deletedAt: null } },
{ $lookup: { from: "products", localField: "productId", foreignField: "_id", as: "p" } },
{ $unwind: "$p" },
{ $group: { _id: "$p.productTypeId", n: { $sum: 1 } } },
]),
]);
const count = new Map(counts.map((c) => [String(c._id), c.n]));
const options = types
.map((x) => ({
id: String(x._id),
name: x.name,
unit: x.unit,
records: count.get(String(x._id)) ?? 0,
}))
.filter((x) => x.records > 0)
.sort((a, c) => compareTr(a.name, c.name));
const chosen = options.find((x) => x.id === typeId) ?? options[0];
if (!chosen) return { types: options, selected: null, months: range.months };
const [history, storeDocs] = await Promise.all([
loadTypePriceHistory(hid, new Types.ObjectId(chosen.id)),
StoreModel.find({ householdId: hid })
.select({ name: 1, branch: 1 })
.lean<{ _id: Types.ObjectId; name: string; branch: string }[]>(),
]);
const inRange = history.filter((h) => h.date >= range.from && h.date <= range.to);
const points = monthlyUnitPrices(
inRange.map((h) => ({
month: periodOfDate(h.date, b.s.startDay),
unitPriceMinor: h.unitPriceMinor,
})),
range.months,
);
const labelOf = new Map(storeDocs.map((s) => [String(s._id), storeLabel(s)]));
const perStore = new Map<string, { minMinor: number; lastDate: string }>();
for (const h of inRange) {
const cur = perStore.get(h.storeId);
perStore.set(h.storeId, {
minMinor: cur ? Math.min(cur.minMinor, h.unitPriceMinor) : h.unitPriceMinor,
lastDate: cur && cur.lastDate > h.date ? cur.lastDate : h.date,
});
}
return {
types: options,
months: range.months,
selected: {
id: chosen.id,
name: chosen.name,
unit: chosen.unit,
points,
stores: [...perStore.entries()]
.map(([id, v]) => ({ id, label: labelOf.get(id) ?? "—", ...v }))
.sort((a, c) => a.minMinor - c.minMinor),
},
};
}
// --- Fire / zarar ---
export type WasteRow = {
productTypeId: string;
name: string;
unit: string;
wasted: number;
purchased: number;
/** Fire / alım (yüzde); alım yoksa null. */
wastePct: number | null;
byReason: Record<WasteReason, number>;
/** Tahmini maliyet: fire × son bilinen birim fiyat (fiyat yoksa null). */
costMinor: number | null;
};
export type WasteReport = {
period: ReportPeriod;
from: string;
to: string;
rows: WasteRow[];
/** Neden başına fire satırı sayısı ve tahmini maliyet. */
reasons: Record<WasteReason, { count: number; costMinor: number }>;
totalCostMinor: number;
unpriced: number;
};
export async function getWasteReport(
userId: string,
householdId: string,
period: ReportPeriod,
): Promise<WasteReport | null> {
const b = await base(userId, householdId);
if (!b) return null;
const range = periodRange(b, period);
const hid = b.ctx.m.householdId;
const moves = await StockMovementModel.find({
householdId: hid,
deletedAt: null,
kind: { $in: ["waste", "purchase"] },
date: { $gte: range.from, $lte: range.to },
})
.select({ productTypeId: 1, kind: 1, quantity: 1, wasteReason: 1 })
.lean<
{
productTypeId: Types.ObjectId;
kind: "waste" | "purchase";
quantity: number;
wasteReason: WasteReason | null;
}[]
>();
const wasteTypeIds = [
...new Set(moves.filter((m) => m.kind === "waste").map((m) => String(m.productTypeId))),
].map((id) => new Types.ObjectId(id));
const [types, prices] = await Promise.all([
ProductTypeModel.find({ householdId: hid, _id: { $in: wasteTypeIds } })
.select({ name: 1, unit: 1 })
.lean<{ _id: Types.ObjectId; name: string; unit: string }[]>(),
latestTypeUnitPrices(hid, wasteTypeIds),
]);
const emptyReasons = () =>
Object.fromEntries(WASTE_REASONS.map((r) => [r, 0])) as Record<WasteReason, number>;
const rows = new Map<string, WasteRow>();
for (const t of types)
rows.set(String(t._id), {
productTypeId: String(t._id),
name: t.name,
unit: t.unit,
wasted: 0,
purchased: 0,
wastePct: null,
byReason: emptyReasons(),
costMinor: null,
});
const reasons = Object.fromEntries(
WASTE_REASONS.map((r) => [r, { count: 0, costMinor: 0 }]),
) as WasteReport["reasons"];
for (const m of moves) {
const row = rows.get(String(m.productTypeId));
if (!row) continue;
if (m.kind === "purchase") {
row.purchased += m.quantity;
continue;
}
const reason = m.wasteReason ?? "other";
row.wasted += m.quantity;
row.byReason[reason] += m.quantity;
reasons[reason].count++;
const price = prices.get(row.productTypeId);
if (price) reasons[reason].costMinor += Math.round((m.quantity * price.unitPriceMinor) / 1000);
}
let total = 0;
let unpriced = 0;
const list = [...rows.values()].map((r) => {
const price = prices.get(r.productTypeId);
const costMinor = price ? Math.round((r.wasted * price.unitPriceMinor) / 1000) : null;
if (costMinor === null) unpriced++;
else total += costMinor;
return { ...r, costMinor, wastePct: r.purchased > 0 ? sharePct(r.wasted, r.purchased) : null };
});
list.sort((a, c) => (c.costMinor ?? -1) - (a.costMinor ?? -1) || compareTr(a.name, c.name));
return {
period,
from: range.from,
to: range.to,
rows: list,
reasons,
totalCostMinor: total,
unpriced,
};
}
+3 -3
View File
@@ -28,9 +28,9 @@ import { categoryBreakdown, savingsRate, sharePct, type CategoryBreakdown } from
const DAY_MS = 86_400_000;
const ORPHAN = "__none__";
type Base = { ctx: Ctx; s: Settings; today: string; currentMonth: string };
export type Base = { ctx: Ctx; s: Settings; today: string; currentMonth: string };
async function base(userId: string, householdId: string): Promise<Base | null> {
export async function base(userId: string, householdId: string): Promise<Base | null> {
const ctx = await loadContext(userId, householdId, "viewer");
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,
+160
View File
@@ -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<number, string>,
periodLabel: "Dönem",
rangeText: (from: string, to: string) => `${from} – ${to}`,
contacts: {
title: "Cari ve aynî borçlar",
description:
"Kim kime ne kadar borçlu; açık altın/döviz borç ve alacakları güncel değeriyle.",
owesMe: "Bana borçlu",
iOwe: "Ben borçluyum",
contact: "Cari",
balance: "Bakiye",
direction: "Yön",
due: "Vade",
noContacts: "Bakiyesi olan cari yok.",
inKindTitle: "Açık aynî borç ve alacaklar",
csvContacts: "Cariler CSV",
csvInKind: "Aynî CSV",
inKindHint: "Kalan miktarın güncel fiyatla TL karşılığı (alacak alış, borç satış fiyatıyla).",
remaining: "Kalan",
value: "Güncel değer",
since: "Tarih",
noInKind: "Açık aynî kayıt yok.",
receivable: "Alacak",
debt: "Borç",
unpriced: (n: number) => `${n} kaydın fiyatı yok; toplama katılmadı.`,
dueStates: { overdue: "Vadesi geçti", upcoming: "Vadesi yaklaşıyor", none: "" } as Record<
string,
string
>,
},
assets: {
title: "Birikim kâr/zarar",
description:
"Varlık başına maliyet, güncel değer (alış fiyatı) ve kâr/zarar; seçilen dönemin özeti.",
ranges: { "3": "Son 3 ay", "6": "Son 6 ay", "12": "Son 12 ay", tum: "Tümü" } as Record<
string,
string
>,
asset: "Varlık",
quantity: "Miktar",
cost: "Maliyet",
value: "Güncel değer",
unrealized: "Gerçekleşmemiş K/Z",
realized: "Gerçekleşen K/Z",
noPrice: "Fiyat yok",
periodTitle: "Dönem özeti",
startValue: "Dönem başı değer",
invested: "Yatırılan",
proceeds: "Satıştan alınan",
endValue: "Dönem sonu değer",
profit: "Dönem kârı",
excluded: (n: number) => `${n} varlığın fiyatı olmadığı için dönem toplamına katılmadı.`,
empty: "Varlık yok.",
total: "Toplam",
},
shopping: {
title: "Alışveriş raporu",
description: "Tamamlanan listelerde plan ve gerçekleşen; mağaza bazlı harcama.",
listsTitle: "Liste ve gerçekleşen",
listsEmpty: "Bu dönemde tamamlanan alışveriş yok.",
list: "Liste",
date: "Tarih",
planned: "Planlanan",
actual: "Gerçekleşen",
diff: "Fark",
counts: (same: number, sub: number, skipped: number, extra: number) =>
`${same} aynı · ${sub} muadil · ${skipped} alınmadı · ${extra} ekstra`,
storesTitle: "Mağaza bazlı harcama",
storesHint: "Alıcısı mağaza adıyla başlayan gerçekleşmiş giderler (şubeler birlikte).",
store: "Mağaza",
amount: "Tutar",
count: "İşlem",
share: "Oran",
fromLists: "Listelerden",
storesEmpty: "Bu dönemde mağaza adıyla eşleşen gider yok.",
total: "Toplam",
csvLists: "Listeler CSV",
csvStores: "Mağazalar CSV",
},
unitPrice: {
title: "Birim fiyat geçmişi",
description: "Ürün tipi başına son 12 bütçe ayında en düşük ve ortalama birim fiyat.",
type: "Ürün tipi",
noTypes: "Henüz fiyat kaydı yok.",
min: "En düşük",
avg: "Ortalama",
month: "Ay",
count: "Kayıt",
chartLabel: (name: string) => `${name}: aylık en düşük ve ortalama birim fiyat`,
storesTitle: "Mağazalarda dönemin en düşük birim fiyatı",
store: "Mağaza",
last: "Son kayıt",
records: (n: number) => `${n} kayıt`,
},
waste: {
title: "Fire / zarar",
description: "Ürün tipi başına fire miktarı, alıma oranı, nedenleri ve tahmini maliyeti.",
total: "Tahmini toplam maliyet",
product: "Ürün tipi",
wasted: "Fire",
purchased: "Alım",
ratio: "Fire / alım",
cost: "Tahmini maliyet",
reasonsTitle: "Nedenler",
count: (n: number) => `${n} kayıt`,
empty: "Bu dönemde fire kaydı yok.",
unpriced: (n: number) => `${n} ürün tipinin fiyatı yok; maliyete katılmadı.`,
costHint: "Fire miktarı × ürün tipinin son bilinen birim fiyatı.",
},
csvFiles: {
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: {
+39 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { categoryBreakdown, changePct, savingsRate, sharePct } from "./reports";
import {
categoryBreakdown,
changePct,
matchStoreName,
monthlyUnitPrices,
savingsRate,
sharePct,
} from "./reports";
describe("oranlar", () => {
it("toplam gidere oran bir ondalıklı (35.000 içinde 5.000 → %14,3)", () => {
@@ -60,3 +67,34 @@ describe("categoryBreakdown", () => {
expect(b.rows.find((r) => r.id === "ulasim")!.changeMinor).toBe(-1_000_00);
});
});
describe("matchStoreName", () => {
const fold = (s: string) => s.toLocaleLowerCase("tr-TR");
const names = ["Şok", "Migros", "Migros Jet", "A101"];
it("katlanmış başlangıç eşleşmesi, en uzun ad, kelime sınırı", () => {
expect(matchStoreName("Şok · Rasimpaşa", names, fold)).toBe("Şok");
expect(matchStoreName("ŞOK MARKET", names, fold)).toBe("Şok");
expect(matchStoreName("Migros Jet Moda", names, fold)).toBe("Migros Jet");
expect(matchStoreName("A101x", names, fold)).toBeNull();
expect(matchStoreName("Ev sahibi", names, fold)).toBeNull();
expect(matchStoreName("", names, fold)).toBeNull();
});
});
describe("monthlyUnitPrices", () => {
it("ay başına en düşük ve ortalama; kayıt yoksa boş", () => {
const out = monthlyUnitPrices(
[
{ month: "2026-10", unitPriceMinor: 100 },
{ month: "2026-10", unitPriceMinor: 140 },
{ month: "2026-11", unitPriceMinor: 90 },
],
["2026-09", "2026-10", "2026-11"],
);
expect(out).toEqual([
{ month: "2026-09", minMinor: null, avgMinor: null, count: 0 },
{ month: "2026-10", minMinor: 100, avgMinor: 120, count: 2 },
{ month: "2026-11", minMinor: 90, avgMinor: 90, count: 1 },
]);
});
});
+55
View File
@@ -118,3 +118,58 @@ export function categoryBreakdown(
rows.sort((a, b) => b.amountMinor - a.amountMinor || a.name.localeCompare(b.name, "tr"));
return { totalMinor, prevTotalMinor, rows };
}
// --- Alışveriş raporları ---
/** Rapor dönemi: içinde bulunulan dahil son N bütçe ayı. */
export const REPORT_PERIODS = [3, 6, 12] as const;
export type ReportPeriod = (typeof REPORT_PERIODS)[number];
export const parsePeriod = (x: unknown): ReportPeriod =>
REPORT_PERIODS.includes(Number(x) as ReportPeriod) ? (Number(x) as ReportPeriod) : 6;
/**
* Gider alıcısını mağaza adına eşler: Türkçe katlanmış alıcı adı mağaza adıyla başlıyorsa
* (en uzun eşleşme). "Şok · Rasimpaşa" ve "ŞOK MARKET" → "Şok". Eşleşme yoksa null.
*/
export function matchStoreName(
payee: string,
storeNames: readonly string[],
fold: (s: string) => string,
): string | null {
const p = fold(payee).trim();
if (!p) return null;
let best: string | null = null;
for (const name of storeNames) {
const n = fold(name).trim();
if (!n || !p.startsWith(n)) continue;
// Kelime sınırı: "a101x" "a101" ile eşleşmesin.
const next = p.charAt(n.length);
if (next && /[\p{L}\p{N}]/u.test(next)) continue;
if (!best || n.length > fold(best).length) best = name;
}
return best;
}
export type UnitPricePoint = {
month: string;
minMinor: number | null;
avgMinor: number | null;
count: number;
};
/** Ay başına en düşük ve ortalama birim fiyat (kayıt yoksa boş). Birim fiyat kesirli kuruş. */
export function monthlyUnitPrices(
records: readonly { month: string; unitPriceMinor: number }[],
months: readonly string[],
): UnitPricePoint[] {
return months.map((month) => {
const xs = records.filter((r) => r.month === month).map((r) => r.unitPriceMinor);
return {
month,
minMinor: xs.length ? Math.min(...xs) : null,
avgMinor: xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null,
count: xs.length,
};
});
}