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"; import { productTypeFormSchema, stockCategoryFormSchema, stockCountFormSchema, stockCountSheetSchema, stockMovementFormSchema, type ProductTypeFormInput, } from "@/shared/schemas/stock"; import { setupSeededDb, teardownDb } from "@/test/seeded-db"; import { addStockMovement, countStock, createProductType, createStockCategory, deleteStockMovement, getCountSheet, getProductType, getStock, getStockAnalysis, getStockOptions, listStockCategories, quickConsume, setProductTypeArchived, setStockCategoryArchived, submitCount, updateStockCategory, } from "./service"; let ids: Awaited>; let cakir = ""; let ayse = ""; let zeynep = ""; let fatma = ""; const today = todayIstanbul(); beforeAll(async () => { ids = await setupSeededDb(); cakir = ids.household("Çakır Ailesi"); ayse = ids.user("ayse"); zeynep = ids.user("zeynep"); fatma = ids.user("fatma"); }); afterAll(teardownDb); async function categoryId(path: string) { const o = (await getStockOptions(ayse, cakir))!; return o.categories.find((c) => c.path === path)!.id; } function product(input: Partial & { categoryId: string }) { return productTypeFormSchema.parse({ name: "Test Ürün", unit: "kg", targetQuantity: "4", thresholdPct: "20", step: "0,5", ...input, }); } describe("seed ve görünüm", () => { it("varsayılan ağaç, gider eşlemesi devralınır; azalanlar ve SKT", async () => { const s = (await getStock(ayse, cakir))!; expect(s.items).toHaveLength(19); const bakliyat = s.categories.find((c) => c.path === "Mutfak › Bakliyat")!; expect(bakliyat.depth).toBe(1); expect(bakliyat.expense).toEqual( expect.objectContaining({ path: "Market › Gıda", inherited: true }), ); expect(s.categories.find((c) => c.path === "Mutfak")!.expense?.inherited).toBe(false); // Bir süredir alınmayanlar eşiğin altında. const low = s.suggestions.map((i) => i.name); expect(low).toEqual(expect.arrayContaining(["Kırmızı Mercimek", "Tablet Deterjan"])); for (const i of s.suggestions) { expect(i.level.low).toBe(true); expect(i.suggestedQuantity).toBe(i.targetQuantity - Math.max(0, i.current)); } // Hiçbir stok eksiye düşmez; süt partilerinin SKT'si yakın. expect(s.items.every((i) => i.current >= 0)).toBe(true); expect(s.expiring.some((e) => e.item.name === "Süt")).toBe(true); // Kategori sırası: Mutfak ürünleri temizlikten önce. expect(s.items.findIndex((i) => i.name === "Domates")).toBeLessThan( s.items.findIndex((i) => i.name === "Toz Deterjan"), ); const annemler = (await getStock(fatma, ids.household("Annemler")))!; expect(annemler.items).toHaveLength(0); expect(annemler.categories.length).toBe(12); }); it("izolasyon: üye olmayan göremez, izleyici yazamaz", async () => { expect(await getStock(fatma, cakir)).toBeNull(); const s = (await getStock(zeynep, cakir))!; expect(s.canWrite).toBe(false); const any = s.items[0]!; expect((await quickConsume(zeynep, cakir, any.id)).ok).toBe(false); expect(await getProductType(fatma, cakir, any.id)).toBeNull(); }); }); describe("ürün tipi ve hareketler", () => { it("başlangıç miktarı, tüketim sınırı, 1 azalt, fire, SKT FIFO, sayım, silme", async () => { const cat = await categoryId("Mutfak › Bakliyat"); const created = await createProductType( ayse, cakir, product({ categoryId: cat, name: "Bulgur", initialQuantity: "1,5" }), ); expect(created.ok).toBe(true); const id = created.ok ? created.data.id : ""; let p = (await getProductType(ayse, cakir, id))!; expect(p.current).toBe(1_500); expect(p.movements[0]).toMatchObject({ kind: "count_adjust", initial: true, delta: 1_500 }); // 1,5 < %20 × 4 = 0,8? hayır → azalmadı. expect(p.level.low).toBe(false); const dup = await createProductType(ayse, cakir, product({ categoryId: cat, name: "bulgur" })); expect(dup.ok).toBe(false); const tooMuch = await addStockMovement( ayse, cakir, id, stockMovementFormSchema.parse({ kind: "consume", date: today, quantity: "2" }), ); expect(tooMuch).toMatchObject({ ok: false, fieldErrors: { quantity: expect.any(String) } }); const q = await quickConsume(ayse, cakir, id); expect(q).toEqual({ ok: true, data: { remaining: 1_000 } }); // Alım (SKT'li) sonra fire: fire en eski partiden (başlangıç) düşer. await addStockMovement( ayse, cakir, id, stockMovementFormSchema.parse({ kind: "purchase", date: today, quantity: "1", expiryDate: addDays(today, 3), }), ); expect( stockMovementFormSchema.safeParse({ kind: "waste", date: today, quantity: "1" }).success, ).toBe(false); await addStockMovement( ayse, cakir, id, stockMovementFormSchema.parse({ kind: "waste", date: today, quantity: "0,8", wasteReason: "spoiled", }), ); p = (await getProductType(ayse, cakir, id))!; expect(p.current).toBe(1_200); expect(p.lots).toEqual([ expect.objectContaining({ remaining: 200, expiryDate: null }), expect.objectContaining({ remaining: 1_000, expiryDate: addDays(today, 3), status: "soon" }), ]); expect(p.expiring).toHaveLength(1); // Sayım: 0,5 sayıldı → −0,7 düzeltme; aynı miktar tekrar → fark yok. const c = await countStock( ayse, cakir, id, stockCountFormSchema.parse({ date: today, actualQuantity: "0,5" }), ); expect(c).toEqual({ ok: true, data: { diff: -700 } }); const same = await countStock( ayse, cakir, id, stockCountFormSchema.parse({ date: today, actualQuantity: "0,5" }), ); expect(same).toEqual({ ok: true, data: { diff: 0 } }); p = (await getProductType(ayse, cakir, id))!; expect(p.current).toBe(500); expect(p.level.low).toBe(true); expect(p.suggestedQuantity).toBe(3_500); // Hareket silme: yeniden hesap + denetim kaydı. const adj = p.movements[0]!; expect(adj.kind).toBe("count_adjust"); expect((await deleteStockMovement(ayse, cakir, adj.id)).ok).toBe(true); expect((await getProductType(ayse, cakir, id))!.current).toBe(1_200); expect( await AuditLogModel.exists({ action: "stock.movement.delete", targetId: adj.id }), ).toBeTruthy(); expect(await StockMovementModel.exists({ _id: adj.id, deletedAt: null })).toBeNull(); // Arşivli üründe hareket yok; arşivden çıkınca devam. expect((await setProductTypeArchived(ayse, cakir, id, true)).ok).toBe(true); expect((await quickConsume(ayse, cakir, id)).ok).toBe(false); expect((await getStock(ayse, cakir))!.items.some((i) => i.id === id)).toBe(false); expect((await setProductTypeArchived(ayse, cakir, id, false)).ok).toBe(true); }); it("boş stokta 1 azalt reddedilir; özel birim", async () => { const cat = await categoryId("Genel Temizlik › Çamaşır"); const r = await createProductType( ayse, cakir, product({ categoryId: cat, name: "Leke Çıkarıcı", unit: "yıkama", step: "1" }), ); const id = r.ok ? r.data.id : ""; expect(await quickConsume(ayse, cakir, id)).toEqual({ ok: false, error: tr.stock.validation.emptyStock, }); expect((await getProductType(ayse, cakir, id))!.unit).toBe("yıkama"); }); }); describe("stok kategorileri", () => { it("oluştur, gider eşle, taşıma kuralları, arşiv koşulu", async () => { const opts = (await getStockOptions(ayse, cakir))!; const market = opts.expenseCategories.find((c) => c.path === "Market")!; const parent = await createStockCategory( ayse, cakir, stockCategoryFormSchema.parse({ name: "Bebek", parentId: "", expenseCategoryId: market.id, countEveryDays: "30", }), ); const parentId = parent.ok ? parent.data.id : ""; const child = await createStockCategory( ayse, cakir, stockCategoryFormSchema.parse({ name: "Bez", parentId, expenseCategoryId: "", countEveryDays: "", }), ); const childId = child.ok ? child.data.id : ""; let list = (await listStockCategories(ayse, cakir))!.categories; const bez = list.find((c) => c.id === childId)!; expect(bez).toMatchObject({ path: "Bebek › Bez", effectiveCountDays: 30, expense: expect.objectContaining({ path: "Market", inherited: true }), }); // Kendi altına taşınamaz; aynı adlı kardeş olamaz. const loop = await updateStockCategory( ayse, cakir, parentId, stockCategoryFormSchema.parse({ name: "Bebek", parentId: childId, expenseCategoryId: "", countEveryDays: "", }), ); expect(loop.ok).toBe(false); const dup = await createStockCategory( ayse, cakir, stockCategoryFormSchema.parse({ name: "mutfak", parentId: "", expenseCategoryId: "", countEveryDays: "", }), ); expect(dup.ok).toBe(false); // Gelir kategorisi eşlenemez. const income = await createStockCategory(ayse, cakir, { name: "Yanlış", parentId: null, expenseCategoryId: "0".repeat(24), countEveryDays: null, }); expect(income.ok).toBe(false); // Ürün varken arşivlenemez; ürün yokken alt ağaçla arşivlenir. const r = await createProductType( ayse, cakir, product({ categoryId: childId, name: "Islak Mendil" }), ); expect((await setStockCategoryArchived(ayse, cakir, parentId, true)).ok).toBe(false); await setProductTypeArchived(ayse, cakir, r.ok ? r.data.id : "", true); expect((await setStockCategoryArchived(ayse, cakir, parentId, true)).ok).toBe(true); list = (await listStockCategories(ayse, cakir))!.categories; expect(list.find((c) => c.id === childId)!.archived).toBe(true); expect((await getStockOptions(ayse, cakir))!.categories.some((c) => c.id === childId)).toBe( false, ); // Arşivli kategoriye ürün eklenemez. expect( (await createProductType(ayse, cakir, product({ categoryId: childId, name: "Pudra" }))).ok, ).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(); }); });