feat(stock): stok kategorileri, ürün tipleri, hareketler ve FIFO SKT

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 08:10:10 +03:00
parent abf2ea6dd7
commit d9de29c18d
18 changed files with 2544 additions and 1 deletions
+307
View File
@@ -0,0 +1,307 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { tr } from "@/i18n/tr";
import { AuditLogModel } from "@/models/audit-log";
import { StockMovementModel } from "@/models/stock-movement";
import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import {
productTypeFormSchema,
stockCategoryFormSchema,
stockCountFormSchema,
stockMovementFormSchema,
type ProductTypeFormInput,
} from "@/shared/schemas/stock";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
addStockMovement,
countStock,
createProductType,
createStockCategory,
deleteStockMovement,
getProductType,
getStock,
getStockOptions,
listStockCategories,
quickConsume,
setProductTypeArchived,
setStockCategoryArchived,
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);
});
});