feat(stock): sayım oturumları, fire ayarlı öneri motoru ve tüketim analizi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 10:58:06 +03:00
parent 5e0f1b5901
commit 814bdbbc2a
12 changed files with 1219 additions and 69 deletions
+7
View File
@@ -9,6 +9,7 @@ import {
productTypeFormSchema,
stockCategoryFormSchema,
stockCountFormSchema,
stockCountSheetSchema,
stockMovementFormSchema,
} from "@/shared/schemas/stock";
import {
@@ -21,6 +22,7 @@ import {
quickConsume,
setProductTypeArchived,
setStockCategoryArchived,
submitCount,
updateProductType,
updateStockCategory,
type StockOption,
@@ -106,6 +108,11 @@ export async function countStockAction(productTypeId: unknown, input: unknown) {
);
}
/** Toplu sayım (/stok/sayim). */
export async function submitCountAction(input: unknown) {
return run(stockCountSheetSchema, input, submitCount);
}
export async function deleteStockMovementAction(movementId: unknown) {
return withId(movementId, (id) =>
run(z.unknown(), null, (u, h) => deleteStockMovement(u, h, id)),
+139
View File
@@ -1,6 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { tr } from "@/i18n/tr";
import { AuditLogModel } from "@/models/audit-log";
import { StockCountModel } from "@/models/stock-count";
import { StockMovementModel } from "@/models/stock-movement";
import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
@@ -8,6 +9,7 @@ import {
productTypeFormSchema,
stockCategoryFormSchema,
stockCountFormSchema,
stockCountSheetSchema,
stockMovementFormSchema,
type ProductTypeFormInput,
} from "@/shared/schemas/stock";
@@ -18,13 +20,16 @@ import {
createProductType,
createStockCategory,
deleteStockMovement,
getCountSheet,
getProductType,
getStock,
getStockAnalysis,
getStockOptions,
listStockCategories,
quickConsume,
setProductTypeArchived,
setStockCategoryArchived,
submitCount,
updateStockCategory,
} from "./service";
@@ -305,3 +310,137 @@ describe("stok kategorileri", () => {
).toBe(false);
});
});
describe("periyodik sayım, öneri ve analiz (Faz 11b)", () => {
it("seed: Mutfak sayımı gecikti, Genel Temizlik değil; fire öneriyi düşürür", async () => {
const s = (await getStock(ayse, cakir))!;
const paths = s.countDue.map((g) => g.path);
expect(paths).toEqual(expect.arrayContaining(["Mutfak › Manav", "Mutfak › Bakliyat"]));
expect(paths.some((p) => p.startsWith("Genel Temizlik"))).toBe(false);
// Son sayım 21 gün önce, sıklık 15 → 6 gün gecikti.
expect(s.countDue.find((g) => g.path === "Mutfak › Manav")!.overdueDays).toBe(6);
const domates = s.items.find((i) => i.name === "Domates")!;
expect(domates.count).toMatchObject({ everyDays: 15, lastDate: addDays(today, -21) });
expect(domates.suggestion.waste?.pct).toBeGreaterThan(0);
const deterjan = s.items.find((i) => i.name === "Toz Deterjan")!;
expect(deterjan.count).toMatchObject({ everyDays: 30, due: false });
expect(s.items.find((i) => i.name === "Şampuan")!.count).toBeNull();
// Öneri hedefi aşmaz, tüketim hızı biliniyor.
for (const i of s.suggestions) {
expect(i.suggestedQuantity).toBeGreaterThan(0);
expect(i.suggestedQuantity).toBeLessThanOrEqual(
Math.ceil(i.suggestion.base / i.step) * i.step,
);
// Bugün eklenen test ürünlerinin geçmişi kısa: kullanım hızı yok.
if (i.movementCount > 5) expect(i.suggestion.usage).not.toBeNull();
}
});
it("sayım listesi kategori alt ağacıyla; toplu sayım fire + düzeltme, oturum kaydı", async () => {
const opts = (await getStockOptions(ayse, cakir))!;
const manav = opts.categories.find((c) => c.path === "Mutfak › Manav")!.id;
const mutfak = opts.categories.find((c) => c.path === "Mutfak")!.id;
const sheet = (await getCountSheet(ayse, cakir, mutfak))!;
expect(sheet.rows.every((r) => r.categoryPath.startsWith("Mutfak"))).toBe(true);
expect(sheet.categories.find((c) => c.id === manav)!.due).toBeGreaterThan(0);
const patates = sheet.rows.find((r) => r.name === "Patates")!;
const sogan = sheet.rows.find((r) => r.name === "Kuru Soğan")!;
const bad = await submitCount(
ayse,
cakir,
stockCountSheetSchema.parse({
date: today,
categoryId: mutfak,
rows: [
{
productTypeId: patates.id,
actualQuantity: String((patates.expected - 500) / 1000).replace(".", ","),
wasteQuantity: "0,6",
wasteReason: "spoiled",
},
],
}),
);
expect(bad).toMatchObject({
ok: false,
fieldErrors: { "rows.0.wasteQuantity": expect.any(String) },
});
const r = await submitCount(
ayse,
cakir,
stockCountSheetSchema.parse({
date: today,
categoryId: mutfak,
note: "Ay sonu",
rows: [
{
productTypeId: patates.id,
actualQuantity: String((patates.expected - 500) / 1000).replace(".", ","),
wasteQuantity: "0,3",
wasteReason: "spoiled",
},
{
productTypeId: sogan.id,
actualQuantity: String(sogan.expected / 1000).replace(".", ","),
},
{ productTypeId: manav, actualQuantity: "" },
].map((x) => ({ wasteQuantity: "", wasteReason: "", ...x })),
}),
);
expect(r).toMatchObject({ ok: true, data: { counted: 2, adjusted: 1, diffs: [-500, 0] } });
const countId = r.ok ? r.data.countId : "";
const session = await StockCountModel.findById(countId).lean();
expect(session!.items).toHaveLength(2);
expect(session!.items[0]).toMatchObject({ expected: patates.expected, wasted: 300 });
const moves = await StockMovementModel.find({ countId }).sort({ createdAt: 1 }).lean();
expect(moves.map((m) => [m.kind, m.quantity, m.wasteReason])).toEqual([
["waste", 300, "spoiled"],
["count_adjust", -200, null],
]);
// Fark sıfır olan soğan da sayıldı: takvim yenilendi.
const p = (await getProductType(ayse, cakir, sogan.id))!;
expect(p.count).toMatchObject({ lastDate: today, due: false, nextDate: addDays(today, 15) });
expect((await getProductType(ayse, cakir, patates.id))!.current).toBe(patates.expected - 500);
// Hiçbir satır dolu değilse şema reddeder; izleyici sayamaz.
expect(
stockCountSheetSchema.safeParse({
date: today,
categoryId: "",
rows: [{ productTypeId: sogan.id, actualQuantity: "", wasteQuantity: "", wasteReason: "" }],
}).success,
).toBe(false);
expect(
(
await submitCount(
zeynep,
cakir,
stockCountSheetSchema.parse({
date: today,
categoryId: "",
rows: [
{ productTypeId: sogan.id, actualQuantity: "1", wasteQuantity: "", wasteReason: "" },
],
}),
)
).ok,
).toBe(false);
expect(await getCountSheet(fatma, cakir, null)).toBeNull();
});
it("tüketim analizi: aylık ortalama ve fire oranı", async () => {
const a = (await getStockAnalysis(ayse, cakir))!;
expect(a.months).toHaveLength(6);
expect(a.months.at(-1)! < a.thisMonth).toBe(true);
const domates = a.rows.find((r) => r.name === "Domates")!;
expect(domates.avgMonthlyUsed).toBeGreaterThan(5_000);
expect(domates.wastePct).toBeGreaterThan(0);
const sampuan = a.rows.find((r) => r.name === "Şampuan")!;
expect(sampuan.avgMonthlyWasted).toBe(0);
expect(sampuan.wastePct).toBe(0);
expect(await getStockAnalysis(fatma, cakir)).toBeNull();
});
});
+397 -41
View File
@@ -20,26 +20,35 @@ import { toObjectId } from "@/lib/object-id";
import { CategoryModel } from "@/models/category";
import { ProductTypeModel } from "@/models/product-type";
import { StockCategoryModel } from "@/models/stock-category";
import { StockCountModel } from "@/models/stock-count";
import { StockMovementModel } from "@/models/stock-movement";
import { todayIstanbul } from "@/shared/dates";
import { shiftMonth } from "@/shared/budget";
import type {
ProductTypeForm,
StockCategoryForm,
StockCountForm,
StockCountSheet,
StockMovementForm,
} from "@/shared/schemas/stock";
import { compareTr, foldTr } from "@/shared/search";
import {
countSchedule,
currentQuantity,
expiringLots,
expiryStatus,
fifoLots,
formatStock,
refillQuantity,
monthlyUsage,
roundForDisplay,
signedDelta,
stockLevel,
suggestPurchase,
type CountSchedule,
type ExpiringLot,
type ExpiryStatus,
type MonthUsage,
type PurchaseSuggestion,
type StockLevel,
type StockMovementKind,
type WasteReason,
@@ -77,6 +86,7 @@ type ProductLean = {
countEveryDays: number | null;
note: string;
archivedAt: Date | null;
createdAt: Date;
};
type MoveLean = {
@@ -124,16 +134,30 @@ export type ProductTypeView = {
current: number;
level: StockLevel;
suggestedQuantity: number;
/** Öneri ayrıntısı: fire indirimi, tüketim hızı yuvarlaması, kaç gün yeter. */
suggestion: PurchaseSuggestion;
/** Periyodik sayım (sıklık ürün tipinden, yoksa kategoriden). */
count: CountSchedule | null;
expiring: ExpiringLot[];
lastMovementDate: string | null;
movementCount: number;
};
/** Sayımı gelen ürünlerin kategoriye göre özeti. */
export type CountDueGroup = {
categoryId: string;
path: string;
products: number;
/** En çok geciken ürünün gecikmesi (gün). */
overdueDays: number;
};
export type StockView = {
categories: StockCategoryView[];
items: ProductTypeView[];
suggestions: ProductTypeView[];
expiring: { item: ProductTypeView; lot: ExpiringLot }[];
countDue: CountDueGroup[];
canWrite: boolean;
today: string;
};
@@ -261,19 +285,43 @@ function categoryViews(
});
}
function toView(
p: ProductLean,
moves: MoveLean[],
categoryPath: string,
today: string,
): ProductTypeView {
/** Ürün başına son sayım tarihi (sayım oturumlarından). */
async function loadLastCounts(householdId: Types.ObjectId, productIds: Types.ObjectId[]) {
const rows = await StockCountModel.aggregate<{ _id: Types.ObjectId; date: string }>([
{ $match: { householdId, "items.productTypeId": { $in: productIds } } },
{ $unwind: "$items" },
{ $match: { "items.productTypeId": { $in: productIds } } },
{ $group: { _id: "$items.productTypeId", date: { $max: "$date" } } },
]);
return new Map(rows.map((r) => [String(r._id), r.date]));
}
type ViewContext = {
categoryPath: string;
/** Kategoriden devralınan sayım sıklığı. */
categoryCountDays: number | null;
lastCount: string | null;
today: string;
};
const ymdOf = (d: Date) => todayIstanbul(d);
function toView(p: ProductLean, moves: MoveLean[], ctx: ViewContext): ProductTypeView {
const { today } = ctx;
const current = currentQuantity(moves);
const level = stockLevel(current, p.targetQuantity, p.thresholdPct);
const suggestion = suggestPurchase({
current,
target: p.targetQuantity,
step: p.step,
moves,
today,
});
return {
id: String(p._id),
name: p.name,
categoryId: String(p.categoryId),
categoryPath,
categoryPath: ctx.categoryPath,
unit: p.unit,
targetQuantity: p.targetQuantity,
thresholdPct: p.thresholdPct,
@@ -284,7 +332,14 @@ function toView(
archived: Boolean(p.archivedAt),
current,
level,
suggestedQuantity: refillQuantity(current, p.targetQuantity),
suggestedQuantity: suggestion.quantity,
suggestion,
count: countSchedule(
p.countEveryDays ?? ctx.categoryCountDays,
ctx.lastCount,
moves[0]?.date ?? ymdOf(p.createdAt),
today,
),
expiring: expiringLots(fifoLots(moves), today),
lastMovementDate: moves.at(-1)?.date ?? null,
movementCount: moves.length,
@@ -308,15 +363,22 @@ export async function getStock(userId: string, householdId: string): Promise<Sto
includeArchived: true,
});
const order = new Map(categories.map((c, i) => [c.id, i]));
const pathOf = new Map(categories.map((c) => [c.id, c.path]));
const moves = await loadMoves(
m.householdId,
products.map((p) => p._id),
);
const catOf = new Map(categories.map((c) => [c.id, c]));
const ids = products.map((p) => p._id);
const [moves, lastCounts] = await Promise.all([
loadMoves(m.householdId, ids),
loadLastCounts(m.householdId, ids),
]);
const items = products
.map((p) =>
toView(p, moves.get(String(p._id)) ?? [], pathOf.get(String(p.categoryId)) ?? "", today),
)
.map((p) => {
const cat = catOf.get(String(p.categoryId));
return toView(p, moves.get(String(p._id)) ?? [], {
categoryPath: cat?.path ?? "",
categoryCountDays: cat?.effectiveCountDays ?? null,
lastCount: lastCounts.get(String(p._id)) ?? null,
today,
});
})
.sort(
(a, b) =>
(order.get(a.categoryId) ?? 1e9) - (order.get(b.categoryId) ?? 1e9) ||
@@ -333,11 +395,31 @@ export async function getStock(userId: string, householdId: string): Promise<Sto
items,
suggestions,
expiring,
countDue: groupCountDue(items),
canWrite: hasHouseholdRole(m.role, "member"),
today,
};
}
function groupCountDue(items: ProductTypeView[]): CountDueGroup[] {
const by = new Map<string, CountDueGroup>();
for (const i of items) {
if (!i.count?.due) continue;
const g = by.get(i.categoryId) ?? {
categoryId: i.categoryId,
path: i.categoryPath,
products: 0,
overdueDays: 0,
};
g.products++;
g.overdueDays = Math.max(g.overdueDays, i.count.overdueDays);
by.set(i.categoryId, g);
}
return [...by.values()].sort(
(a, b) => b.overdueDays - a.overdueDays || compareTr(a.path, b.path),
);
}
async function loadProduct(m: Membership, productTypeId: string, session?: ClientSession) {
const id = toObjectId(productTypeId);
if (!id) return null;
@@ -357,11 +439,14 @@ export async function getProductType(
if (!p) return null;
const today = todayIstanbul();
const cats = await loadCategories(m.householdId);
const path =
categoryViews(cats, new Map(), new Map(), { includeArchived: true }).find(
(c) => c.id === String(p.categoryId),
)?.path ?? "";
const moves = (await loadMoves(m.householdId, [p._id])).get(String(p._id)) ?? [];
const cat = categoryViews(cats, new Map(), new Map(), { includeArchived: true }).find(
(c) => c.id === String(p.categoryId),
);
const [movesBy, lastCounts] = await Promise.all([
loadMoves(m.householdId, [p._id]),
loadLastCounts(m.householdId, [p._id]),
]);
const moves = movesBy.get(String(p._id)) ?? [];
let running = 0;
const rows = moves.map((mv): StockMovementRow => {
const delta = signedDelta(mv);
@@ -379,7 +464,12 @@ export async function getProductType(
};
});
return {
...toView(p, moves, path, today),
...toView(p, moves, {
categoryPath: cat?.path ?? "",
categoryCountDays: cat?.effectiveCountDays ?? null,
lastCount: lastCounts.get(String(p._id)) ?? null,
today,
}),
movements: rows.reverse(),
lots: fifoLots(moves).map((l) => ({
date: l.date,
@@ -877,8 +967,8 @@ export async function quickConsume(
}
/**
* Tek ürün sayımı: o tarihe kadarki kayıtlara göre beklenen ile sayılan arasındaki fark sayım
* düzeltmesi olur. Fark yoksa kayıt oluşmaz. Farkı (işaretli) döner.
* Tek ürün sayımı (ürün detayından): toplu sayımın tek satırlık hâli. Fark yoksa düzeltme
* oluşmaz ama sayım kaydı tutulur. Farkı (işaretli) döner.
*/
export async function countStock(
actorId: string,
@@ -886,22 +976,21 @@ export async function countStock(
productTypeId: string,
input: StockCountForm,
): Promise<ActionResult<{ diff: number }>> {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
const r = await activeProductWithStock(m, productTypeId, input.date);
if (r.error !== null) return fail(r.error);
const diff = input.actualQuantity - r.current;
if (diff !== 0)
await StockMovementModel.create({
householdId: m.householdId,
productTypeId: r.p._id,
kind: "count_adjust",
date: input.date,
quantity: diff,
note: input.note,
createdBy: new Types.ObjectId(actorId),
});
return ok({ diff });
const r = await submitCount(actorId, householdId, {
date: input.date,
categoryId: null,
note: input.note,
rows: [
{
productTypeId,
actualQuantity: input.actualQuantity,
wasteQuantity: null,
wasteReason: null,
},
],
});
if (!r.ok) return r;
return ok({ diff: r.data.diffs[0] ?? 0 });
}
export async function deleteStockMovement(
@@ -936,3 +1025,270 @@ export async function deleteStockMovement(
});
return ok(undefined);
}
// --- Periyodik sayım (Faz 11b) ---
export type CountSheetRow = {
id: string;
name: string;
categoryId: string;
categoryPath: string;
unit: string;
step: number;
location: string;
/** Kayıtlara göre bugün beklenen miktar. */
expected: number;
count: CountSchedule | null;
};
export type CountSheet = {
today: string;
category: { id: string; path: string } | null;
/** Filtre seçenekleri: ürünü olan kategoriler ve sayımı gelen ürün sayısı. */
categories: { id: string; path: string; depth: number; due: number }[];
rows: CountSheetRow[];
canWrite: boolean;
};
/** /stok/sayim: beklenen miktarlar (kategori seçiliyse alt ağacıyla). */
export async function getCountSheet(
userId: string,
householdId: string,
categoryId: string | null,
): Promise<CountSheet | null> {
const data = await getStock(userId, householdId);
if (!data) return null;
const all = data.categories;
const category = categoryId ? (all.find((c) => c.id === categoryId) ?? null) : null;
const inScope = category
? new Set([
category.id,
...descendantIds(
all.map((c) => ({
id: c.id,
kind: "expense" as const,
name: c.name,
parentId: c.parentId,
order: 0,
archived: false,
})),
category.id,
),
])
: null;
const due = new Map(data.countDue.map((g) => [g.categoryId, g.products]));
const withItems = new Set(data.items.map((i) => i.categoryId));
// Ürünü olan kategoriler ve ataları seçenek olarak görünür.
const visible = new Set<string>();
for (const c of all)
if (withItems.has(c.id)) {
visible.add(c.id);
let parent = c.parentId;
while (parent) {
visible.add(parent);
parent = all.find((x) => x.id === parent)?.parentId ?? null;
}
}
return {
today: data.today,
category: category ? { id: category.id, path: category.path } : null,
categories: all
.filter((c) => visible.has(c.id))
.map((c) => ({ id: c.id, path: c.path, depth: c.depth, due: due.get(c.id) ?? 0 })),
rows: data.items
.filter((i) => !inScope || inScope.has(i.categoryId))
.map((i) => ({
id: i.id,
name: i.name,
categoryId: i.categoryId,
categoryPath: i.categoryPath,
unit: i.unit,
step: i.step,
location: i.location,
expected: i.current,
count: i.count,
})),
canWrite: data.canWrite,
};
}
/**
* Toplu sayım (tek transaction): her sayılan ürün için o tarihe kadarki kayıtlara göre beklenen
* hesaplanır; eksiğin fire olarak işaretlenen kısmı `waste`, kalan fark `count_adjust` olur.
* Fark sıfır olsa da sayım oturumuna yazılır. Fire, eksikten fazla olamaz.
*/
export async function submitCount(
actorId: string,
householdId: string,
input: StockCountSheet,
): Promise<ActionResult<{ countId: string; counted: number; adjusted: number; diffs: number[] }>> {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
const rows = input.rows.filter((r) => r.actualQuantity !== null);
if (rows.length === 0) return fail(t.validation.nothingCounted);
if (new Set(rows.map((r) => r.productTypeId)).size !== rows.length)
return fail(tr.common.notFound);
const ids = rows.map((r) => new Types.ObjectId(r.productTypeId));
const products = await ProductTypeModel.find({
_id: { $in: ids },
householdId: m.householdId,
}).lean<ProductLean[]>();
const byId = new Map(products.map((p) => [String(p._id), p]));
const moves = await loadMoves(m.householdId, ids);
const fieldErrors: Record<string, string> = {};
const plan = rows.map((r) => {
const p = byId.get(r.productTypeId);
const index = input.rows.indexOf(r);
if (!p) {
fieldErrors[`rows.${index}.actualQuantity`] = tr.common.notFound;
return null;
}
if (p.archivedAt) {
fieldErrors[`rows.${index}.actualQuantity`] = t.validation.archived;
return null;
}
const expected = currentQuantity(
(moves.get(r.productTypeId) ?? []).filter((x) => x.date <= input.date),
);
const diff = r.actualQuantity! - expected;
const wasted = r.wasteQuantity ?? 0;
if (wasted > Math.max(0, -diff)) {
fieldErrors[`rows.${index}.wasteQuantity`] = t.validation.wasteOverShortage(
formatStock(Math.max(0, -diff), p.unit),
);
return null;
}
return { p, row: r, expected, diff, wasted };
});
if (Object.keys(fieldErrors).length) return fail(tr.adminUsers.errors.checkFields, fieldErrors);
const items = plan.filter((x) => x !== null);
const actor = new Types.ObjectId(actorId);
const mongoose = await connectMongoose();
let countId = "";
await mongoose.connection.transaction(async (session) => {
const [count] = await StockCountModel.create(
[
{
householdId: m.householdId,
date: input.date,
categoryId: input.categoryId ? new Types.ObjectId(input.categoryId) : null,
note: input.note,
items: items.map((x) => ({
productTypeId: x.p._id,
expected: x.expected,
actual: x.row.actualQuantity!,
wasted: x.wasted,
})),
createdBy: actor,
},
],
{ session },
);
countId = String(count!._id);
const docs = items.flatMap((x) => {
const base = {
householdId: m.householdId,
productTypeId: x.p._id,
date: input.date,
countId: count!._id,
createdBy: actor,
};
const out: Record<string, unknown>[] = [];
if (x.wasted > 0)
out.push({
...base,
kind: "waste",
quantity: x.wasted,
wasteReason: x.row.wasteReason,
note: t.countNote,
});
const rest = x.diff + x.wasted;
if (rest !== 0)
out.push({
...base,
kind: "count_adjust",
quantity: rest,
note: input.note || t.countNote,
});
return out;
});
// Sıralı ekleme: aynı tarihte fire, düzeltmeden önce gelir.
for (const d of docs) await StockMovementModel.create([d], { session });
});
return ok({
countId,
counted: items.length,
adjusted: items.filter((x) => x.diff !== 0).length,
diffs: items.map((x) => x.diff),
});
}
// --- Tüketim analizi (Faz 11b) ---
/** Analizde gösterilen tam ay sayısı (içinde bulunulan ay ayrıca). */
export const ANALYSIS_MONTHS = 6;
export type StockAnalysisRow = {
id: string;
name: string;
categoryId: string;
categoryPath: string;
unit: string;
months: MonthUsage[];
/** Ürünün var olduğu tam aylarda aylık ortalama kullanım (yoksa null). */
avgMonthlyUsed: number | null;
avgMonthlyWasted: number | null;
/** Fire / (kullanım + fire), %. */
wastePct: number | null;
/** Bu ay (bugüne kadar). */
thisMonth: MonthUsage;
};
export type StockAnalysis = {
months: string[];
thisMonth: string;
rows: StockAnalysisRow[];
};
/** /stok/analiz: ürün tipi başına aylık ortalama tüketim ve fire. */
export async function getStockAnalysis(
userId: string,
householdId: string,
): Promise<StockAnalysis | null> {
const data = await getStock(userId, householdId);
if (!data) return null;
const thisMonth = data.today.slice(0, 7);
const months = Array.from({ length: ANALYSIS_MONTHS }, (_, i) =>
shiftMonth(thisMonth, i - ANALYSIS_MONTHS),
);
const m = (await resolveMembership(userId, householdId, "viewer"))!;
const moves = await loadMoves(
m.householdId,
data.items.map((i) => new Types.ObjectId(i.id)),
);
const rows = data.items.map((item): StockAnalysisRow => {
const list = moves.get(item.id) ?? [];
const [usage, current] = [monthlyUsage(list, months), monthlyUsage(list, [thisMonth])[0]!];
// Ortalama: ilk hareketin ayından sonraki tam aylar (ilk ay eksik olabilir).
const first = list[0]?.date.slice(0, 7);
const full = first ? usage.filter((u) => u.month > first) : [];
const sum = (k: "used" | "wasted") => full.reduce((s, u) => s + u[k], 0);
const used = sum("used");
const wasted = sum("wasted");
return {
id: item.id,
name: item.name,
categoryId: item.categoryId,
categoryPath: item.categoryPath,
unit: item.unit,
months: usage,
avgMonthlyUsed: full.length ? roundForDisplay(used / full.length) : null,
avgMonthlyWasted: full.length ? roundForDisplay(wasted / full.length) : null,
wastePct: used + wasted > 0 ? Math.round((wasted * 100) / (used + wasted)) : null,
thisMonth: current,
};
});
return { months, thisMonth, rows };
}