feat(reports): cari, birikim, alışveriş, birim fiyat ve fire rapor servisleri
- mağaza adı eşleşmesi ve aylık birim fiyat özeti (saf, birim testli) - tamamlanan listelerin plan/gerçekleşen özeti, mağaza bazlı harcama - ürün tipi fiyat geçmişi yükleyicisi; fire oranı ve tahmini maliyet; testler Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user