feat(shopping): alışveriş listeleri, stok eşiği önerileri ve akıllı mağaza önerisi motoru
- shoppingLists koleksiyonu (ürün tipi / marka / serbest metin kalemleri), migration 015 - planShopping saf fonksiyonu: paket yuvarlama, zahmet ve hacimli cezası, teslimat, yemek kartı önceliği, ek mağaza eşiği - eşik önerileri onay/red (red stok yenilenene kadar), ürün tipine hacimli işareti - fiyat servisinde ortak loadTypeOffers; seed listeleri ve testler Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { AuditLogModel } from "@/models/audit-log";
|
||||
import { ProductModel } from "@/models/product";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { ShoppingListModel } from "@/models/shopping-list";
|
||||
import { shoppingItemFormSchema, shoppingItemUpdateSchema } from "@/shared/schemas/shopping";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
addShoppingItem,
|
||||
approveSuggestions,
|
||||
createShoppingList,
|
||||
deleteShoppingList,
|
||||
dismissSuggestion,
|
||||
getShoppingList,
|
||||
listShoppingLists,
|
||||
moveShoppingItem,
|
||||
removeShoppingItem,
|
||||
setShoppingItemChecked,
|
||||
updateShoppingItem,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
let weekly = "";
|
||||
let monthEnd = "";
|
||||
|
||||
const typeId = (seedRef: string) =>
|
||||
ProductTypeModel.findOne({ seedRef })
|
||||
.lean()
|
||||
.then((x) => String(x!._id));
|
||||
const productId = (seedRef: string) =>
|
||||
ProductModel.findOne({ seedRef })
|
||||
.lean()
|
||||
.then((x) => String(x!._id));
|
||||
const item = (input: Record<string, unknown>) => shoppingItemFormSchema.parse(input);
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
zeynep = ids.user("zeynep");
|
||||
fatma = ids.user("fatma");
|
||||
weekly = String((await ShoppingListModel.findOne({ seedRef: "haftalik" }).lean())!._id);
|
||||
monthEnd = String((await ShoppingListModel.findOne({ seedRef: "ay-sonu" }).lean())!._id);
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
describe("liste okuma ve akıllı öneri", () => {
|
||||
it("aktif listeler, bekleyen öneri sayısı; başka hane göremez", async () => {
|
||||
const data = (await listShoppingLists(ayse, cakir))!;
|
||||
expect(data.active.map((l) => l.name).sort()).toEqual(["Ay sonu toptan", "Haftalık market"]);
|
||||
expect(data.suggestions).toBeGreaterThanOrEqual(3);
|
||||
expect(data.canWrite).toBe(true);
|
||||
expect(await listShoppingLists(fatma, cakir)).toBeNull();
|
||||
expect(await getShoppingList(fatma, cakir, weekly)).toBeNull();
|
||||
});
|
||||
|
||||
it("kalem görünümleri, eşik önerileri ve plan", async () => {
|
||||
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||
expect(d.items).toHaveLength(9);
|
||||
const milk = d.items.find((i) => i.label === "Süt")!;
|
||||
expect(milk).toMatchObject({ kind: "type", unit: "lt", quantityLabel: "4 lt" });
|
||||
expect(milk.stock).not.toBeNull();
|
||||
const yumos = d.items.find((i) => i.kind === "product")!;
|
||||
expect(yumos.quantityLabel).toBe("1 paket");
|
||||
const candle = d.items.find((i) => i.kind === "text")!;
|
||||
expect(candle).toMatchObject({ label: "Doğum günü mumu", note: "Rakam mum: 7" });
|
||||
|
||||
// Eşiğin altındaki mercimek listede yok: öneri olarak gelir.
|
||||
expect(d.suggestions.map((s) => s.name)).toContain("Kırmızı Mercimek");
|
||||
expect(d.suggestions.every((s) => s.suggestedQuantity >= 0)).toBe(true);
|
||||
|
||||
// Plan: domatesin fiyatı yok; serbest metin plana katılmaz; diğerleri karşılanır.
|
||||
const tomato = d.items.find((i) => i.label === "Domates")!;
|
||||
expect(d.plan.unpriced).toEqual([tomato.id]);
|
||||
const rec = d.plan.recommended!;
|
||||
expect(rec.missing).toEqual([]);
|
||||
const planned = rec.stores.flatMap((g) => g.lines.map((l) => l.itemId));
|
||||
expect(planned).toHaveLength(7);
|
||||
expect(planned).not.toContain(candle.id);
|
||||
for (const g of rec.stores) {
|
||||
expect(d.plan.stores[g.storeId]).toBeDefined();
|
||||
for (const l of g.lines) expect(d.plan.products[l.productId]).toBeDefined();
|
||||
}
|
||||
// Belirli marka kalemi yalnızca o ürünle planlanır.
|
||||
const yumosLine = rec.stores.flatMap((g) => g.lines).find((l) => l.itemId === yumos.id)!;
|
||||
expect(yumosLine.productId).toBe(yumos.productId);
|
||||
expect(d.plan.single).not.toBeNull();
|
||||
expect(d.plan.thresholdMinor).toBe(10_000);
|
||||
// Ayşe'nin yemek kartı bakiyesi plana girer (seed: Multinet).
|
||||
expect(d.plan.mealCardBalanceMinor).toBeGreaterThan(0);
|
||||
expect(d.options.types.length).toBeGreaterThan(10);
|
||||
expect(d.otherLists.map((o) => o.name)).toEqual(["Ay sonu toptan"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("kalem işlemleri", () => {
|
||||
it("ekle (çift kayıt reddi, izleyici yazamaz), düzenle, işaretle, taşı, çıkar", async () => {
|
||||
const milk = await typeId("sut");
|
||||
expect(shoppingItemFormSchema.safeParse({ kind: "type", productTypeId: "" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shoppingItemFormSchema.safeParse({ kind: "text", text: "" }).success).toBe(false);
|
||||
expect(
|
||||
(await addShoppingItem(ayse, cakir, weekly, item({ kind: "type", productTypeId: milk }))).ok,
|
||||
).toBe(false);
|
||||
const text = item({ kind: "text", text: "Pil", quantity: "4", unit: "adet" });
|
||||
expect((await addShoppingItem(zeynep, cakir, weekly, text)).ok).toBe(false);
|
||||
|
||||
const added = await addShoppingItem(ayse, cakir, weekly, text);
|
||||
expect(added.ok).toBe(true);
|
||||
const id = added.ok ? added.data.id : "";
|
||||
const brand = await addShoppingItem(
|
||||
ayse,
|
||||
cakir,
|
||||
weekly,
|
||||
item({ kind: "product", productId: await productId("ariel-5kg"), quantity: "2" }),
|
||||
);
|
||||
expect(brand.ok).toBe(true);
|
||||
|
||||
expect(
|
||||
(
|
||||
await updateShoppingItem(
|
||||
ayse,
|
||||
cakir,
|
||||
weekly,
|
||||
id,
|
||||
shoppingItemUpdateSchema.parse({ quantity: "6", text: "AA pil", unit: "adet" }),
|
||||
)
|
||||
).ok,
|
||||
).toBe(true);
|
||||
let d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||
expect(d.items.find((i) => i.id === id)).toMatchObject({
|
||||
label: "AA pil",
|
||||
quantityLabel: "6 adet",
|
||||
});
|
||||
const ariel = d.items.find((i) => i.label.startsWith("Ariel"))!;
|
||||
expect(ariel.quantityLabel).toBe("2 paket");
|
||||
|
||||
expect((await setShoppingItemChecked(ayse, cakir, weekly, id, true)).ok).toBe(true);
|
||||
d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||
// Sepete alınanlar sona.
|
||||
expect(d.items.at(-1)).toMatchObject({ id, checked: true });
|
||||
expect(d.list.checkedCount).toBe(1);
|
||||
|
||||
expect((await moveShoppingItem(ayse, cakir, weekly, id, weekly)).ok).toBe(false);
|
||||
expect((await moveShoppingItem(ayse, cakir, weekly, id, monthEnd)).ok).toBe(true);
|
||||
const target = (await getShoppingList(ayse, cakir, monthEnd))!;
|
||||
expect(target.items.find((i) => i.id === id)).toMatchObject({ checked: false });
|
||||
|
||||
expect((await removeShoppingItem(ayse, cakir, monthEnd, id)).ok).toBe(true);
|
||||
expect((await removeShoppingItem(ayse, cakir, monthEnd, id)).ok).toBe(false);
|
||||
expect((await removeShoppingItem(ayse, cakir, weekly, ariel.id)).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("eşik önerileri", () => {
|
||||
it("onayla: önerilen miktarla eklenir, öneriden düşer; tekrar onay reddedilir", async () => {
|
||||
const lentil = await typeId("kirmizi-mercimek");
|
||||
const r = await approveSuggestions(ayse, cakir, weekly, [lentil]);
|
||||
expect(r).toMatchObject({ ok: true, data: { added: 1 } });
|
||||
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||
const row = d.items.find((i) => i.productTypeId === lentil)!;
|
||||
expect(row.source).toBe("suggestion");
|
||||
expect(row.quantity).toBeGreaterThan(0);
|
||||
expect(d.suggestions.some((s) => s.productTypeId === lentil)).toBe(false);
|
||||
expect((await approveSuggestions(ayse, cakir, weekly, [lentil])).ok).toBe(false);
|
||||
// Başka aktif listede de öneri olarak görünmez.
|
||||
const other = (await getShoppingList(ayse, cakir, monthEnd))!;
|
||||
expect(other.suggestions.some((s) => s.productTypeId === lentil)).toBe(false);
|
||||
await removeShoppingItem(ayse, cakir, weekly, row.id);
|
||||
});
|
||||
|
||||
it("reddet: stok yenilenene kadar gizli; izleyici reddedemez", async () => {
|
||||
const tablet = await typeId("tablet-deterjan");
|
||||
expect((await dismissSuggestion(zeynep, cakir, tablet)).ok).toBe(false);
|
||||
expect((await dismissSuggestion(ayse, cakir, tablet)).ok).toBe(true);
|
||||
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||
expect(d.suggestions.some((s) => s.productTypeId === tablet)).toBe(false);
|
||||
await ProductTypeModel.updateOne(
|
||||
{ seedRef: "tablet-deterjan" },
|
||||
{ $set: { suggestionDismissedAt: null } },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("liste yönetimi", () => {
|
||||
it("oluştur ve sil (soft delete + denetim)", async () => {
|
||||
const r = await createShoppingList(ayse, cakir, { name: "Deneme", note: "" });
|
||||
expect(r.ok).toBe(true);
|
||||
const id = r.ok ? r.data.id : "";
|
||||
expect((await deleteShoppingList(zeynep, cakir, id)).ok).toBe(false);
|
||||
expect((await deleteShoppingList(ayse, cakir, id)).ok).toBe(true);
|
||||
expect(await getShoppingList(ayse, cakir, id)).toBeNull();
|
||||
expect(
|
||||
await AuditLogModel.exists({ action: "shoppingList.delete", targetId: id }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user