814bdbbc2a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
447 lines
16 KiB
TypeScript
447 lines
16 KiB
TypeScript
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<ReturnType<typeof setupSeededDb>>;
|
||
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<ProductTypeFormInput> & { 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();
|
||
});
|
||
});
|