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
+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();
});
});