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 };
}
+77 -2
View File
@@ -935,6 +935,67 @@ export const tr = {
"Evdeki ürünlerin miktarı, kritik eşik ve son kullanma tarihleri. Stok ürün tipi düzeyinde tutulur.",
newProduct: "Yeni ürün tipi",
categoriesLink: "Stok kategorileri",
countLink: "Sayım",
analysisLink: "Tüketim analizi",
countNote: "Sayım",
countDue: {
title: "Sayımı gelenler",
description:
"Sayım sıklığı dolan kategoriler. Sıklık ürün tipinde veya stok kategorisinde ayarlanır.",
products: (n: number) => `${n} ürün`,
overdue: (days: number) => (days > 0 ? `${days} gün gecikti` : "Bugün"),
start: "Sayıma başla",
},
count: {
title: "Sayım",
description:
"Evdeki gerçek miktarları girin. Kayıtlarla arasındaki fark sayım düzeltmesi olur; eksiğin bir kısmını fire olarak işaretleyebilirsiniz. Boş bıraktığınız ürünler sayılmaz.",
back: "Ev stoğuna dön",
allCategories: "Tüm stok",
category: "Kategori",
dueCount: (n: number) => `${n} sayımı gelen`,
onlyDue: "Yalnızca sayımı gelenler",
expected: "Beklenen",
actual: "Sayılan",
diff: "Fark",
same: "Aynı",
markWaste: "Eksiğin bir kısmı fire",
wasteQuantity: "Fire miktarı",
wasteMax: (q: string) => `En çok ${q}`,
dueBadge: (days: number) => (days > 0 ? `Sayım ${days} gün gecikti` : "Sayım bugün"),
lastCount: (date: string) => `Son sayım ${date}`,
neverCounted: "Hiç sayılmadı",
fillExpected: "Beklenenle doldur",
fillExpectedHint: "Sayılmayan satırlara beklenen miktarı yazar; değişenleri düzeltin.",
clear: "Temizle",
note: "Not",
counted: (n: number) => `${n} ürün sayılacak`,
submit: "Sayımı kaydet",
empty: "Bu kategoride sayılacak ürün yok.",
done: (counted: number, adjusted: number) =>
adjusted
? `${counted} ürün sayıldı; ${adjusted} üründe fark düzeltildi.`
: `${counted} ürün sayıldı; kayıtlarla aynı.`,
},
analysis: {
title: "Tüketim analizi",
description:
"Ürün tipi başına aylık tüketim ve fire. Tüketim: girilen tüketimler ve sayımda çıkan eksikler. Maliyet, ürün fiyatları (Faz 12) gelince eklenecek.",
back: "Ev stoğuna dön",
product: "Ürün tipi",
avgUsed: "Aylık ort. tüketim",
avgWasted: "Aylık ort. fire",
wastePct: "Fire oranı",
thisMonth: "Bu ay",
trend: (n: number) => `Son ${n} ay`,
noData: "—",
empty: "Analiz için ürün tipi yok.",
chartLabel: (name: string) => `${name}: aylık tüketim`,
monthValue: (month: string, used: string, wasted: string) =>
`${month}: tüketim ${used}, fire ${wasted}`,
highWaste: "Fire oranı yüksek",
table: "Tablo",
},
readOnly: "İzleyici rolündesiniz; stoğu değiştiremezsiniz.",
summary: {
products: "Ürün tipi",
@@ -947,6 +1008,10 @@ export const tr = {
description: "Kritik eşiğin altına düşen ürünler ve hedefe tamamlamak için gereken miktar.",
empty: "Eşiğin altına düşen ürün yok.",
suggested: (q: string) => `Öneri: ${q}`,
wasteReduced: (purchases: number, pct: number) =>
`Son ${purchases} alımda %${pct} fire, miktar azaltıldı.`,
roundedByUsage: (week: string) => `Haftalık tüketime (${week}) göre yuvarlandı.`,
covers: (days: number) => `Mevcutla birlikte yaklaşık ${days} gün yeter.`,
},
expiring: {
title: "Son kullanma tarihi",
@@ -1031,6 +1096,13 @@ export const tr = {
target: "Hedef",
threshold: "Eşik",
suggested: "Önerilen alım",
usage: "Tüketim hızı",
perWeek: (q: string) => `Haftada ~${q}`,
noUsage: "Henüz yeterli geçmiş yok",
countSchedule: "Periyodik sayım",
countEvery: (n: number) => `${n} günde bir`,
nextCount: (date: string) => `Sonraki sayım ${date}`,
noCountSchedule: "Sayım sıklığı ayarlanmadı",
location: "Saklama yeri",
lots: "Evdeki partiler",
lotsHint: "Çıkışlar en eski alımdan düşülür (FIFO).",
@@ -1103,17 +1175,20 @@ export const tr = {
wasteReason: "Fire nedenini seçin",
expiryBeforeDate: "Son kullanma tarihi alım tarihinden önce olamaz",
notEnough: (q: string) => `Stokta yalnızca ${q} var.`,
nothingCounted: "En az bir ürünün sayılan miktarını girin.",
wasteOverShortage: (q: string) => `Fire, sayımda çıkan eksikten (${q}) fazla olamaz.`,
emptyStock: "Stok zaten boş.",
duplicate: "Bu adla bir ürün tipi zaten var.",
categoryArchived: "Seçilen stok kategorisi arşivli veya bulunamadı.",
archived: "Ürün tipi arşivli.",
},
dashboard: {
title: "Azalan stoklar",
description: (low: number, expiring: number) =>
title: "Stok uyarıları",
description: (low: number, expiring: number, countDue = 0) =>
[
low ? `${low} ürün eşiğin altında` : null,
expiring ? `${expiring} partinin SKT'si yaklaşıyor` : null,
countDue ? `${countDue} kategoride sayım zamanı` : null,
]
.filter(Boolean)
.join(" · "),
+45
View File
@@ -0,0 +1,45 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
/**
* Sayım oturumu (Bölüm 6.10, periyodik sayım). Her sayılan ürün için beklenen ve sayılan miktar,
* fireye ayrılan kısım. Fark sıfır olsa da kayıt tutulur: "son sayım" ve sayım takvimi buradan
* türetilir. Oluşan hareketler `countId` ile bağlanır.
*/
const stockCountItemSchema = new Schema(
{
productTypeId: { type: Schema.Types.ObjectId, required: true },
expected: { type: Number, required: true },
actual: { type: Number, required: true },
/** Eksiğin fire olarak işaretlenen kısmı (≥ 0). */
wasted: { type: Number, default: 0 },
},
{ _id: false },
);
const stockCountSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
/** "YYYY-MM-DD" (İstanbul). */
date: { type: String, required: true },
/** Sayılan stok kategorisi (alt ağacıyla); null: tüm stok veya tek ürün. */
categoryId: { type: Schema.Types.ObjectId, default: null },
items: { type: [stockCountItemSchema], default: [] },
note: { type: String, default: "", maxlength: 300 },
seedRef: { type: String, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "stockCounts" },
);
stockCountSchema.index({ householdId: 1, "items.productTypeId": 1, date: -1 });
stockCountSchema.index({ householdId: 1, date: -1 });
stockCountSchema.index(
{ householdId: 1, seedRef: 1 },
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
);
export type StockCount = InferSchemaType<typeof stockCountSchema>;
export const StockCountModel: Model<StockCount> =
(models.StockCount as Model<StockCount> | undefined) ??
model<StockCount>("StockCount", stockCountSchema);
+40
View File
@@ -118,3 +118,43 @@ export const stockCountFormSchema = z.object({
});
export type StockCountFormInput = z.input<typeof stockCountFormSchema>;
export type StockCountForm = z.output<typeof stockCountFormSchema>;
/**
* Toplu sayım satırı: sayılan miktar boşsa ürün sayılmamış sayılır (atlanır). Eksiğin bir kısmı
* fire olarak işaretlenebilir (neden zorunlu).
*/
const stockCountRowSchema = z
.object({
productTypeId: z.string().regex(/^[a-f0-9]{24}$/i),
actualQuantity: optionalQuantity,
wasteQuantity: optionalQuantity.default(null),
wasteReason: z.preprocess(
(x) => (x === "" || x === undefined ? null : x),
z.enum(WASTE_REASONS).nullable(),
),
})
.superRefine((x, ctx) => {
if (x.wasteQuantity && !x.wasteReason)
ctx.addIssue({ code: "custom", path: ["wasteReason"], message: s.wasteReason });
if (x.wasteQuantity && x.actualQuantity === null)
ctx.addIssue({ code: "custom", path: ["actualQuantity"], message: s.quantity });
})
.transform((x) => ({
...x,
wasteQuantity: x.wasteQuantity || null,
wasteReason: x.wasteQuantity ? x.wasteReason : null,
}));
export const stockCountSheetSchema = z
.object({
date: ymd,
categoryId: optionalId,
note: z.string().trim().max(300).default(""),
rows: z.array(stockCountRowSchema).max(500),
})
.superRefine((x, ctx) => {
if (!x.rows.some((r) => r.actualQuantity !== null))
ctx.addIssue({ code: "custom", path: ["rows"], message: s.nothingCounted });
});
export type StockCountSheetInput = z.input<typeof stockCountSheetSchema>;
export type StockCountSheet = z.output<typeof stockCountSheetSchema>;
+128
View File
@@ -1,13 +1,19 @@
import { describe, expect, it } from "vitest";
import {
countSchedule,
currentQuantity,
expiringLots,
expiryStatus,
fifoLots,
monthlyUsage,
quantityInput,
recentWaste,
roundForDisplay,
refillQuantity,
stepLabel,
stockLevel,
suggestPurchase,
usageRate,
type StockMove,
} from "./stock";
@@ -94,8 +100,130 @@ describe("eşik ve öneri", () => {
expect(quantityInput(250)).toBe("0,25");
});
it("gösterim yuvarlaması", () => {
expect(roundForDisplay(18_368)).toBe(18_400);
expect(roundForDisplay(1_672)).toBe(1_670);
expect(roundForDisplay(441.4)).toBe(440);
});
it("azaltma adımı etiketi", () => {
expect(stepLabel(1_000, "adet")).toBe("−1 adet");
expect(stepLabel(500, "kg")).toBe("−0,5 kg");
});
});
describe("fire oranı ve önerilen alım", () => {
const wasteful = [
m("purchase", "2026-11-01", 3_000),
m("waste", "2026-11-02", 900),
m("purchase", "2026-11-03", 3_000),
m("waste", "2026-11-04", 900),
m("consume", "2026-11-05", 4_200),
];
it("fire FIFO ile alımlara yazılır; son alımların oranı", () => {
expect(recentWaste(wasteful)).toEqual({
purchases: 2,
purchased: 6_000,
wasted: 1_800,
pct: 30,
});
// Başlangıç partisinden düşen fire alım oranına girmez.
expect(
recentWaste([
{ ...m("count_adjust", "2026-10-01", 1_000), initial: true } as StockMove,
m("waste", "2026-10-02", 500),
m("purchase", "2026-10-03", 2_000),
]),
).toMatchObject({ purchases: 1, wasted: 0, pct: 0 });
expect(recentWaste([m("consume", "2026-10-01", 1)])).toBeNull();
});
it("%30 fire öneriyi düşürür, adıma yuvarlanır", () => {
const s = suggestPurchase({
current: 0,
target: 3_000,
step: 500,
moves: wasteful,
today: "2026-11-06",
});
// 3 kg × %70 = 2,1 kg → 0,5'in katı 2 kg. Geçmiş 14 günden kısa: kullanım yok.
expect(s).toMatchObject({ base: 3_000, quantity: 2_000, roundTo: 500, usage: null });
expect(s.waste?.pct).toBe(30);
});
it("tüketim hızına (haftalık kullanımın katına) yuvarlanır, hedefi aşmaz", () => {
const moves = [
{ ...m("count_adjust", "2026-10-10", 10_000), initial: true },
m("consume", "2026-11-01", 8_400),
];
expect(usageRate(moves, "2026-11-06")).toEqual({ perDay: 300, days: 28 });
const s = suggestPurchase({
current: 1_600,
target: 6_000,
step: 500,
moves,
today: "2026-11-06",
});
// Haftalık 2,1 → 2 kg; taban 4,4 → 2 hafta = 4 kg; (1,6 + 4) / 0,3 = 18 gün.
expect(s).toMatchObject({
base: 4_400,
quantity: 4_000,
roundTo: 2_000,
roundedByUsage: true,
waste: null,
coverDays: 18,
});
// Taban küçükse en az bir adım; hedef doluysa öneri yok.
expect(
suggestPurchase({ current: 950, target: 1_000, step: 100, moves: [], today: "2026-11-06" })
.quantity,
).toBe(100);
expect(
suggestPurchase({ current: 1_000, target: 1_000, step: 100, moves: [], today: "2026-11-06" })
.quantity,
).toBe(0);
});
});
describe("sayım takvimi ve aylık kullanım", () => {
it("son sayımdan n gün sonra sayım gelir", () => {
expect(countSchedule(15, "2026-10-20", "2026-01-01", "2026-11-06")).toEqual({
everyDays: 15,
lastDate: "2026-10-20",
nextDate: "2026-11-04",
due: true,
overdueDays: 2,
});
expect(countSchedule(15, null, "2026-10-25", "2026-11-06")).toMatchObject({
nextDate: "2026-11-09",
due: false,
overdueDays: 0,
});
expect(countSchedule(7, "2026-10-30", "2026-10-01", "2026-11-06")).toMatchObject({
due: true,
overdueDays: 0,
});
expect(countSchedule(null, null, "2026-10-01", "2026-11-06")).toBeNull();
});
it("kullanım = tüketim + sayım eksiği; fire ayrı", () => {
expect(
monthlyUsage(
[
{ ...m("count_adjust", "2026-10-01", 5_000), initial: true },
m("consume", "2026-10-05", 1_000),
m("count_adjust", "2026-10-10", -200),
m("waste", "2026-10-12", 300),
m("count_adjust", "2026-10-15", 100),
m("consume", "2026-11-02", 500),
],
["2026-09", "2026-10", "2026-11"],
),
).toEqual([
{ month: "2026-09", used: 0, wasted: 0 },
{ month: "2026-10", used: 1_200, wasted: 300 },
{ month: "2026-11", used: 500, wasted: 0 },
]);
});
});
+219 -9
View File
@@ -1,4 +1,5 @@
import { daysBetween } from "./budget";
import { addDays } from "./recurrence";
import { formatQuantity, QUANTITY_SCALE } from "./quantity";
/**
@@ -58,13 +59,11 @@ export type Lot = {
remaining: number;
};
/**
* FIFO partileri. `moves` tarih (ve oluşturulma) sırasında olmalı. Girişler (alım, artı sayım
* farkı) parti açar; çıkışlar en eski partiden düşer. Stok geçici olarak eksiye düşerse (geriye
* tarihli tüketim) açık, sonraki girişten kapatılır. Yalnızca kalanı olan partiler döner.
*/
export function fifoLots(moves: readonly StockMove[]): Lot[] {
const lots: Lot[] = [];
type TrackedLot = Lot & { kind: StockMovementKind; wasted: number };
/** FIFO çalıştırır; her girişin kalanını ve fireyle düşülen kısmını izler. */
function runFifo(moves: readonly StockMove[]): TrackedLot[] {
const lots: TrackedLot[] = [];
let deficit = 0;
moves.forEach((m, index) => {
const delta = signedDelta(m);
@@ -73,10 +72,12 @@ export function fifoLots(moves: readonly StockMove[]): Lot[] {
deficit -= covered;
lots.push({
index,
kind: m.kind,
date: m.date,
expiryDate: m.kind === "purchase" ? (m.expiryDate ?? null) : null,
quantity: delta,
remaining: delta - covered,
wasted: 0,
});
return;
}
@@ -85,11 +86,29 @@ export function fifoLots(moves: readonly StockMove[]): Lot[] {
if (out === 0) break;
const take = Math.min(lot.remaining, out);
lot.remaining -= take;
if (m.kind === "waste") lot.wasted += take;
out -= take;
}
deficit += out;
});
return lots.filter((l) => l.remaining > 0);
return lots;
}
/**
* FIFO partileri. `moves` tarih (ve oluşturulma) sırasında olmalı. Girişler (alım, artı sayım
* farkı) parti açar; çıkışlar en eski partiden düşer. Stok geçici olarak eksiye düşerse (geriye
* tarihli tüketim) açık, sonraki girişten kapatılır. Yalnızca kalanı olan partiler döner.
*/
export function fifoLots(moves: readonly StockMove[]): Lot[] {
return runFifo(moves)
.filter((l) => l.remaining > 0)
.map(({ index, date, expiryDate, quantity, remaining }) => ({
index,
date,
expiryDate,
quantity,
remaining,
}));
}
export type ExpiryStatus = "expired" | "soon" | "ok";
@@ -142,7 +161,7 @@ export function stockLevel(current: number, target: number, thresholdPct: number
};
}
/** Basit öneri: hedefe tamamla (Faz 11b'de tüketim hızı ve fireye göre ayarlanır). */
/** Hedefe tamamlayan miktar (öneri motorunun tabanı). */
export function refillQuantity(current: number, target: number): number {
return Math.max(0, target - Math.max(0, current));
}
@@ -157,9 +176,200 @@ export function stepLabel(step: number, unit: string): string {
return `−${formatQuantity(step || QUANTITY_SCALE, unit)}`;
}
/** Ortalama/hız gösterimi: 10 birimden büyükse bir, değilse iki ondalık (18,368 → 18,4). */
export function roundForDisplay(quantity: number): number {
const unit = Math.abs(quantity) >= 10 * QUANTITY_SCALE ? 100 : 10;
return Math.round(quantity / unit) * unit;
}
/** Form alanı için gruplamasız metin: 1500 → "1,5"; 1_000_000 → "1000" (parseQuantity ile uyumlu). */
export function quantityInput(quantity: number): string {
const int = Math.trunc(quantity / QUANTITY_SCALE);
const frac = (quantity % QUANTITY_SCALE).toString().padStart(3, "0").replace(/0+$/, "");
return frac ? `${int},${frac}` : String(int);
}
// --- Öneri, tüketim hızı, sayım ve analiz (Faz 11b) ---
/** Fire oranı son bu kadar alımdan hesaplanır. */
export const WASTE_LOOKBACK_PURCHASES = 3;
/** Bu oranın (%) altındaki fire öneriyi değiştirmez. */
export const WASTE_MIN_PCT = 5;
/** Tüketim hızı son bu kadar günden hesaplanır. */
export const USAGE_WINDOW_DAYS = 90;
/** Bundan kısa geçmişte tüketim hızı hesaplanmaz. */
export const USAGE_MIN_DAYS = 14;
export type WasteStat = {
/** Hesaba katılan alım sayısı. */
purchases: number;
purchased: number;
wasted: number;
/** Fire oranı (%, tamsayı). */
pct: number;
};
/**
* Son alımların fire oranı: fireler FIFO ile hangi alımdan düşüldüyse ona yazılır
* (ör. iki alımın 6 kg'ından 1,8 kg'ı bozuldu → %30). Alım yoksa null.
*/
export function recentWaste(
moves: readonly StockMove[],
lookback = WASTE_LOOKBACK_PURCHASES,
): WasteStat | null {
const purchases = runFifo(moves)
.filter((l) => l.kind === "purchase")
.slice(-lookback);
if (purchases.length === 0) return null;
const purchased = purchases.reduce((s, l) => s + l.quantity, 0);
const wasted = purchases.reduce((s, l) => s + l.wasted, 0);
return {
purchases: purchases.length,
purchased,
wasted,
pct: purchased > 0 ? Math.round((wasted * 100) / purchased) : 0,
};
}
/** Kullanım: tüketim + sayımda çıkan eksik (başlangıç hariç). Fire ayrı tutulur. */
function usageOf(m: StockMove & { initial?: boolean }): number {
if (m.kind === "consume") return m.quantity;
if (m.kind === "count_adjust" && !m.initial && m.quantity < 0) return -m.quantity;
return 0;
}
export type UsageRate = {
/** Günlük ortalama kullanım (binde bir, kesirli olabilir). */
perDay: number;
/** Hesaba katılan gün sayısı. */
days: number;
};
/**
* Son `window` günün günlük ortalama kullanımı. Pencere, ilk hareketten önce başlıyorsa ilk
* hareketten sayılır. Geçmiş `USAGE_MIN_DAYS`'ten kısaysa null.
*/
export function usageRate(
moves: readonly (StockMove & { initial?: boolean })[],
today: string,
window = USAGE_WINDOW_DAYS,
): UsageRate | null {
const first = moves[0]?.date;
if (!first) return null;
const windowStart = addDays(today, -(window - 1));
const start = first > windowStart ? first : windowStart;
if (start > today) return null;
const days = daysBetween(start, today);
if (days < USAGE_MIN_DAYS) return null;
const used = moves
.filter((m) => m.date >= start && m.date <= today)
.reduce((s, m) => s + usageOf(m), 0);
return { perDay: used / days, days };
}
export type PurchaseSuggestion = {
/** Önerilen alım (binde bir). */
quantity: number;
/** Hedefe tamamlayan ham miktar. */
base: number;
/** Öneriyi düşüren fire (oran WASTE_MIN_PCT'nin altındaysa null). */
waste: WasteStat | null;
/** Yuvarlama birimi: haftalık kullanım (adımın katı) veya adım. */
roundTo: number;
roundedByUsage: boolean;
usage: UsageRate | null;
/** Önerilen alım + mevcut kaç gün yeter (kullanım bilinmiyorsa null). */
coverDays: number | null;
};
const roundUp = (x: number, unit: number) => Math.ceil(x / unit) * unit;
/**
* Önerilen alım miktarı (Bölüm 6.10):
* 1. Taban = hedef − mevcut.
* 2. Son alımlarda fire varsa (≥ %5) taban fire oranı kadar düşürülür.
* 3. Tüketim hızına göre yuvarlanır: haftalık kullanım adımdan büyükse haftalık kullanımın
* (adıma yuvarlanmış) katına, değilse adıma en yakın kata. Sonuç tabanı (adıma yukarı
* yuvarlanmış) aşmaz; taban > 0 iken en az bir adımdır.
*/
export function suggestPurchase(input: {
current: number;
target: number;
step: number;
moves: readonly (StockMove & { initial?: boolean })[];
today: string;
}): PurchaseSuggestion {
const step = Math.max(1, input.step);
const base = refillQuantity(input.current, input.target);
const stat = recentWaste(input.moves);
const waste = stat && stat.pct >= WASTE_MIN_PCT && stat.wasted > 0 ? stat : null;
const usage = usageRate(input.moves, input.today);
const weekly = usage ? Math.round((usage.perDay * 7) / step) * step : 0;
const roundTo = weekly > step ? weekly : step;
let quantity = 0;
if (base > 0) {
const reduced = waste ? (base * (100 - waste.pct)) / 100 : base;
quantity = Math.round(reduced / roundTo) * roundTo;
if (quantity === 0) quantity = roundUp(reduced, step);
quantity = Math.max(step, Math.min(quantity, roundUp(base, step)));
}
const after = Math.max(0, input.current) + quantity;
return {
quantity,
base,
waste,
roundTo,
roundedByUsage: roundTo !== step,
usage,
coverDays: usage && usage.perDay > 0 ? Math.floor(after / usage.perDay) : null,
};
}
export type CountSchedule = {
everyDays: number;
/** Son sayım (yoksa null). */
lastDate: string | null;
nextDate: string;
due: boolean;
/** Sayım günü geçtiyse kaç gün geçti (bugün sayım günüyse 0). */
overdueDays: number;
};
/**
* Periyodik sayım takvimi: son sayımdan (hiç sayılmadıysa `since`'ten, ör. ilk hareket)
* `everyDays` gün sonra sayım gelir. Sıklık yoksa null.
*/
export function countSchedule(
everyDays: number | null,
lastDate: string | null,
since: string,
today: string,
): CountSchedule | null {
if (!everyDays) return null;
const nextDate = addDays(lastDate ?? since, everyDays);
const due = nextDate <= today;
return {
everyDays,
lastDate,
nextDate,
due,
overdueDays: due ? daysBetween(nextDate, today) - 1 : 0,
};
}
export type MonthUsage = { month: string; used: number; wasted: number };
/** Ay ay ("YYYY-MM") kullanım ve fire. */
export function monthlyUsage(
moves: readonly (StockMove & { initial?: boolean })[],
months: readonly string[],
): MonthUsage[] {
const by = new Map(months.map((m) => [m, { month: m, used: 0, wasted: 0 }]));
for (const mv of moves) {
const row = by.get(mv.date.slice(0, 7));
if (!row) continue;
row.used += usageOf(mv);
if (mv.kind === "waste") row.wasted += mv.quantity;
}
return months.map((m) => by.get(m)!);
}