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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user