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
+92
View File
@@ -0,0 +1,92 @@
import { Types } from "mongoose";
import { StockCountModel } from "@/models/stock-count";
import { StockMovementModel } from "@/models/stock-movement";
import { signedDelta, type StockMovementKind } from "@/shared/stock";
import type { Migration } from "./types";
type Move = {
_id: Types.ObjectId;
householdId: Types.ObjectId;
productTypeId: Types.ObjectId;
kind: StockMovementKind;
date: string;
quantity: number;
initial: boolean;
countId: Types.ObjectId | null;
createdBy: Types.ObjectId;
createdAt: Date;
};
/**
* Faz 11b: sayım oturumları. Faz 11a'da tek ürün sayımıyla oluşan (oturumsuz) sayım
* düzeltmeleri, o ana kadarki kayıtlardan beklenen miktar hesaplanarak hane + tarih + kişi
* başına bir sayım oturumuna bağlanır. Başlangıç miktarları sayım sayılmaz.
*/
export const migration: Migration = {
name: "012-stock-counts",
description: "stockCounts index'leri; oturumsuz sayım düzeltmelerini sayım oturumuna bağla",
async up() {
await StockCountModel.createIndexes();
const orphans = await StockMovementModel.find({
kind: "count_adjust",
initial: { $ne: true },
countId: null,
deletedAt: null,
}).lean<Move[]>();
if (orphans.length === 0) return;
const products = [...new Set(orphans.map((o) => String(o.productTypeId)))];
const all = await StockMovementModel.find({
productTypeId: { $in: products.map((id) => new Types.ObjectId(id)) },
deletedAt: null,
}).lean<Move[]>();
const byProduct = new Map<string, Move[]>();
for (const m of all) {
const list = byProduct.get(String(m.productTypeId)) ?? [];
list.push(m);
byProduct.set(String(m.productTypeId), list);
}
// Her düzeltme için beklenen = önceki hareketlerin toplamı (tarih, oluşturulma sırası).
const expectedOf = new Map<string, number>();
for (const list of byProduct.values()) {
list.sort((a, b) =>
a.date === b.date
? a.createdAt.getTime() - b.createdAt.getTime()
: a.date < b.date
? -1
: 1,
);
let running = 0;
for (const m of list) {
expectedOf.set(String(m._id), running);
running += signedDelta(m);
}
}
const sessions = new Map<string, Move[]>();
for (const o of orphans) {
const key = `${o.householdId}:${o.date}:${o.createdBy}`;
sessions.set(key, [...(sessions.get(key) ?? []), o]);
}
for (const moves of sessions.values()) {
const first = moves[0]!;
const count = await StockCountModel.create({
householdId: first.householdId,
date: first.date,
categoryId: null,
items: moves.map((m) => {
const expected = expectedOf.get(String(m._id)) ?? 0;
return {
productTypeId: m.productTypeId,
expected,
actual: expected + m.quantity,
wasted: 0,
};
}),
createdBy: first.createdBy,
});
await StockMovementModel.updateMany(
{ _id: { $in: moves.map((m) => m._id) } },
{ $set: { countId: count._id } },
);
}
},
};
+2
View File
@@ -11,6 +11,7 @@ import { migration as m008 } from "./008-in-kind";
import { migration as m009 } from "./009-assets";
import { migration as m010 } from "./010-budgets";
import { migration as m011 } from "./011-stock";
import { migration as m012 } from "./012-stock-counts";
import type { Migration } from "./types";
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
@@ -26,6 +27,7 @@ export const MIGRATIONS: Migration[] = [
m009,
m010,
m011,
m012,
];
const COLLECTION = "migrations";
+1
View File
@@ -83,6 +83,7 @@ const DOMAIN_COLLECTIONS = [
"stockCategories",
"productTypes",
"stockMovements",
"stockCounts",
];
type AuthApi = ReturnType<typeof getAuth>["api"];
+72 -17
View File
@@ -2,6 +2,7 @@ import { Types } from "mongoose";
import { installDefaultStockCategories } from "@/features/stock/service";
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 { addDays } from "@/shared/recurrence";
@@ -9,8 +10,10 @@ import { foldTr } from "@/shared/search";
/**
* Örnek ev stoğu (Faz 11). Varsayılan stok kategorileri her haneye; Çakır Ailesi'ne ürün tipleri
* ve son 6 ayın haftalık tüketim simülasyonu (alım, tüketim, fire, sayım, SKT). Deterministik;
* idempotent: ürün tipi `seedRef` ile bir kez oluşturulur. Miktarlar binde bir.
* ve son 6 ayın haftalık tüketim simülasyonu (alım, tüketim, fire, periyodik sayım, SKT).
* Sayım: Mutfak 15 günde bir (son sayım 21 gün önce → gecikmiş), Genel Temizlik 30 günde bir
* (son sayım 14 gün önce). Deterministik; idempotent: ürün tipi `seedRef` ile bir kez
* oluşturulur, sayım oturumları `seedRef` ile paylaşılır. Miktarlar binde bir.
*/
type SeedProduct = {
@@ -30,8 +33,8 @@ type SeedProduct = {
waste?: { every: number; pct: number; reason: "spoiled" | "expired" | "damaged" };
/** Bu günden (gün önce) sonra yeniden alınmaz: bugün azalmış görünür. */
stopRefillDaysAgo?: number;
/** Her n haftada bir küçük sayım farkı (−%). */
countDrift?: { everyWeeks: number; pct: number };
/** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */
countDrift?: { everyCounts: number; pct: number };
};
const K = 1000;
@@ -188,7 +191,7 @@ export const PRODUCTS: SeedProduct[] = [
pack: 50 * K,
perDay: 700,
location: "Banyo dolabı",
countDrift: { everyWeeks: 8, pct: 4 },
countDrift: { everyCounts: 2, pct: 4 },
},
{
ref: "yumusatici",
@@ -221,7 +224,7 @@ export const PRODUCTS: SeedProduct[] = [
pack: 16 * K,
perDay: 600,
location: "Banyo dolabı",
countDrift: { everyWeeks: 10, pct: 6 },
countDrift: { everyCounts: 3, pct: 6 },
},
// Kişisel Temizlik
{
@@ -259,6 +262,12 @@ export const PRODUCTS: SeedProduct[] = [
const START_DAYS_AGO = 182;
/** Sayım sıklığı (gün) ve sayım günü kuralı (gün önce), ana stok kategorisine göre. */
const COUNT_PLANS: Record<string, { everyDays: number; on: (daysAgo: number) => boolean }> = {
mutfak: { everyDays: 15, on: (d) => d >= 21 && d % 14 === 7 },
"genel-temizlik": { everyDays: 30, on: (d) => d % 28 === 14 },
};
type Row = {
kind: "purchase" | "consume" | "waste" | "count_adjust";
daysAgo: number;
@@ -267,17 +276,24 @@ type Row = {
wasteReason?: string | null;
note?: string;
initial?: boolean;
/** Bu satır hangi sayıma ait (gün önce). */
countDaysAgo?: number;
};
/** Haftalık simülasyon: tüketim, eşikte alım, fire ve sayım farkı. Stok asla eksiye düşmez. */
export function simulate(p: SeedProduct, today: string): Row[] {
export type SeedCount = { daysAgo: number; expected: number; actual: number };
/**
* Haftalık simülasyon: tüketim, periyodik sayım (farkı nadiren), eşikte alım ve fire. Stok asla
* eksiye düşmez.
*/
export function simulate(p: SeedProduct, today: string): { rows: Row[]; counts: SeedCount[] } {
const rows: Row[] = [];
const counts: SeedCount[] = [];
const plan = COUNT_PLANS[p.category.split(".")[0]!];
let stock = p.target;
let purchases = 0;
rows.push({ kind: "count_adjust", daysAgo: START_DAYS_AGO, quantity: stock, initial: true });
let week = 0;
for (let d = START_DAYS_AGO - 7; d >= 0; d -= 7) {
week++;
// Haftalık tüketim azaltma adımının katı (ör. tam tablet, 0,25 kg).
const step = p.step ?? K;
const consume = Math.min(stock, Math.max(step, Math.round((p.perDay * 7) / step) * step));
@@ -285,12 +301,21 @@ export function simulate(p: SeedProduct, today: string): Row[] {
rows.push({ kind: "consume", daysAgo: d, quantity: consume });
stock -= consume;
}
if (p.countDrift && week % p.countDrift.everyWeeks === 0 && stock > 0) {
const drift = Math.min(stock, Math.round((p.target * p.countDrift.pct) / 100));
if (drift > 0) {
rows.push({ kind: "count_adjust", daysAgo: d, quantity: -drift, note: "Sayım" });
if (plan?.on(d)) {
const expected = stock;
const nth = counts.length + 1;
if (p.countDrift && nth % p.countDrift.everyCounts === 0 && stock > 0) {
const drift = Math.min(stock, Math.round((p.target * p.countDrift.pct) / 100));
rows.push({
kind: "count_adjust",
daysAgo: d,
quantity: -drift,
note: "Sayım",
countDaysAgo: d,
});
stock -= drift;
}
counts.push({ daysAgo: d, expected, actual: stock });
}
const canRefill = p.stopRefillDaysAgo === undefined || d > p.stopRefillDaysAgo;
if (canRefill && stock < (p.target * 35) / 100) {
@@ -311,7 +336,7 @@ export function simulate(p: SeedProduct, today: string): Row[] {
}
}
}
return rows;
return { rows, counts };
}
export async function seedStock(
@@ -321,6 +346,12 @@ export async function seedStock(
const actor = new Types.ObjectId(users.get("ayse")!);
for (const [, id] of householdIds) await installDefaultStockCategories(id, actor);
const householdId = householdIds.get("Çakır Ailesi")!;
// Periyodik sayım sıklığı (kullanıcı ayarladıysa dokunulmaz).
for (const [key, plan] of Object.entries(COUNT_PLANS))
await StockCategoryModel.updateOne(
{ householdId, defaultKey: key, countEveryDays: null },
{ $set: { countEveryDays: plan.everyDays } },
);
const cats = await StockCategoryModel.find({ householdId, defaultKey: { $type: "string" } })
.select({ defaultKey: 1 })
.lean<{ _id: Types.ObjectId; defaultKey: string }[]>();
@@ -344,9 +375,33 @@ export async function seedStock(
updatedBy: actor,
});
const base = Date.now() - START_DAYS_AGO * 86_400_000;
const { rows, counts } = simulate(p, today);
// Sayım oturumu kategori başına ortak: ürün satırı oturuma eklenir.
const root = p.category.split(".")[0]!;
const countIds = new Map<number, Types.ObjectId>();
for (const c of counts) {
const session = await StockCountModel.findOneAndUpdate(
{ householdId, seedRef: `${root}:${c.daysAgo}` },
{
$setOnInsert: {
householdId,
date: addDays(today, -c.daysAgo),
categoryId: catByKey.get(root) ?? null,
note: "",
seedRef: `${root}:${c.daysAgo}`,
createdBy: actor,
},
$push: {
items: { productTypeId: doc._id, expected: c.expected, actual: c.actual, wasted: 0 },
},
},
{ upsert: true, returnDocument: "after" },
).lean<{ _id: Types.ObjectId }>();
countIds.set(c.daysAgo, session!._id);
}
// Ham ekleme: aynı tarihli hareketlerin sırası (createdAt) korunur.
await StockMovementModel.collection.insertMany(
simulate(p, today).map((r, i) => {
rows.map((r, i) => {
const at = new Date(base + i * 1000);
return {
householdId,
@@ -358,7 +413,7 @@ export async function seedStock(
expiryDate: r.expiryDate ?? null,
note: r.note ?? "",
initial: r.initial ?? false,
countId: null,
countId: r.countDaysAgo === undefined ? null : (countIds.get(r.countDaysAgo) ?? null),
deletedAt: null,
seedRef: `${p.ref}:${i}`,
createdBy: actor,
+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)!);
}