diff --git a/scripts/migrations/015-shopping-lists.ts b/scripts/migrations/015-shopping-lists.ts new file mode 100644 index 0000000..90cbd04 --- /dev/null +++ b/scripts/migrations/015-shopping-lists.ts @@ -0,0 +1,10 @@ +import { ShoppingListModel } from "@/models/shopping-list"; +import type { Migration } from "./types"; + +export const migration: Migration = { + name: "015-shopping-lists", + description: "alışveriş listeleri (shoppingLists) index'leri", + async up() { + await ShoppingListModel.createIndexes(); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index dc8e080..dfe9f06 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -14,6 +14,7 @@ import { migration as m011 } from "./011-stock"; import { migration as m012 } from "./012-stock-counts"; import { migration as m013 } from "./013-stores-products"; import { migration as m014 } from "./014-product-prices"; +import { migration as m015 } from "./015-shopping-lists"; import type { Migration } from "./types"; /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ @@ -32,6 +33,7 @@ export const MIGRATIONS: Migration[] = [ m012, m013, m014, + m015, ]; const COLLECTION = "migrations"; diff --git a/scripts/seed/run.int.test.ts b/scripts/seed/run.int.test.ts index a3e466a..1ec90fa 100644 --- a/scripts/seed/run.int.test.ts +++ b/scripts/seed/run.int.test.ts @@ -61,6 +61,7 @@ describe("seed", () => { stores: 0, products: 0, prices: 0, + shoppingLists: 0, }); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts index 0227767..fe09f23 100644 --- a/scripts/seed/run.ts +++ b/scripts/seed/run.ts @@ -13,7 +13,7 @@ import { runMigrations } from "../migrations"; import { seedContacts } from "./contacts"; import { seedAssets } from "./assets"; import { seedBudgets } from "./budgets"; -import { seedProductPrices, seedShopping } from "./shopping"; +import { seedProductPrices, seedShopping, seedShoppingLists } from "./shopping"; import { seedStock } from "./stock"; import { seedInKind } from "./in-kind"; import { seedInstallments } from "./installments"; @@ -61,6 +61,7 @@ export type SeedSummary = { stores: number; products: number; prices: number; + shoppingLists: number; }; accounts: SeedAccountRow[]; }; @@ -91,6 +92,7 @@ const DOMAIN_COLLECTIONS = [ "stores", "products", "productPrices", + "shoppingLists", ]; type AuthApi = ReturnType["api"]; @@ -150,6 +152,7 @@ export async function runSeed({ stores: 0, products: 0, prices: 0, + shoppingLists: 0, }, accounts: [], }; @@ -341,6 +344,9 @@ export async function runSeed({ log(`✓ Örnek mağazalar ve ürünler: ${shopping.stores} / ${shopping.products}`); summary.created.prices = await seedProductPrices(householdIds, userIdMap); if (summary.created.prices) log(`✓ Örnek ürün fiyatları: ${summary.created.prices}`); + summary.created.shoppingLists = await seedShoppingLists(householdIds, userIdMap); + if (summary.created.shoppingLists) + log(`✓ Örnek alışveriş listeleri: ${summary.created.shoppingLists}`); // 7) Geliştirme hesapları tablosu const all = [ diff --git a/scripts/seed/shopping.ts b/scripts/seed/shopping.ts index 52da88f..74fcb81 100644 --- a/scripts/seed/shopping.ts +++ b/scripts/seed/shopping.ts @@ -2,6 +2,7 @@ import { Types } from "mongoose"; import { ProductModel } from "@/models/product"; import { ProductPriceModel } from "@/models/product-price"; import { ProductTypeModel } from "@/models/product-type"; +import { ShoppingListModel } from "@/models/shopping-list"; import { StoreModel } from "@/models/store"; import type { MealCardProvider } from "@/shared/accounts"; import { autoConvertible } from "@/shared/products"; @@ -492,3 +493,88 @@ export async function seedProductPrices( if (docs.length) await ProductPriceModel.collection.insertMany(docs); return docs.length; } + +type SeedListItem = + | { type: string; qty?: number } + | { product: string; qty?: number } + | { text: string; qty?: number; unit?: string; note?: string }; + +/** + * Örnek alışveriş listeleri (Faz 13). Eşiğin altındaki ürünler (mercimek, tablet deterjan, diş + * macunu) listelere eklenmez; liste sayfasında öneri olarak görünür. Domatesin fiyatı yok: + * akıllı öneride "fiyatı bilinmeyen" örneği. + */ +const SHOPPING_LISTS: { ref: string; name: string; note?: string; items: SeedListItem[] }[] = [ + { + ref: "haftalik", + name: "Haftalık market", + items: [ + { type: "sut", qty: 4 * K }, + { type: "yumurta", qty: 30 * K }, + { type: "pirinc", qty: 2_500 }, + { type: "beyaz-peynir", qty: 500 }, + { type: "domates", qty: 2 * K }, + { type: "toz-deterjan", qty: 40 * K }, + { type: "tuvalet-kagidi", qty: 32 * K }, + { product: "yumos-1440", qty: 1 * K }, + { text: "Doğum günü mumu", qty: 1 * K, unit: "paket", note: "Rakam mum: 7" }, + ], + }, + { + ref: "ay-sonu", + name: "Ay sonu toptan", + note: "Metro'ya arabayla.", + items: [{ type: "camasir-suyu", qty: 4 * K }], + }, +]; + +export async function seedShoppingLists( + householdIds: Map, + users: Map, +): Promise { + const actor = new Types.ObjectId(users.get("ayse")!); + const householdId = householdIds.get("Çakır Ailesi")!; + const [types, products] = await Promise.all([ + ProductTypeModel.find({ householdId, seedRef: { $type: "string" } }) + .select({ seedRef: 1 }) + .lean<{ _id: Types.ObjectId; seedRef: string }[]>(), + ProductModel.find({ householdId, seedRef: { $type: "string" } }) + .select({ seedRef: 1 }) + .lean<{ _id: Types.ObjectId; seedRef: string }[]>(), + ]); + const typeByRef = new Map(types.map((x) => [x.seedRef, x._id])); + const productByRef = new Map(products.map((x) => [x.seedRef, x._id])); + let created = 0; + for (const l of SHOPPING_LISTS) { + if (await ShoppingListModel.exists({ householdId, seedRef: l.ref })) continue; + const items = l.items.flatMap((i): Record[] => { + const common = { + _id: new Types.ObjectId(), + quantity: i.qty ?? null, + source: "manual", + addedBy: actor, + addedAt: new Date(), + }; + if ("type" in i) { + const id = typeByRef.get(i.type); + return id ? [{ ...common, kind: "type", productTypeId: id }] : []; + } + if ("product" in i) { + const id = productByRef.get(i.product); + return id ? [{ ...common, kind: "product", productId: id }] : []; + } + return [{ ...common, kind: "text", text: i.text, unit: i.unit ?? "", note: i.note ?? "" }]; + }); + await ShoppingListModel.create({ + householdId, + name: l.name, + note: l.note ?? "", + items, + seedRef: l.ref, + createdBy: actor, + updatedBy: actor, + }); + created++; + } + return created; +} diff --git a/scripts/seed/stock.ts b/scripts/seed/stock.ts index f215960..80bc38e 100644 --- a/scripts/seed/stock.ts +++ b/scripts/seed/stock.ts @@ -33,6 +33,8 @@ type SeedProduct = { waste?: { every: number; pct: number; reason: "spoiled" | "expired" | "damaged" }; /** Bu günden (gün önce) sonra yeniden alınmaz: bugün azalmış görünür. */ stopRefillDaysAgo?: number; + /** Ağır/hacimli (akıllı öneride taşıma cezası). */ + bulky?: boolean; /** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */ countDrift?: { everyCounts: number; pct: number }; }; @@ -184,6 +186,7 @@ export const PRODUCTS: SeedProduct[] = [ }, { ref: "toz-deterjan", + bulky: true, category: "genel-temizlik.camasir", name: "Toz Deterjan", unit: "yıkama", @@ -195,6 +198,7 @@ export const PRODUCTS: SeedProduct[] = [ }, { ref: "yumusatici", + bulky: true, category: "genel-temizlik.camasir", name: "Yumuşatıcı", unit: "lt", @@ -206,6 +210,7 @@ export const PRODUCTS: SeedProduct[] = [ }, { ref: "camasir-suyu", + bulky: true, category: "genel-temizlik.genel", name: "Çamaşır Suyu", unit: "lt", @@ -217,6 +222,7 @@ export const PRODUCTS: SeedProduct[] = [ }, { ref: "tuvalet-kagidi", + bulky: true, category: "genel-temizlik.genel", name: "Tuvalet Kağıdı", unit: "rulo", @@ -359,7 +365,15 @@ export async function seedStock( const today = todayIstanbul(); let created = 0; for (const p of PRODUCTS) { - if (await ProductTypeModel.exists({ householdId, seedRef: p.ref })) continue; + if (await ProductTypeModel.exists({ householdId, seedRef: p.ref })) { + // Faz 13 öncesi seed'lenmiş veritabanında hacimli işaretini tamamla. + if (p.bulky) + await ProductTypeModel.updateOne( + { householdId, seedRef: p.ref, bulky: { $exists: false } }, + { $set: { bulky: true } }, + ); + continue; + } const doc = await ProductTypeModel.create({ householdId, categoryId: catByKey.get(p.category)!, @@ -370,6 +384,7 @@ export async function seedStock( thresholdPct: p.threshold ?? 20, location: p.location, step: p.step ?? K, + bulky: p.bulky ?? false, seedRef: p.ref, createdBy: actor, updatedBy: actor, diff --git a/src/features/prices/service.ts b/src/features/prices/service.ts index a510386..2003a52 100644 --- a/src/features/prices/service.ts +++ b/src/features/prices/service.ts @@ -293,17 +293,35 @@ export async function getTypePriceComparison( }; } +/** Ürün tipi birimine çevrilebilen ürünlerin mağaza başına geçerli fiyatı (planlayıcı girdisi). */ +export type TypeOffer = { + productId: string; + productTypeId: string; + storeId: string; + priceMinor: number; + inTypeUnit: number; + unitPriceMinor: number; + date: string; + stale: boolean; + isPromo: boolean; +}; + /** - * Ürün tipi başına son bilinen birim fiyat (en yeni geçerli fiyat kaydı; tüm marka/mağazalar). - * Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır. + * Ürün tiplerinin tüm (arşivsiz) ürünlerinin mağaza başına geçerli fiyatları. Dönüşümü eksik + * ürünler atlanır. `activeOnly`: arşivli ürün ve mağazaları dışarıda bırak. */ -export async function latestTypeUnitPrices( +export async function loadTypeOffers( householdId: Types.ObjectId, productTypeIds: Types.ObjectId[], - today = todayIstanbul(), -): Promise> { - const [products, types] = await Promise.all([ - ProductModel.find({ householdId, productTypeId: { $in: productTypeIds } }) + { today = todayIstanbul(), activeOnly = false } = {}, +): Promise { + if (!productTypeIds.length) return []; + const [products, types, archivedStores] = await Promise.all([ + ProductModel.find({ + householdId, + productTypeId: { $in: productTypeIds }, + ...(activeOnly ? { archivedAt: null } : {}), + }) .select({ productTypeId: 1, packageQuantity: 1, packageUnit: 1, typeQuantity: 1 }) .lean< { @@ -317,7 +335,13 @@ export async function latestTypeUnitPrices( ProductTypeModel.find({ householdId, _id: { $in: productTypeIds } }) .select({ unit: 1 }) .lean<{ _id: Types.ObjectId; unit: string }[]>(), + activeOnly + ? StoreModel.find({ householdId, archivedAt: { $ne: null } }) + .select({ _id: 1 }) + .lean<{ _id: Types.ObjectId }[]>() + : Promise.resolve([]), ]); + const skipStore = new Set(archivedStores.map((x) => String(x._id))); const unitOf = new Map(types.map((x) => [String(x._id), x.unit])); const prices = await loadPrices( householdId, @@ -325,11 +349,12 @@ export async function latestTypeUnitPrices( ); const groups = new Map(); for (const r of prices) { + if (skipStore.has(String(r.storeId))) continue; const key = `${r.productId}:${r.storeId}`; groups.set(key, [...(groups.get(key) ?? []), r]); } const productById = new Map(products.map((p) => [String(p._id), p])); - const out = new Map(); + const out: TypeOffer[] = []; for (const records of groups.values()) { const p = productById.get(String(records[0]!.productId))!; const typeId = String(p.productTypeId); @@ -341,12 +366,35 @@ export async function latestTypeUnitPrices( }); if (!inType) continue; const eff = effectivePrice(records, today)!; - const prev = out.get(typeId); - if (!prev || eff.record.date > prev.date) - out.set(typeId, { - unitPriceMinor: unitPrice(eff.record.priceMinor, inType), - date: eff.record.date, - }); + out.push({ + productId: String(p._id), + productTypeId: typeId, + storeId: String(records[0]!.storeId), + priceMinor: eff.record.priceMinor, + inTypeUnit: inType, + unitPriceMinor: unitPrice(eff.record.priceMinor, inType), + date: eff.record.date, + stale: eff.stale, + isPromo: eff.record.isPromo && !eff.promoEnded, + }); + } + return out; +} + +/** + * Ürün tipi başına son bilinen birim fiyat (en yeni geçerli fiyat kaydı; tüm marka/mağazalar). + * Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır. + */ +export async function latestTypeUnitPrices( + householdId: Types.ObjectId, + productTypeIds: Types.ObjectId[], + today = todayIstanbul(), +): Promise> { + const out = new Map(); + for (const o of await loadTypeOffers(householdId, productTypeIds, { today })) { + const prev = out.get(o.productTypeId); + if (!prev || o.date > prev.date) + out.set(o.productTypeId, { unitPriceMinor: o.unitPriceMinor, date: o.date }); } return out; } diff --git a/src/features/shopping/actions.ts b/src/features/shopping/actions.ts new file mode 100644 index 0000000..12b76e2 --- /dev/null +++ b/src/features/shopping/actions.ts @@ -0,0 +1,120 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { tr } from "@/i18n/tr"; +import { fail, type ActionResult } from "@/lib/action-result"; +import { getHouseholdActionContext } from "@/lib/dal"; +import { + shoppingItemFormSchema, + shoppingItemUpdateSchema, + shoppingListFormSchema, +} from "@/shared/schemas/shopping"; +import { + addShoppingItem, + approveSuggestions, + createShoppingList, + deleteShoppingList, + dismissSuggestion, + moveShoppingItem, + removeShoppingItem, + setShoppingItemChecked, + updateShoppingItem, + updateShoppingList, +} from "./service"; + +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); + +function fieldErrors(error: z.ZodError) { + const out: Record = {}; + for (const issue of error.issues) { + const key = issue.path.join("."); + if (key && !out[key]) out[key] = issue.message; + } + return out; +} + +async function run( + schema: z.ZodType, + input: unknown, + fn: (userId: string, householdId: string, data: T) => Promise>, +): Promise> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = schema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + const result = await fn(ctx.user.id, ctx.household.id, parsed.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} + +const ids = (...keys: string[]) => z.object(Object.fromEntries(keys.map((k) => [k, idSchema]))); + +export async function saveShoppingListAction(listId: string | null, input: unknown) { + if (listId === null) + return run(shoppingListFormSchema, input, (u, h, d) => createShoppingList(u, h, d)); + const id = idSchema.safeParse(listId); + if (!id.success) return fail(tr.common.notFound); + return run(shoppingListFormSchema, input, (u, h, d) => updateShoppingList(u, h, id.data, d)); +} + +export async function deleteShoppingListAction(listId: unknown) { + const id = idSchema.safeParse(listId); + if (!id.success) return fail(tr.common.notFound); + return run(z.unknown(), null, (u, h) => deleteShoppingList(u, h, id.data)); +} + +export async function addShoppingItemAction(listId: unknown, input: unknown) { + const id = idSchema.safeParse(listId); + if (!id.success) return fail(tr.common.notFound); + return run(shoppingItemFormSchema, input, (u, h, d) => addShoppingItem(u, h, id.data, d)); +} + +export async function updateShoppingItemAction(listId: unknown, itemId: unknown, input: unknown) { + const ref = ids("listId", "itemId").safeParse({ listId, itemId }); + if (!ref.success) return fail(tr.common.notFound); + return run(shoppingItemUpdateSchema, input, (u, h, d) => + updateShoppingItem(u, h, ref.data.listId!, ref.data.itemId!, d), + ); +} + +export async function removeShoppingItemAction(listId: unknown, itemId: unknown) { + const ref = ids("listId", "itemId").safeParse({ listId, itemId }); + if (!ref.success) return fail(tr.common.notFound); + return run(z.unknown(), null, (u, h) => + removeShoppingItem(u, h, ref.data.listId!, ref.data.itemId!), + ); +} + +export async function setShoppingItemCheckedAction( + listId: unknown, + itemId: unknown, + checked: unknown, +) { + const ref = ids("listId", "itemId").safeParse({ listId, itemId }); + if (!ref.success || typeof checked !== "boolean") return fail(tr.common.notFound); + return run(z.unknown(), null, (u, h) => + setShoppingItemChecked(u, h, ref.data.listId!, ref.data.itemId!, checked), + ); +} + +export async function moveShoppingItemAction(listId: unknown, itemId: unknown, target: unknown) { + const ref = ids("listId", "itemId", "target").safeParse({ listId, itemId, target }); + if (!ref.success) return fail(tr.common.notFound); + return run(z.unknown(), null, (u, h) => + moveShoppingItem(u, h, ref.data.listId!, ref.data.itemId!, ref.data.target!), + ); +} + +export async function approveSuggestionsAction(listId: unknown, productTypeIds: unknown) { + const id = idSchema.safeParse(listId); + const types = z.array(idSchema).min(1).max(200).safeParse(productTypeIds); + if (!id.success || !types.success) return fail(tr.common.notFound); + return run(z.unknown(), null, (u, h) => approveSuggestions(u, h, id.data, types.data)); +} + +export async function dismissSuggestionAction(productTypeId: unknown) { + const id = idSchema.safeParse(productTypeId); + if (!id.success) return fail(tr.common.notFound); + return run(z.unknown(), null, (u, h) => dismissSuggestion(u, h, id.data)); +} diff --git a/src/features/shopping/service.int.test.ts b/src/features/shopping/service.int.test.ts new file mode 100644 index 0000000..1406446 --- /dev/null +++ b/src/features/shopping/service.int.test.ts @@ -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>; +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) => 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(); + }); +}); diff --git a/src/features/shopping/service.ts b/src/features/shopping/service.ts new file mode 100644 index 0000000..9e85834 --- /dev/null +++ b/src/features/shopping/service.ts @@ -0,0 +1,878 @@ +import "server-only"; +import { Types } from "mongoose"; +import { listAccounts } from "@/features/accounts/service"; +import { hasHouseholdRole } from "@/features/households/roles"; +import { resolveMembership, type Membership } from "@/features/households/service"; +import { loadTypeOffers } from "@/features/prices/service"; +import { listProducts, type ProductView } from "@/features/products/service"; +import { getStock, type ProductTypeView } from "@/features/stock/service"; +import { storeLabel } from "@/features/stores/service"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { writeAudit } from "@/lib/audit"; +import { toObjectId } from "@/lib/object-id"; +import { HouseholdModel } from "@/models/household"; +import { ProductModel } from "@/models/product"; +import { ProductTypeModel } from "@/models/product-type"; +import { ShoppingListModel } from "@/models/shopping-list"; +import { StockMovementModel } from "@/models/stock-movement"; +import { StoreModel } from "@/models/store"; +import { seriesSlot } from "@/shared/prices"; +import { formatQuantity, QUANTITY_SCALE } from "@/shared/quantity"; +import type { + ShoppingItemForm, + ShoppingItemUpdate, + ShoppingListForm, +} from "@/shared/schemas/shopping"; +import { + requiredQuantity, + suggestionHidden, + type ShoppingItemKind, + type ShoppingItemSource, + type ShoppingListStatus, +} from "@/shared/shopping"; +import { planShopping, type PlanResult } from "@/shared/shopping-plan"; +import { compareTr } from "@/shared/search"; +import { formatStock, type PurchaseSuggestion } from "@/shared/stock"; +import type { StoreKind } from "@/shared/stores"; + +/** + * Alışveriş listeleri (Bölüm 6.12). Hane içinde ortak; okuma viewer, yazma member. + * Stok eşiğinin altındaki ürün tipleri aktif listelere onayla/reddet önerisi olarak gelir. + * Akıllı mağaza önerisi saf motorla (shared/shopping-plan) okuma sırasında hesaplanır. + */ + +const t = tr.shopping; + +type ItemLean = { + _id: Types.ObjectId; + kind: ShoppingItemKind; + productTypeId: Types.ObjectId | null; + productId: Types.ObjectId | null; + text: string; + quantity: number | null; + unit: string; + note: string; + source: ShoppingItemSource; + checked: boolean; + checkedAt: Date | null; + addedAt: Date; +}; + +type ListLean = { + _id: Types.ObjectId; + name: string; + status: ShoppingListStatus; + note: string; + items: ItemLean[]; + completedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +export type ShoppingListSummary = { + id: string; + name: string; + status: ShoppingListStatus; + note: string; + itemCount: number; + checkedCount: number; + /** İlk birkaç kalemin adı (kart önizlemesi). */ + preview: string[]; + updatedAt: string; + completedAt: string | null; +}; + +export type ShoppingItemView = { + id: string; + kind: ShoppingItemKind; + productTypeId: string | null; + productId: string | null; + text: string; + label: string; + /** Ürün: paket ve ürün tipi; ürün tipi: kategori yolu. */ + detail: string; + quantity: number | null; + /** Ürün tipinde tipin birimi, üründe "paket", serbest metinde girilen birim. */ + unit: string; + /** "2 kg", "3 paket"; boş miktarda ürün tipinde "" (en az 1 paket). */ + quantityLabel: string; + note: string; + source: ShoppingItemSource; + checked: boolean; + /** Ürün tipi/ürün arşivlendi. */ + archived: boolean; + /** Ürün tipi kaleminde evdeki stok. */ + stock: { current: string; target: string; low: boolean } | null; +}; + +export type SuggestionView = { + productTypeId: string; + name: string; + categoryPath: string; + unit: string; + current: number; + target: number; + pct: number | null; + suggestedQuantity: number; + suggestion: PurchaseSuggestion; +}; + +export type PlanStoreRef = { + id: string; + label: string; + kind: StoreKind; + slot: number; + mealCard: boolean; +}; + +export type ShoppingPlanView = PlanResult & { + stores: Record; + products: Record; + thresholdMinor: number; + /** Bu kullanıcının görebildiği yemek kartı bakiyelerinin toplamı. */ + mealCardBalanceMinor: number; +}; + +export type ItemOptions = { + types: { id: string; name: string; unit: string; path: string }[]; + products: { + id: string; + label: string; + packageLabel: string; + productTypeId: string; + typeName: string; + }[]; +}; + +export type ShoppingListDetail = { + list: ShoppingListSummary; + items: ShoppingItemView[]; + suggestions: SuggestionView[]; + plan: ShoppingPlanView; + options: ItemOptions; + /** Kalem taşımak için diğer aktif listeler. */ + otherLists: { id: string; name: string }[]; + canWrite: boolean; +}; + +// --- Yardımcılar --- + +const iso = (d: Date | null) => (d ? d.toISOString() : null); + +function summary(l: ListLean, labels: Map): ShoppingListSummary { + return { + id: String(l._id), + name: l.name, + status: l.status, + note: l.note, + itemCount: l.items.length, + checkedCount: l.items.filter((i) => i.checked).length, + preview: l.items.slice(0, 4).map((i) => itemName(i, labels)), + updatedAt: l.updatedAt.toISOString(), + completedAt: iso(l.completedAt), + }; +} + +const refKey = (i: Pick) => + i.kind === "type" ? `t:${i.productTypeId}` : i.kind === "product" ? `p:${i.productId}` : null; + +function itemName(i: ItemLean, labels: Map) { + const key = refKey(i); + return key ? (labels.get(key) ?? "—") : i.text; +} + +/** Kalemlerin ürün tipi ve ürün adları (arşivliler dahil). */ +async function loadLabels(householdId: Types.ObjectId, lists: ListLean[]) { + const typeIds = new Set(); + const productIds = new Set(); + for (const l of lists) + for (const i of l.items) { + if (i.productTypeId) typeIds.add(String(i.productTypeId)); + if (i.productId) productIds.add(String(i.productId)); + } + const [types, products] = await Promise.all([ + ProductTypeModel.find({ + householdId, + _id: { $in: [...typeIds].map((x) => new Types.ObjectId(x)) }, + }) + .select({ name: 1 }) + .lean<{ _id: Types.ObjectId; name: string }[]>(), + ProductModel.find({ + householdId, + _id: { $in: [...productIds].map((x) => new Types.ObjectId(x)) }, + }) + .select({ brand: 1, name: 1 }) + .lean<{ _id: Types.ObjectId; brand: string; name: string }[]>(), + ]); + const labels = new Map(); + for (const x of types) labels.set(`t:${x._id}`, x.name); + for (const x of products) labels.set(`p:${x._id}`, x.name ? `${x.brand} ${x.name}` : x.brand); + return labels; +} + +async function loadList(m: Membership, listId: string) { + const id = toObjectId(listId); + if (!id) return null; + return ShoppingListModel.findOne({ + _id: id, + householdId: m.householdId, + deletedAt: null, + }).lean(); +} + +/** Ürün tipi başına son alım hareketinin giriş zamanı (öneri reddi için). */ +async function lastPurchaseAt(householdId: Types.ObjectId, typeIds: Types.ObjectId[]) { + if (!typeIds.length) return new Map(); + const rows = await StockMovementModel.aggregate<{ _id: Types.ObjectId; at: Date }>([ + { + $match: { + householdId, + productTypeId: { $in: typeIds }, + kind: "purchase", + deletedAt: null, + }, + }, + { $group: { _id: "$productTypeId", at: { $max: "$createdAt" } } }, + ]); + return new Map(rows.map((r) => [String(r._id), r.at])); +} + +/** + * Bekleyen eşik önerileri: eşiğin altındaki ürün tipleri; hiçbir aktif listede (alınmamış kalem + * olarak, tip ya da o tipin ürünü) yoksa ve reddedilmemişse. + */ +async function pendingSuggestions( + m: Membership, + low: ProductTypeView[], +): Promise { + if (!low.length) return []; + const lowIds = low.map((x) => new Types.ObjectId(x.id)); + const [active, dismissed, purchases] = await Promise.all([ + ShoppingListModel.find({ householdId: m.householdId, deletedAt: null, status: "active" }) + .select({ items: 1 }) + .lean<{ items: ItemLean[] }[]>(), + ProductTypeModel.find({ householdId: m.householdId, _id: { $in: lowIds } }) + .select({ suggestionDismissedAt: 1 }) + .lean<{ _id: Types.ObjectId; suggestionDismissedAt: Date | null }[]>(), + lastPurchaseAt(m.householdId, lowIds), + ]); + const listed = new Set(); + const productIds: Types.ObjectId[] = []; + for (const l of active) + for (const i of l.items) { + if (i.checked) continue; + if (i.productTypeId) listed.add(String(i.productTypeId)); + if (i.productId) productIds.push(i.productId); + } + if (productIds.length) { + const rows = await ProductModel.find({ householdId: m.householdId, _id: { $in: productIds } }) + .select({ productTypeId: 1 }) + .lean<{ productTypeId: Types.ObjectId }[]>(); + for (const r of rows) listed.add(String(r.productTypeId)); + } + const dismissedAt = new Map(dismissed.map((d) => [String(d._id), d.suggestionDismissedAt])); + return low + .filter( + (x) => + !listed.has(x.id) && + !suggestionHidden(dismissedAt.get(x.id) ?? null, purchases.get(x.id) ?? null), + ) + .map((x) => ({ + productTypeId: x.id, + name: x.name, + categoryPath: x.categoryPath, + unit: x.unit, + current: x.current, + target: x.targetQuantity, + pct: x.level.pct, + suggestedQuantity: x.suggestedQuantity, + suggestion: x.suggestion, + })); +} + +/** Eşik önerileri (dashboard ve stok sayfası özetleri için). */ +export async function getPendingSuggestions( + userId: string, + householdId: string, +): Promise { + const m = await resolveMembership(userId, householdId, "viewer"); + if (!m) return null; + const stock = await getStock(userId, householdId); + return stock ? pendingSuggestions(m, stock.suggestions) : null; +} + +// --- Okuma --- + +export async function listShoppingLists( + userId: string, + householdId: string, +): Promise<{ + active: ShoppingListSummary[]; + completed: ShoppingListSummary[]; + suggestions: number; + canWrite: boolean; +} | null> { + const m = await resolveMembership(userId, householdId, "viewer"); + if (!m) return null; + const [lists, suggestions] = await Promise.all([ + ShoppingListModel.find({ householdId: m.householdId, deletedAt: null }) + .sort({ updatedAt: -1 }) + .lean(), + getPendingSuggestions(userId, householdId), + ]); + const labels = await loadLabels(m.householdId, lists); + return { + active: lists.filter((l) => l.status === "active").map((l) => summary(l, labels)), + completed: lists + .filter((l) => l.status === "completed") + .slice(0, 20) + .map((l) => summary(l, labels)), + suggestions: suggestions?.length ?? 0, + canWrite: hasHouseholdRole(m.role, "member"), + }; +} + +/** Dashboard: aktif listelerin özeti. */ +export async function getActiveListsSummary(userId: string, householdId: string) { + const data = await listShoppingLists(userId, householdId); + if (!data) return null; + return { lists: data.active, suggestions: data.suggestions }; +} + +function itemView( + i: ItemLean, + types: Map, + products: Map, + archivedTypes: Map, +): ShoppingItemView { + const base = { + id: String(i._id), + kind: i.kind, + productTypeId: i.productTypeId ? String(i.productTypeId) : null, + productId: i.productId ? String(i.productId) : null, + text: i.text, + quantity: i.quantity, + note: i.note, + source: i.source, + checked: i.checked, + }; + if (i.kind === "type") { + const ty = types.get(String(i.productTypeId)); + const fallback = archivedTypes.get(String(i.productTypeId)); + const unit = ty?.unit ?? fallback?.unit ?? ""; + return { + ...base, + label: ty?.name ?? fallback?.name ?? "—", + detail: ty?.categoryPath ?? "", + unit, + quantityLabel: i.quantity === null ? "" : formatStock(i.quantity, unit), + archived: !ty, + stock: ty + ? { + current: formatStock(Math.max(0, ty.current), ty.unit), + target: formatStock(ty.targetQuantity, ty.unit), + low: ty.level.low, + } + : null, + }; + } + if (i.kind === "product") { + const p = products.get(String(i.productId)); + const packages = i.quantity ?? QUANTITY_SCALE; + return { + ...base, + label: p?.label ?? "—", + detail: p ? `${p.packageLabel} · ${p.productTypeName}` : "", + unit: t.packageUnit, + quantityLabel: `${formatQuantity(packages)} ${t.packageUnit}`, + archived: !p || p.archived, + stock: null, + }; + } + return { + ...base, + label: i.text, + detail: "", + unit: i.unit, + quantityLabel: i.quantity === null ? "" : formatQuantity(i.quantity, i.unit || undefined), + archived: false, + stock: null, + }; +} + +async function buildPlan( + userId: string, + m: Membership, + items: ItemLean[], + products: Map, + types: Map, +): Promise { + const planItems = items + .filter((i) => i.kind !== "text") + .map((i) => { + const p = i.productId ? products.get(String(i.productId)) : undefined; + const typeId = i.kind === "product" ? (p?.productTypeId ?? "") : String(i.productTypeId); + return { + id: String(i._id), + productTypeId: typeId, + quantity: requiredQuantity(i, p?.inTypeUnit ?? null), + productId: i.kind === "product" ? String(i.productId) : null, + bulky: types.get(typeId)?.bulky ?? false, + }; + }) + .filter((i) => i.productTypeId); + const typeIds = [...new Set(planItems.map((i) => i.productTypeId))].map( + (x) => new Types.ObjectId(x), + ); + const [offers, stores, household, accounts] = await Promise.all([ + loadTypeOffers(m.householdId, typeIds, { activeOnly: true }), + StoreModel.find({ householdId: m.householdId }).sort({ createdAt: 1, _id: 1 }).lean< + { + _id: Types.ObjectId; + name: string; + branch: string; + kind: StoreKind; + distanceM: number | null; + carryDifficulty: number; + deliveryFeeMinor: number | null; + freeDeliveryMinMinor: number | null; + mealCardAccepted: boolean; + mealCardProviders: string[]; + archivedAt: Date | null; + }[] + >(), + HouseholdModel.findById(m.householdId).select({ settings: 1 }).lean(), + listAccounts(userId, String(m.householdId)), + ]); + const mealCards = (accounts?.accounts ?? []) + .filter( + (a) => + a.type === "meal_card" && + !a.archived && + a.mealCardProvider && + a.balanceMinor !== null && + a.balanceMinor > 0, + ) + .map((a) => ({ provider: a.mealCardProvider!, balanceMinor: a.balanceMinor! })); + const active = stores.filter((s) => !s.archivedAt); + const threshold = household?.settings?.extraStoreSavingThresholdMinor ?? 10_000; + const result = planShopping({ + items: planItems, + offers, + stores: active.map((s) => ({ + id: String(s._id), + kind: s.kind, + distanceM: s.distanceM, + carryDifficulty: s.carryDifficulty, + deliveryFeeMinor: s.deliveryFeeMinor, + freeDeliveryMinMinor: s.freeDeliveryMinMinor, + mealCardProviders: s.mealCardAccepted ? s.mealCardProviders : [], + })), + mealCards, + extraStoreThresholdMinor: threshold, + }); + // Planda geçen ürünlerin etiketleri (listede olmayan muadil markalar dahil). + const used = new Set(); + for (const o of [result.single, result.recommended]) + for (const g of o?.stores ?? []) for (const l of g.lines) used.add(l.productId); + for (const l of Object.values(result.cheapest)) used.add(l.productId); + const missing = [...used].filter((id) => !products.has(id)); + if (missing.length) { + const extra = await listProducts(userId, String(m.householdId), { includeArchived: true }); + for (const p of extra?.products ?? []) products.set(p.id, p); + } + return { + ...result, + stores: Object.fromEntries( + stores.map((s, i) => [ + String(s._id), + { + id: String(s._id), + label: storeLabel(s), + kind: s.kind, + slot: seriesSlot(i), + mealCard: s.mealCardAccepted, + }, + ]), + ), + products: Object.fromEntries( + [...used].flatMap((id) => { + const p = products.get(id); + return p + ? [[id, { label: p.label, packageLabel: p.packageLabel, typeUnit: p.typeUnit }]] + : []; + }), + ), + thresholdMinor: threshold, + mealCardBalanceMinor: mealCards.reduce((a, c) => a + c.balanceMinor, 0), + }; +} + +export async function getShoppingList( + userId: string, + householdId: string, + listId: string, +): Promise { + const m = await resolveMembership(userId, householdId, "viewer"); + if (!m) return null; + const list = await loadList(m, listId); + if (!list) return null; + const [stock, productList, others, labels] = await Promise.all([ + getStock(userId, householdId), + listProducts(userId, householdId, { includeArchived: true }), + ShoppingListModel.find({ + householdId: m.householdId, + deletedAt: null, + status: "active", + _id: { $ne: list._id }, + }) + .select({ name: 1 }) + .sort({ updatedAt: -1 }) + .lean<{ _id: Types.ObjectId; name: string }[]>(), + loadLabels(m.householdId, [list]), + ]); + if (!stock || !productList) return null; + const types = new Map(stock.items.map((x) => [x.id, x])); + const products = new Map(productList.products.map((p) => [p.id, p])); + // Arşivlenmiş ürün tiplerinin adı/birimi (getStock yalnızca aktifleri döner). + const archivedTypeIds = list.items + .filter((i) => i.productTypeId && !types.has(String(i.productTypeId))) + .map((i) => i.productTypeId!); + const archivedTypes = new Map( + ( + await ProductTypeModel.find({ householdId: m.householdId, _id: { $in: archivedTypeIds } }) + .select({ name: 1, unit: 1 }) + .lean<{ _id: Types.ObjectId; name: string; unit: string }[]>() + ).map((x) => [String(x._id), { name: x.name, unit: x.unit }]), + ); + const items = list.items + .map((i) => itemView(i, types, products, archivedTypes)) + .sort((a, b) => Number(a.checked) - Number(b.checked)); + const isActive = list.status === "active"; + const canWrite = hasHouseholdRole(m.role, "member") && isActive; + const [suggestions, plan] = await Promise.all([ + isActive ? pendingSuggestions(m, stock.suggestions) : Promise.resolve([]), + buildPlan(userId, m, list.items, products, types), + ]); + return { + list: summary(list, labels), + items, + suggestions, + plan, + options: { + types: stock.items + .map((x) => ({ id: x.id, name: x.name, unit: x.unit, path: x.categoryPath })) + .sort((a, b) => compareTr(a.name, b.name)), + products: productList.products + .filter((p) => !p.archived && !p.conversionMissing && types.has(p.productTypeId)) + .map((p) => ({ + id: p.id, + label: p.label, + packageLabel: p.packageLabel, + productTypeId: p.productTypeId, + typeName: p.productTypeName, + })), + }, + otherLists: others.map((o) => ({ id: String(o._id), name: o.name })), + canWrite, + }; +} + +// --- Yazma: listeler --- + +async function writableList(actorId: string, householdId: string, listId: string) { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return { error: fail(tr.common.unauthorized) } as const; + const list = await loadList(m, listId); + if (!list) return { error: fail(tr.common.notFound) } as const; + if (list.status !== "active") return { error: fail(t.validation.notActive) } as const; + return { error: null, m, list, actor: new Types.ObjectId(actorId) } as const; +} + +export async function createShoppingList( + actorId: string, + householdId: string, + input: ShoppingListForm, +): Promise> { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const actor = new Types.ObjectId(actorId); + const doc = await ShoppingListModel.create({ + householdId: m.householdId, + name: input.name, + note: input.note, + createdBy: actor, + updatedBy: actor, + }); + return ok({ id: String(doc._id) }); +} + +export async function updateShoppingList( + actorId: string, + householdId: string, + listId: string, + input: ShoppingListForm, +): Promise { + const r = await writableList(actorId, householdId, listId); + if (r.error) return r.error; + await ShoppingListModel.updateOne( + { _id: r.list._id }, + { $set: { name: input.name, note: input.note, updatedBy: r.actor } }, + ); + return ok(undefined); +} + +/** Liste silme (soft delete, audit). Tamamlanmış liste de silinebilir. */ +export async function deleteShoppingList( + actorId: string, + householdId: string, + listId: string, +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const list = await loadList(m, listId); + if (!list) return fail(tr.common.notFound); + const actor = new Types.ObjectId(actorId); + await ShoppingListModel.updateOne( + { _id: list._id }, + { $set: { deletedAt: new Date(), updatedBy: actor } }, + ); + await writeAudit({ + action: "shoppingList.delete", + actorId: actor, + householdId: m.householdId, + targetType: "shoppingList", + targetId: listId, + diff: { name: list.name, status: list.status, items: list.items.length }, + }); + return ok(undefined); +} + +// --- Yazma: kalemler --- + +/** Ürün tipi / ürün aktif mi; aynı kalem listede alınmamış olarak var mı? */ +async function checkItemRef( + m: Membership, + list: ListLean, + input: Pick, +): Promise | null> { + if (input.kind === "type") { + const ok = await ProductTypeModel.exists({ + _id: new Types.ObjectId(input.productTypeId!), + householdId: m.householdId, + archivedAt: null, + }); + if (!ok) return fail(t.validation.productType, { productTypeId: t.validation.productType }); + } + if (input.kind === "product") { + const ok = await ProductModel.exists({ + _id: new Types.ObjectId(input.productId!), + householdId: m.householdId, + archivedAt: null, + }); + if (!ok) return fail(t.validation.product, { productId: t.validation.product }); + } + const key = refKey({ + kind: input.kind, + productTypeId: input.productTypeId ? new Types.ObjectId(input.productTypeId) : null, + productId: input.productId ? new Types.ObjectId(input.productId) : null, + }); + if (key && list.items.some((i) => !i.checked && refKey(i) === key)) + return fail(t.validation.duplicate); + return null; +} + +export async function addShoppingItem( + actorId: string, + householdId: string, + listId: string, + input: ShoppingItemForm, + source: ShoppingItemSource = "manual", +): Promise> { + const r = await writableList(actorId, householdId, listId); + if (r.error) return r.error; + const bad = await checkItemRef(r.m, r.list, input); + if (bad) return bad; + const _id = new Types.ObjectId(); + await ShoppingListModel.updateOne( + { _id: r.list._id }, + { + $push: { + items: { + _id, + kind: input.kind, + productTypeId: input.productTypeId ? new Types.ObjectId(input.productTypeId) : null, + productId: input.productId ? new Types.ObjectId(input.productId) : null, + text: input.text, + quantity: input.quantity, + unit: input.unit, + note: input.note, + source, + addedBy: r.actor, + addedAt: new Date(), + }, + }, + $set: { updatedBy: r.actor }, + }, + ); + return ok({ id: String(_id) }); +} + +export async function updateShoppingItem( + actorId: string, + householdId: string, + listId: string, + itemId: string, + input: ShoppingItemUpdate, +): Promise { + const r = await writableList(actorId, householdId, listId); + if (r.error) return r.error; + const item = r.list.items.find((i) => String(i._id) === itemId); + if (!item) return fail(tr.common.notFound); + if (item.kind === "text" && !input.text) + return fail(t.validation.text, { text: t.validation.text }); + const set: Record = { + "items.$.quantity": input.quantity, + "items.$.note": input.note, + updatedBy: r.actor, + }; + if (item.kind === "text") { + set["items.$.text"] = input.text; + set["items.$.unit"] = input.unit; + } + await ShoppingListModel.updateOne({ _id: r.list._id, "items._id": item._id }, { $set: set }); + return ok(undefined); +} + +export async function removeShoppingItem( + actorId: string, + householdId: string, + listId: string, + itemId: string, +): Promise { + const r = await writableList(actorId, householdId, listId); + if (r.error) return r.error; + const id = toObjectId(itemId); + if (!id || !r.list.items.some((i) => i._id.equals(id))) return fail(tr.common.notFound); + await ShoppingListModel.updateOne( + { _id: r.list._id }, + { $pull: { items: { _id: id } }, $set: { updatedBy: r.actor } }, + ); + return ok(undefined); +} + +/** Alışveriş modunda sepete alındı / geri alındı. */ +export async function setShoppingItemChecked( + actorId: string, + householdId: string, + listId: string, + itemId: string, + checked: boolean, +): Promise { + const r = await writableList(actorId, householdId, listId); + if (r.error) return r.error; + const id = toObjectId(itemId); + if (!id) return fail(tr.common.notFound); + const res = await ShoppingListModel.updateOne( + { _id: r.list._id, "items._id": id }, + { + $set: { + "items.$.checked": checked, + "items.$.checkedBy": checked ? r.actor : null, + "items.$.checkedAt": checked ? new Date() : null, + }, + }, + ); + return res.matchedCount ? ok(undefined) : fail(tr.common.notFound); +} + +/** Kalemi başka bir aktif listeye taşır. */ +export async function moveShoppingItem( + actorId: string, + householdId: string, + listId: string, + itemId: string, + targetListId: string, +): Promise { + const r = await writableList(actorId, householdId, listId); + if (r.error) return r.error; + const target = await writableList(actorId, householdId, targetListId); + if (target.error) return target.error; + if (target.list._id.equals(r.list._id)) return fail(t.validation.sameList); + const item = r.list.items.find((i) => String(i._id) === itemId); + if (!item) return fail(tr.common.notFound); + const key = refKey(item); + if (key && target.list.items.some((i) => !i.checked && refKey(i) === key)) + return fail(t.validation.duplicateIn(target.list.name)); + await ShoppingListModel.updateOne( + { _id: target.list._id }, + { + $push: { items: { ...item, checked: false, checkedBy: null, checkedAt: null } }, + $set: { updatedBy: r.actor }, + }, + ); + await ShoppingListModel.updateOne( + { _id: r.list._id }, + { $pull: { items: { _id: item._id } }, $set: { updatedBy: r.actor } }, + ); + return ok(undefined); +} + +// --- Eşik önerileri --- + +/** Önerileri onayla: önerilen miktarla ürün tipi kalemi olarak eklenir. */ +export async function approveSuggestions( + actorId: string, + householdId: string, + listId: string, + productTypeIds: string[], +): Promise> { + const r = await writableList(actorId, householdId, listId); + if (r.error) return r.error; + const stock = await getStock(actorId, householdId); + if (!stock) return fail(tr.common.unauthorized); + const pending = new Map( + (await pendingSuggestions(r.m, stock.suggestions)).map((x) => [x.productTypeId, x]), + ); + const items = productTypeIds + .map((id) => pending.get(id)) + .filter((x): x is SuggestionView => Boolean(x)) + .map((x) => ({ + _id: new Types.ObjectId(), + kind: "type" as const, + productTypeId: new Types.ObjectId(x.productTypeId), + productId: null, + text: "", + quantity: x.suggestedQuantity > 0 ? x.suggestedQuantity : null, + unit: "", + note: "", + source: "suggestion" as const, + addedBy: r.actor, + addedAt: new Date(), + })); + if (!items.length) return fail(t.validation.suggestionGone); + await ShoppingListModel.updateOne( + { _id: r.list._id }, + { $push: { items: { $each: items } }, $set: { updatedBy: r.actor } }, + ); + return ok({ added: items.length }); +} + +/** Öneriyi reddet: stok yenilenene (yeni alım) kadar gösterilmez. */ +export async function dismissSuggestion( + actorId: string, + householdId: string, + productTypeId: string, +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const id = toObjectId(productTypeId); + const res = id + ? await ProductTypeModel.updateOne( + { _id: id, householdId: m.householdId, archivedAt: null }, + { $set: { suggestionDismissedAt: new Date() } }, + ) + : null; + return res?.matchedCount ? ok(undefined) : fail(tr.common.notFound); +} diff --git a/src/features/stock/service.ts b/src/features/stock/service.ts index b31b546..c26b15c 100644 --- a/src/features/stock/service.ts +++ b/src/features/stock/service.ts @@ -87,6 +87,7 @@ type ProductLean = { step: number; countEveryDays: number | null; note: string; + bulky?: boolean; archivedAt: Date | null; createdAt: Date; }; @@ -132,6 +133,7 @@ export type ProductTypeView = { step: number; countEveryDays: number | null; note: string; + bulky: boolean; archived: boolean; current: number; level: StockLevel; @@ -331,6 +333,7 @@ function toView(p: ProductLean, moves: MoveLean[], ctx: ViewContext): ProductTyp step: p.step, countEveryDays: p.countEveryDays, note: p.note, + bulky: Boolean(p.bulky), archived: Boolean(p.archivedAt), current, level, @@ -807,6 +810,7 @@ const productFields = (input: ProductTypeForm) => ({ step: input.step, countEveryDays: input.countEveryDays, note: input.note, + bulky: input.bulky, }); export async function createProductType( diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index c4def65..563b498 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -1012,6 +1012,7 @@ export const tr = { title: "Alınması önerilenler", description: "Kritik eşiğin altına düşen ürünler ve hedefe tamamlamak için gereken miktar.", empty: "Eşiğin altına düşen ürün yok.", + toShopping: "Alışveriş listelerinde onayla veya reddet", suggested: (q: string) => `Öneri: ${q}`, wasteReduced: (purchases: number, pct: number) => `Son ${purchases} alımda %${pct} fire, miktar azaltıldı.`, @@ -1055,6 +1056,9 @@ export const tr = { stepHint: "Hızlı azaltma düğmesinin düşeceği miktar.", countEveryDays: "Sayım sıklığı (gün)", countEveryDaysHint: "Boş bırakılırsa kategoriden devralınır.", + bulky: "Ağır / hacimli", + bulkyHint: + "Su, deterjan gibi taşıması zor ürünler. Akıllı öneri bunları yakın ya da online mağazaya yönlendirir.", note: "Not", initialQuantity: "Evdeki mevcut miktar", initialHint: "Tahmini veya ambalaj üzerinden. Boş bırakılırsa 0 kabul edilir.", @@ -1352,6 +1356,149 @@ export const tr = { archived: "Ürün arşivli.", }, }, + shopping: { + title: "Alışveriş listeleri", + description: + "Aile üyeleri aynı listeleri kullanır. Stokta azalan ürünler öneri olarak gelir; akıllı öneri en uygun mağaza ve markayı seçer.", + readOnly: "Görüntüleme yetkiniz var; listeleri değiştiremezsiniz.", + newList: "Yeni liste", + createTitle: "Yeni alışveriş listesi", + editTitle: "Listeyi düzenle", + active: "Aktif listeler", + completed: "Tamamlanan alışverişler", + emptyActive: "Aktif liste yok. Yeni bir liste açın.", + itemCount: (n: number) => `${n} kalem`, + checkedCount: (done: number, total: number) => `${done}/${total} alındı`, + updated: (when: string) => `Güncellendi: ${when}`, + completedAt: (when: string) => `Tamamlandı: ${when}`, + emptyList: "Liste boş.", + suggestionsBanner: (n: number) => + `Stokta eşiğin altına düşen ${n} ürün var. Bir listeyi açıp öneriyi onaylayın ya da reddedin.`, + fields: { + name: "Liste adı", + namePlaceholder: "ör. Haftalık market, Ay başı toptan", + note: "Not", + kind: "Kalem türü", + productType: "Ürün tipi", + product: "Ürün (marka/paket)", + text: "Kalem", + textPlaceholder: "ör. Doğum günü mumu", + quantity: "Miktar", + packages: "Paket adedi", + quantityHint: "Boş bırakılırsa en az bir paket.", + unit: "Birim", + unitPlaceholder: "ör. adet", + itemNote: "Not", + itemNotePlaceholder: "ör. laktozsuz olsun", + }, + kinds: { type: "Ürün tipi", product: "Belirli marka", text: "Serbest metin" }, + kindHints: { + type: "Markayı akıllı öneri seçer.", + product: "Yalnızca bu ürün önerilir.", + text: "Stokla ve fiyatlarla bağlanmaz.", + }, + packageUnit: "paket", + stockLine: (current: string, target: string) => `Evde ${current} / hedef ${target}`, + fromSuggestion: "Stok önerisi", + archivedRef: "Arşivlendi", + actions: { + more: "Diğer işlemler", + addItem: "Kalem ekle", + edit: "Düzenle", + editList: "Listeyi düzenle", + deleteList: "Listeyi sil", + remove: "Listeden çıkar", + moveTo: "Başka listeye taşı", + goShopping: "Alışverişe çık", + back: "Alışveriş listeleri", + backToList: "Listeye dön", + }, + editItemTitle: "Kalemi düzenle", + addItemTitle: "Kalem ekle", + moveTitle: "Başka listeye taşı", + moveDescription: (name: string) => `"${name}" seçilen aktif listeye taşınır.`, + moveTarget: "Hedef liste", + noOtherLists: "Taşınabilecek başka aktif liste yok.", + deleteTitle: "Liste silinsin mi?", + deleteDescription: (name: string) => `"${name}" listesi ve kalemleri silinir.`, + suggestions: { + title: "Stok önerileri", + description: "Eşiğin altına düşen ürünler. Onaylananlar önerilen miktarla listeye eklenir.", + addAll: "Tümünü ekle", + add: "Ekle", + dismiss: "Reddet", + dismissHint: "Stok yenilenene (yeni alım girilene) kadar önerilmez.", + line: (current: string, target: string, pct: number | null) => + `Evde ${current} / hedef ${target}${pct === null ? "" : ` (%${pct})`}`, + suggested: (q: string) => `Önerilen: ${q}`, + }, + plan: { + title: "Akıllı mağaza önerisi", + description: + "Son bilinen fiyatlarla. Uzaklık ve taşıma güçlüğü, online teslimat ücreti ve yemek kartı bakiyesi hesaba katılır.", + recommended: "Önerilen dağılım", + single: "Tek mağaza", + singleBest: (store: string, total: string) => `Tek mağaza en iyi seçenek: ${store}, ${total}`, + split: (parts: string, total: string) => `Önerilen dağılım: ${parts}: ${total}`, + part: (store: string, n: number) => `${store} (${n} ürün)`, + saving: (amount: string) => `${amount} tasarruf`, + singleMissing: (n: number) => `Tek mağazada ${n} kalem bulunmuyor.`, + threshold: (amount: string) => + `Ek mağaza ancak ${amount} üstü tasarruf sağlarsa önerilir (hane ayarı).`, + mealCard: (amount: string) => `Yemek kartıyla ödenebilir: ${amount}`, + mealCardBalance: (amount: string) => `Yemek kartı bakiyeniz: ${amount}`, + delivery: (amount: string) => `Teslimat ${amount}`, + freeDelivery: "Teslimat ücretsiz", + subtotal: "Ara toplam", + total: "Tahmini toplam", + line: (packages: string, price: string) => `${packages} × ${price}`, + unpriced: "Fiyatı bilinmeyenler", + unpricedHint: "Bu kalemler için mağaza fiyatı girilmemiş.", + cheapestAt: (store: string, unitPrice: string) => + `En ucuz birim fiyat: ${store}, ${unitPrice}`, + empty: "Öneri için listede fiyatı bilinen ürün yok.", + textItems: "Serbest metin kalemleri öneriye katılmaz.", + missing: "Bu planda alınamayan", + }, + mode: { + title: "Alışveriş modu", + progress: (done: number, total: number) => `${done}/${total} sepette`, + other: "Diğer", + otherHint: "Fiyatı bilinmeyen ve serbest metin kalemleri", + inCart: "Sepette", + allDone: "Hepsi sepette.", + planChoice: "Plan", + check: (label: string) => `${label}: sepete alındı olarak işaretle`, + refreshed: "Liste güncellendi", + }, + toast: { + saved: "Liste kaydedildi.", + deleted: "Liste silindi.", + itemAdded: "Kalem eklendi.", + itemSaved: "Kalem güncellendi.", + itemRemoved: "Kalem listeden çıkarıldı.", + moved: "Kalem taşındı.", + suggestionsAdded: (n: number) => `${n} öneri listeye eklendi.`, + dismissed: "Öneri reddedildi.", + }, + validation: { + quantity: "Geçerli bir miktar girin (ör. 2 veya 1,5)", + productType: "Aktif bir ürün tipi seçin", + product: "Aktif bir ürün seçin", + text: "Kalem adını yazın", + duplicate: "Bu ürün listede zaten var; miktarını düzenleyin.", + duplicateIn: (name: string) => `Bu ürün "${name}" listesinde zaten var.`, + notActive: "Bu liste tamamlandı; değiştirilemez.", + sameList: "Kalem zaten bu listede.", + suggestionGone: "Öneri artık geçerli değil (listede ya da stok yenilenmiş).", + }, + dashboard: { + title: "Alışveriş", + empty: "Aktif liste yok.", + open: "Listeleri aç", + suggestions: (n: number) => `${n} stok önerisi bekliyor`, + }, + }, productPrices: { add: "Fiyat gir", addTitle: "Fiyat gir", diff --git a/src/models/audit-log.ts b/src/models/audit-log.ts index f7f3336..16fe27c 100644 --- a/src/models/audit-log.ts +++ b/src/models/audit-log.ts @@ -45,6 +45,7 @@ export const AUDIT_ACTIONS = [ "product.archive", "product.unarchive", "price.delete", + "shoppingList.delete", ] as const; export type AuditAction = (typeof AUDIT_ACTIONS)[number]; diff --git a/src/models/product-type.ts b/src/models/product-type.ts index 18bb305..9300e10 100644 --- a/src/models/product-type.ts +++ b/src/models/product-type.ts @@ -22,6 +22,13 @@ const productTypeSchema = new Schema( /** Sayım sıklığı (gün); null ise kategoriden devralınır (Faz 11b). */ countEveryDays: { type: Number, default: null, min: 1, max: 365 }, note: { type: String, default: "", maxlength: 300 }, + /** Ağır/hacimli (su, deterjan): akıllı öneri zahmetli fiziksel mağazadan uzak tutar (Faz 13). */ + bulky: { type: Boolean, default: false }, + /** + * Eşik önerisi reddedildi: bu tarihten sonra alım hareketi gelene (stok yenilenene) kadar + * öneri gösterilmez (Faz 13). + */ + suggestionDismissedAt: { type: Date, default: null }, archivedAt: { type: Date, default: null }, seedRef: { type: String, default: null }, createdBy: { type: Schema.Types.ObjectId, required: true }, diff --git a/src/models/shopping-list.ts b/src/models/shopping-list.ts new file mode 100644 index 0000000..d842d30 --- /dev/null +++ b/src/models/shopping-list.ts @@ -0,0 +1,61 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; +import { + SHOPPING_ITEM_KINDS, + SHOPPING_ITEM_SOURCES, + SHOPPING_LIST_STATUSES, +} from "@/shared/shopping"; + +/** + * Alışveriş kalemi (Bölüm 6.12): ürün tipi (genel), belirli ürün (marka/paket) ya da serbest metin. + * Miktar binde bir: ürün tipinde tipin biriminde, üründe paket adedi, serbest metinde `unit` ile. + * `checked`: alışveriş modunda sepete alındı. + */ +const shoppingItemSchema = new Schema( + { + kind: { type: String, enum: SHOPPING_ITEM_KINDS, required: true }, + productTypeId: { type: Schema.Types.ObjectId, default: null }, + productId: { type: Schema.Types.ObjectId, default: null }, + text: { type: String, default: "", trim: true, maxlength: 120 }, + /** Boşsa "1 paket" kabul edilir. */ + quantity: { type: Number, default: null, min: 0 }, + /** Serbest metin kaleminin birimi. */ + unit: { type: String, default: "", trim: true, maxlength: 20 }, + note: { type: String, default: "", trim: true, maxlength: 200 }, + source: { type: String, enum: SHOPPING_ITEM_SOURCES, default: "manual" }, + checked: { type: Boolean, default: false }, + checkedBy: { type: Schema.Types.ObjectId, default: null }, + checkedAt: { type: Date, default: null }, + addedBy: { type: Schema.Types.ObjectId, required: true }, + addedAt: { type: Date, default: () => new Date() }, + }, + { _id: true }, +); + +/** Alışveriş listesi; hane içinde ortak. Birden çok aktif liste olabilir. Silme: soft delete. */ +const shoppingListSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + name: { type: String, required: true, trim: true, maxlength: 80 }, + status: { type: String, enum: SHOPPING_LIST_STATUSES, default: "active" }, + note: { type: String, default: "", trim: true, maxlength: 300 }, + items: { type: [shoppingItemSchema], default: [] }, + completedAt: { type: Date, default: null }, + deletedAt: { type: Date, default: null }, + seedRef: { type: String, default: null }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + updatedBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "shoppingLists" }, +); + +shoppingListSchema.index({ householdId: 1, deletedAt: 1, status: 1, updatedAt: -1 }); +shoppingListSchema.index( + { householdId: 1, seedRef: 1 }, + { unique: true, partialFilterExpression: { seedRef: { $type: "string" } } }, +); + +export type ShoppingList = InferSchemaType; + +export const ShoppingListModel: Model = + (models.ShoppingList as Model | undefined) ?? + model("ShoppingList", shoppingListSchema); diff --git a/src/shared/schemas/shopping.ts b/src/shared/schemas/shopping.ts new file mode 100644 index 0000000..d0ca59f --- /dev/null +++ b/src/shared/schemas/shopping.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; +import { tr } from "../../i18n/tr"; +import { parseQuantity } from "../quantity"; + +const s = tr.shopping.validation; +const id = z.string().regex(/^[a-f0-9]{24}$/i); +const optionalId = z + .string() + .regex(/^([a-f0-9]{24})?$/i) + .transform((x) => (x === "" ? null : x)); + +/** Boş → null (1 paket / belirtilmemiş); dolu → binde bir pozitif tamsayı. */ +const optionalQuantity = z.preprocess( + (x) => (x === undefined || x === null || String(x).trim() === "" ? null : x), + z + .string() + .trim() + .transform((x, ctx) => { + const q = parseQuantity(x); + if (q === null || q <= 0) { + ctx.addIssue({ code: "custom", message: s.quantity }); + return z.NEVER; + } + return q; + }) + .nullable(), +); + +export const shoppingListFormSchema = z.object({ + name: z.string().trim().min(1, tr.common.required).max(80, tr.validation.nameMax), + note: z.string().trim().max(300).default(""), +}); +export type ShoppingListFormInput = z.input; +export type ShoppingListForm = z.output; + +/** Kalem: ürün tipi, belirli ürün (marka/paket) ya da serbest metin. */ +export const shoppingItemFormSchema = z + .object({ + kind: z.enum(["type", "product", "text"]), + productTypeId: optionalId.default(""), + productId: optionalId.default(""), + text: z.string().trim().max(120).default(""), + quantity: optionalQuantity.default(null), + unit: z.string().trim().max(20).default(""), + note: z.string().trim().max(200).default(""), + }) + .superRefine((x, ctx) => { + if (x.kind === "type" && !x.productTypeId) + ctx.addIssue({ code: "custom", path: ["productTypeId"], message: s.productType }); + if (x.kind === "product" && !x.productId) + ctx.addIssue({ code: "custom", path: ["productId"], message: s.product }); + if (x.kind === "text" && !x.text) + ctx.addIssue({ code: "custom", path: ["text"], message: s.text }); + }) + .transform((x) => ({ + ...x, + productTypeId: x.kind === "type" ? x.productTypeId : null, + productId: x.kind === "product" ? x.productId : null, + text: x.kind === "text" ? x.text : "", + unit: x.kind === "text" ? x.unit : "", + })); +export type ShoppingItemFormInput = z.input; +export type ShoppingItemForm = z.output; + +/** Kalem düzenleme: miktar ve not (serbest metinde metin ve birim de). */ +export const shoppingItemUpdateSchema = z.object({ + quantity: optionalQuantity.default(null), + text: z.string().trim().max(120).default(""), + unit: z.string().trim().max(20).default(""), + note: z.string().trim().max(200).default(""), +}); +export type ShoppingItemUpdate = z.output; + +export const shoppingItemRefSchema = z.object({ listId: id, itemId: id }); diff --git a/src/shared/schemas/stock.ts b/src/shared/schemas/stock.ts index 440baa9..5aa12a5 100644 --- a/src/shared/schemas/stock.ts +++ b/src/shared/schemas/stock.ts @@ -77,6 +77,8 @@ export const productTypeFormSchema = z.object({ step: quantity(), countEveryDays, note: z.string().trim().max(300).default(""), + /** Ağır/hacimli: akıllı mağaza önerisinde taşıma cezası. */ + bulky: z.boolean().default(false), /** Yalnızca yeni ürün tipinde: evdeki başlangıç miktarı. */ initialQuantity: optionalQuantity.default(null), }); diff --git a/src/shared/shopping-plan.test.ts b/src/shared/shopping-plan.test.ts new file mode 100644 index 0000000..f3c1441 --- /dev/null +++ b/src/shared/shopping-plan.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from "vitest"; +import { + BULKY_PENALTY_PER_POINT, + planShopping, + VISIT_PENALTY_PER_POINT, + type PlanInput, + type PlanItem, + type PlanOffer, + type PlanStore, +} from "./shopping-plan"; +import { effortScore } from "./stores"; + +const store = (id: string, over: Partial = {}): PlanStore => ({ + id, + kind: "market", + distanceM: 0, + carryDifficulty: 1, + deliveryFeeMinor: null, + freeDeliveryMinMinor: null, + mealCardProviders: [], + ...over, +}); + +const item = ( + id: string, + type: string, + quantity = 1000, + over: Partial = {}, +): PlanItem => ({ + id, + productTypeId: type, + quantity, + productId: null, + bulky: false, + ...over, +}); + +const offer = ( + storeId: string, + type: string, + priceMinor: number, + over: Partial = {}, +): PlanOffer => ({ + productId: `${type}-p`, + productTypeId: type, + storeId, + priceMinor, + inTypeUnit: 1000, + ...over, +}); + +const base = (over: Partial): PlanInput => ({ + items: [], + offers: [], + stores: [], + mealCards: [], + extraStoreThresholdMinor: 10_000, + ...over, +}); + +const storesOf = (o: { stores: { storeId: string }[] } | null) => o?.stores.map((s) => s.storeId); + +describe("planShopping", () => { + it("fiyatı olmayan kalemler ayrılır; hiç fiyat yoksa plan yok", () => { + const r = planShopping(base({ items: [item("i1", "t1")], stores: [store("A")] })); + expect(r.recommended).toBeNull(); + expect(r.single).toBeNull(); + expect(r.unpriced).toEqual(["i1"]); + }); + + it("paket sayısı ihtiyaca göre yukarı yuvarlanır; mağazada en az harcamalı paket seçilir", () => { + const r = planShopping( + base({ + items: [item("i1", "t1", 2500)], + stores: [store("A")], + offers: [ + offer("A", "t1", 3000, { productId: "small", inTypeUnit: 1000 }), + // Birim fiyatı daha ucuz ama 5 birimlik paket: harcama 12.000 > 3 × 3.000. + offer("A", "t1", 12_000, { productId: "big", inTypeUnit: 5000 }), + ], + }), + ); + const line = r.recommended!.stores[0]!.lines[0]!; + expect(line).toMatchObject({ productId: "small", packages: 3, costMinor: 9000 }); + // En ucuz birim fiyat bilgisi büyük paketi gösterir. + expect(r.cheapest.i1!.productId).toBe("big"); + }); + + it("belirli marka istenen kalemde yalnızca o ürünün fiyatı kullanılır", () => { + const r = planShopping( + base({ + items: [item("i1", "t1", 1000, { productId: "ariel" })], + stores: [store("A"), store("B")], + offers: [ + offer("A", "t1", 5000, { productId: "ariel" }), + offer("B", "t1", 3000, { productId: "omo" }), + ], + }), + ); + expect(storesOf(r.recommended)).toEqual(["A"]); + expect(r.recommended!.totalMinor).toBe(5000); + }); + + it("tasarruf eşiği aşılmazsa tek mağazada toplar", () => { + const r = planShopping( + base({ + items: [item("i1", "t1"), item("i2", "t2")], + stores: [store("A"), store("B")], + offers: [ + offer("A", "t1", 10_000), + offer("A", "t2", 10_000), + offer("B", "t1", 9_000), + offer("B", "t2", 10_500), + ], + extraStoreThresholdMinor: 10_000, + }), + ); + // A: 200 TL, B: 195 TL tek başına; ikisi birden 190 TL (5 TL fark < 100 TL eşik). + expect(storesOf(r.single)).toEqual(["B"]); + expect(storesOf(r.recommended)).toEqual(["B"]); + expect(r.savingMinor).toBe(0); + }); + + it("tasarruf eşiği aşılırsa ek mağaza önerilir", () => { + const r = planShopping( + base({ + items: [item("i1", "t1"), item("i2", "t2"), item("i3", "t3")], + stores: [store("A"), store("B")], + offers: [ + offer("A", "t1", 50_000), + offer("A", "t2", 50_000), + offer("A", "t3", 50_000), + offer("B", "t1", 30_000), + offer("B", "t2", 60_000), + offer("B", "t3", 60_000), + ], + extraStoreThresholdMinor: 10_000, + }), + ); + expect(storesOf(r.single)).toEqual(["A"]); + expect(r.recommended!.stores.map((s) => [s.storeId, s.lines.length])).toEqual([ + ["A", 2], + ["B", 1], + ]); + expect(r.recommended!.totalMinor).toBe(130_000); + expect(r.savingMinor).toBe(20_000); + }); + + it("tek mağaza her şeyi karşılamıyorsa en az mağazalı eksiksiz plan önerilir", () => { + const r = planShopping( + base({ + items: [item("i1", "t1"), item("i2", "t2")], + stores: [store("A"), store("B")], + offers: [offer("A", "t1", 1000), offer("B", "t2", 1000)], + }), + ); + expect(r.single!.missing).toHaveLength(1); + expect(r.recommended!.missing).toEqual([]); + expect(r.recommended!.stores).toHaveLength(2); + expect(r.savingMinor).toBeNull(); + }); + + it("uzaklık ve taşıma güçlüğü ziyaret cezası olarak puana eklenir", () => { + const far = store("FAR", { distanceM: 20_000, carryDifficulty: 5 }); + const near = store("NEAR"); + const r = planShopping( + base({ + items: [item("i1", "t1")], + stores: [far, near], + offers: [offer("FAR", "t1", 10_000), offer("NEAR", "t1", 13_000)], + }), + ); + // FAR 30 TL ucuz ama zahmet 100 → 50 TL ceza. + expect(effortScore(far) * VISIT_PENALTY_PER_POINT).toBe(5000); + expect(storesOf(r.recommended)).toEqual(["NEAR"]); + expect(storesOf(r.single)).toEqual(["NEAR"]); + }); + + it("hacimli kalemler zahmetli fiziksel mağaza yerine online'a yönelir", () => { + const hard = store("HARD", { distanceM: 10_000, carryDifficulty: 5 }); + const online = store("ONLINE", { kind: "online", distanceM: null, deliveryFeeMinor: 0 }); + const items = [item("water", "w", 1000, { bulky: true }), item("bread", "b")]; + const offers = [ + offer("HARD", "w", 10_000), + offer("ONLINE", "w", 10_500), + offer("HARD", "b", 1000), + ]; + const bulky = planShopping( + base({ items, stores: [hard, online], offers, extraStoreThresholdMinor: 0 }), + ); + const line = bulky.recommended!.stores.find((s) => s.storeId === "ONLINE")?.lines[0]; + expect(line?.itemId).toBe("water"); + // Aynı kalem hacimli değilse 5 TL ucuz olan HARD seçilir. + const plain = planShopping( + base({ + items: [item("water", "w"), item("bread", "b")], + stores: [hard, online], + offers, + extraStoreThresholdMinor: 0, + }), + ); + expect(storesOf(plain.recommended)).toEqual(["HARD"]); + expect(effortScore(hard) * BULKY_PENALTY_PER_POINT).toBeGreaterThan(500); + }); + + it("online teslimat ücreti ücretsiz teslimat limitinin altında eklenir", () => { + const online = store("ON", { + kind: "online", + distanceM: null, + deliveryFeeMinor: 3000, + freeDeliveryMinMinor: 50_000, + }); + const shop = store("SHOP"); + const offers = [offer("ON", "t1", 10_000), offer("SHOP", "t1", 11_000)]; + const small = planShopping(base({ items: [item("i1", "t1")], stores: [online, shop], offers })); + // 100 TL + 30 TL teslimat > 110 TL. + expect(storesOf(small.recommended)).toEqual(["SHOP"]); + const big = planShopping( + base({ items: [item("i1", "t1", 5000)], stores: [online, shop], offers }), + ); + // 500 TL ≥ limit: teslimat ücretsiz, online 50 TL ucuz. + expect(storesOf(big.recommended)).toEqual(["ON"]); + expect(big.recommended!.stores[0]!.deliveryFeeMinor).toBe(0); + }); + + it("yemek kartı bakiyesi varsa fiyatı yakın kalemler kart geçen mağazaya yönelir", () => { + const meal = store("MEAL", { mealCardProviders: ["multinet"] }); + const other = store("OTHER"); + const offers = [offer("MEAL", "t1", 10_300), offer("OTHER", "t1", 10_000)]; + const items = [item("i1", "t1")]; + const without = planShopping(base({ items, stores: [meal, other], offers })); + expect(storesOf(without.recommended)).toEqual(["OTHER"]); + const withCard = planShopping( + base({ + items, + stores: [meal, other], + offers, + mealCards: [{ provider: "multinet", balanceMinor: 50_000 }], + }), + ); + expect(storesOf(withCard.recommended)).toEqual(["MEAL"]); + expect(withCard.recommended!.mealCardMinor).toBe(10_300); + // Kart sağlayıcısı mağazada geçmiyorsa öncelik yok. + const wrong = planShopping( + base({ + items, + stores: [meal, other], + offers, + mealCards: [{ provider: "sodexo", balanceMinor: 50_000 }], + }), + ); + expect(storesOf(wrong.recommended)).toEqual(["OTHER"]); + }); + + it("yemek kartı bakiyesi mağazalar arasında bir kez harcanır", () => { + const a = store("A", { mealCardProviders: ["multinet"] }); + const b = store("B", { mealCardProviders: ["multinet"] }); + const r = planShopping( + base({ + items: [item("i1", "t1"), item("i2", "t2")], + stores: [a, b], + offers: [offer("A", "t1", 30_000), offer("B", "t2", 20_000)], + mealCards: [{ provider: "multinet", balanceMinor: 40_000 }], + }), + ); + expect(r.recommended!.mealCardMinor).toBe(40_000); + expect(r.recommended!.stores.map((s) => s.mealCardMinor).sort()).toEqual([10_000, 30_000]); + }); +}); diff --git a/src/shared/shopping-plan.ts b/src/shared/shopping-plan.ts new file mode 100644 index 0000000..0160c07 --- /dev/null +++ b/src/shared/shopping-plan.ts @@ -0,0 +1,307 @@ +import { effortScore, type StoreKind } from "./stores"; + +/** + * Akıllı mağaza önerisi (Bölüm 6.12). Saf fonksiyon; son bilinen fiyatlarla listedeki her kalemi + * bir mağazaya ve markaya dağıtır. + * + * Kalem maliyeti: ihtiyacı karşılayan paket sayısı × paket fiyatı (gerçek harcama). Bir mağazada + * birden çok marka/paket varsa en az harcamalı olan, eşitlikte birim fiyatı düşük olan seçilir. + * + * Planın puanı (küçük olan iyi) = harcama + teslimat ücretleri + zahmet cezaları − yemek kartı payı: + * - Ziyaret cezası: fiziksel mağaza başına zahmet skoru × ZİYARET_KATSAYISI (online: 0). + * - Hacimli kalem cezası: fiziksel mağazada hacimli kalem başına zahmet skoru × HACİMLİ_KATSAYI. + * - Online teslimat: ara toplam ücretsiz teslimat alt limitinin altındaysa (limit yoksa her + * zaman) teslimat ücreti eklenir. + * - Yemek kartı önceliği: yemek kartı geçen mağazalarda kartla ödenebilecek tutarın + * MEAL_CARD_PREFERENCE_PCT'si puandan düşülür; böylece fiyatı yakın kalemler karta yönelir. + * + * Seçim: tüm fiyatlı kalemleri karşılayan en az mağazalı plandan başlanır; bir mağaza daha + * eklemek ancak puanı eşikten (hane ayarı) fazla düşürüyorsa kabul edilir. + */ + +/** Ziyaret cezası: zahmet puanı başına kuruş (zahmet 100 → 50 TL). */ +export const VISIT_PENALTY_PER_POINT = 50; +/** Hacimli kalem cezası: zahmet puanı başına kuruş (zahmet 100 → kalem başına 15 TL). */ +export const BULKY_PENALTY_PER_POINT = 15; +/** Yemek kartıyla ödenebilecek tutarın puandan düşülen yüzdesi. */ +export const MEAL_CARD_PREFERENCE_PCT = 10; +/** Bir planda en fazla bu kadar mağaza. */ +export const MAX_PLAN_STORES = 4; + +export type PlanStore = { + id: string; + kind: StoreKind; + distanceM: number | null; + carryDifficulty: number; + deliveryFeeMinor: number | null; + freeDeliveryMinMinor: number | null; + /** Yemek kartı geçmiyorsa boş. */ + mealCardProviders: readonly string[]; +}; + +export type PlanOffer = { + productId: string; + productTypeId: string; + storeId: string; + /** Paket fiyatı (kuruş). */ + priceMinor: number; + /** Bir paketin ürün tipi birimindeki miktarı (binde bir). */ + inTypeUnit: number; +}; + +export type PlanItem = { + id: string; + productTypeId: string; + /** Gereken miktar, ürün tipi biriminde (binde bir). */ + quantity: number; + /** Belirli marka/ürün istenmişse yalnızca o ürünün fiyatları. */ + productId: string | null; + bulky: boolean; +}; + +export type PlanMealCard = { provider: string; balanceMinor: number }; + +export type PlanInput = { + items: readonly PlanItem[]; + offers: readonly PlanOffer[]; + stores: readonly PlanStore[]; + mealCards: readonly PlanMealCard[]; + /** Ek mağaza eşiği (kuruş). */ + extraStoreThresholdMinor: number; +}; + +export type PlanLine = { + itemId: string; + productId: string; + storeId: string; + packages: number; + priceMinor: number; + costMinor: number; + /** Ürün tipi biriminde 1 birimin fiyatı (kesirli kuruş). */ + unitPriceMinor: number; + bulkyPenaltyMinor: number; +}; + +export type PlanStoreGroup = { + storeId: string; + lines: PlanLine[]; + subtotalMinor: number; + deliveryFeeMinor: number; + visitPenaltyMinor: number; + /** Bu mağazada yemek kartıyla ödenebilecek tutar. */ + mealCardMinor: number; +}; + +export type PlanOption = { + stores: PlanStoreGroup[]; + /** Harcama + teslimat. */ + totalMinor: number; + /** Zahmet cezaları (ziyaret + hacimli). */ + penaltyMinor: number; + mealCardMinor: number; + /** Karşılaştırma puanı (kesirli olabilir). */ + score: number; + /** Bu planda alınamayan (fiyatlı) kalemler. */ + missing: string[]; +}; + +export type PlanResult = { + /** Tek mağazanın en iyisi (hepsini karşılayan yoksa en çok kalemi karşılayan). */ + single: PlanOption | null; + /** Önerilen dağılım; tek mağaza en iyiyse onunla aynı. */ + recommended: PlanOption | null; + /** Tek mağazaya göre harcama farkı (pozitif = tasarruf); karşılaştırılamıyorsa null. */ + savingMinor: number | null; + /** Hiçbir mağazada fiyatı bilinmeyen kalemler. */ + unpriced: string[]; + /** Kalem başına birim fiyatı en düşük teklif (mağaza ve marka). */ + cheapest: Record; +}; + +const unitOf = (o: PlanOffer) => (o.priceMinor * 1000) / o.inTypeUnit; + +/** Bir teklifle kalemi karşılamak: paket sayısı ve harcama. */ +function lineFor(item: PlanItem, o: PlanOffer, store: PlanStore): PlanLine { + const packages = Math.max(1, Math.ceil(item.quantity / o.inTypeUnit)); + const physical = store.kind !== "online"; + return { + itemId: item.id, + productId: o.productId, + storeId: o.storeId, + packages, + priceMinor: o.priceMinor, + costMinor: packages * o.priceMinor, + unitPriceMinor: unitOf(o), + bulkyPenaltyMinor: item.bulky && physical ? effortScore(store) * BULKY_PENALTY_PER_POINT : 0, + }; +} + +const better = (a: PlanLine, b: PlanLine) => + a.costMinor !== b.costMinor ? a.costMinor < b.costMinor : a.unitPriceMinor < b.unitPriceMinor; + +/** Mağazalardaki yemek kartı payı: kart bakiyeleri bir kez harcanır, büyük ara toplam önce. */ +function allocateMealCards( + groups: PlanStoreGroup[], + stores: Map, + cards: readonly PlanMealCard[], +) { + const left = cards.map((c) => ({ ...c })); + const order = [...groups].sort((a, b) => b.subtotalMinor - a.subtotalMinor); + for (const g of order) { + const accepted = new Set(stores.get(g.storeId)!.mealCardProviders); + let need = g.subtotalMinor + g.deliveryFeeMinor; + for (const c of left) { + if (need <= 0) break; + if (!accepted.has(c.provider) || c.balanceMinor <= 0) continue; + const use = Math.min(need, c.balanceMinor); + c.balanceMinor -= use; + need -= use; + g.mealCardMinor += use; + } + } +} + +function* subsets(n: number, k: number, start = 0, acc: number[] = []): Generator { + if (acc.length === k) { + yield [...acc]; + return; + } + for (let i = start; i <= n - (k - acc.length); i++) { + acc.push(i); + yield* subsets(n, k, i + 1, acc); + acc.pop(); + } +} + +export function planShopping(input: PlanInput): PlanResult { + const storeById = new Map(input.stores.map((s) => [s.id, s])); + // Kalem × mağaza en iyi satır. + const best = new Map>(); + const cheapest: Record = {}; + for (const item of input.items) { + const byStore = new Map(); + for (const o of input.offers) { + if (o.inTypeUnit <= 0 || o.productTypeId !== item.productTypeId) continue; + if (item.productId && o.productId !== item.productId) continue; + const store = storeById.get(o.storeId); + if (!store) continue; + const line = lineFor(item, o, store); + const prev = byStore.get(o.storeId); + if (!prev || better(line, prev)) byStore.set(o.storeId, line); + const c = cheapest[item.id]; + if (!c || line.unitPriceMinor < c.unitPriceMinor) cheapest[item.id] = line; + } + best.set(item.id, byStore); + } + const priced = input.items.filter((i) => best.get(i.id)!.size > 0); + const unpriced = input.items.filter((i) => best.get(i.id)!.size === 0).map((i) => i.id); + const candidates = input.stores.filter((s) => priced.some((i) => best.get(i.id)!.has(s.id))); + + if (priced.length === 0 || candidates.length === 0) + return { single: null, recommended: null, savingMinor: null, unpriced, cheapest }; + + function evaluate(chosen: PlanStore[]): PlanOption { + const groups = new Map(); + const missing: string[] = []; + for (const item of priced) { + const options = best.get(item.id)!; + let pick: PlanLine | null = null; + for (const s of chosen) { + const line = options.get(s.id); + if (!line) continue; + if ( + !pick || + line.costMinor + line.bulkyPenaltyMinor < pick.costMinor + pick.bulkyPenaltyMinor || + (line.costMinor + line.bulkyPenaltyMinor === pick.costMinor + pick.bulkyPenaltyMinor && + line.unitPriceMinor < pick.unitPriceMinor) + ) + pick = line; + } + if (!pick) { + missing.push(item.id); + continue; + } + const g = groups.get(pick.storeId) ?? { + storeId: pick.storeId, + lines: [], + subtotalMinor: 0, + deliveryFeeMinor: 0, + visitPenaltyMinor: 0, + mealCardMinor: 0, + }; + g.lines.push(pick); + g.subtotalMinor += pick.costMinor; + groups.set(pick.storeId, g); + } + const list = [...groups.values()]; + for (const g of list) { + const s = storeById.get(g.storeId)!; + if (s.kind === "online") { + const fee = s.deliveryFeeMinor ?? 0; + const free = s.freeDeliveryMinMinor; + g.deliveryFeeMinor = free !== null && g.subtotalMinor >= free ? 0 : fee; + } else g.visitPenaltyMinor = effortScore(s) * VISIT_PENALTY_PER_POINT; + } + allocateMealCards(list, storeById, input.mealCards); + const totalMinor = list.reduce((a, g) => a + g.subtotalMinor + g.deliveryFeeMinor, 0); + const penaltyMinor = list.reduce( + (a, g) => a + g.visitPenaltyMinor + g.lines.reduce((b, l) => b + l.bulkyPenaltyMinor, 0), + 0, + ); + const mealCardMinor = list.reduce((a, g) => a + g.mealCardMinor, 0); + return { + stores: list.sort( + (a, b) => b.lines.length - a.lines.length || b.subtotalMinor - a.subtotalMinor, + ), + totalMinor, + penaltyMinor, + mealCardMinor, + score: totalMinor + penaltyMinor - (mealCardMinor * MEAL_CARD_PREFERENCE_PCT) / 100, + missing, + }; + } + + // Boyut başına en iyi tam (eksiksiz) plan; tek mağazada eksiksiz yoksa en çok kalemi karşılayan. + const bestBySize: (PlanOption | null)[] = []; + let single: PlanOption | null = null; + const maxK = Math.min(MAX_PLAN_STORES, candidates.length); + for (let k = 1; k <= maxK; k++) { + let top: PlanOption | null = null; + for (const idx of subsets(candidates.length, k)) { + const option = evaluate(idx.map((i) => candidates[i]!)); + // Kullanılmayan mağaza içeren alt küme daha küçük boyutta zaten değerlendirildi. + if (option.stores.length < k) continue; + if ( + k === 1 && + (!single || + option.missing.length < single.missing.length || + (option.missing.length === single.missing.length && option.score < single.score)) + ) + single = option; + if (option.missing.length === 0 && (!top || option.score < top.score)) top = option; + } + bestBySize[k] = top; + } + + let k = bestBySize.findIndex((o, i) => i > 0 && o !== null); + let recommended: PlanOption | null = k > 0 ? bestBySize[k]! : null; + // Tüm alt kümeler sınırı aştıysa (kalemler çok dağınık): en iyi eksikli plan. + if (!recommended) recommended = single; + else + while (k + 1 <= maxK) { + const next = bestBySize[k + 1]; + if (!next || recommended.score - next.score <= input.extraStoreThresholdMinor) break; + recommended = next; + k++; + } + + const comparable = + single && recommended && single.missing.length === 0 && recommended.missing.length === 0; + return { + single, + recommended, + savingMinor: comparable ? single!.totalMinor - recommended!.totalMinor : null, + unpriced, + cheapest, + }; +} diff --git a/src/shared/shopping.test.ts b/src/shared/shopping.test.ts new file mode 100644 index 0000000..7635b3d --- /dev/null +++ b/src/shared/shopping.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { requiredQuantity, suggestionHidden } from "./shopping"; + +describe("requiredQuantity", () => { + it("ürün tipi kaleminde girilen miktar; boşsa 0", () => { + expect(requiredQuantity({ kind: "type", quantity: 2500 }, null)).toBe(2500); + expect(requiredQuantity({ kind: "type", quantity: null }, null)).toBe(0); + }); + + it("ürün kaleminde paket adedi × paketin tip karşılığı; boşsa 1 paket", () => { + // 2 paket × 50 yıkama + expect(requiredQuantity({ kind: "product", quantity: 2000 }, 50_000)).toBe(100_000); + expect(requiredQuantity({ kind: "product", quantity: null }, 500)).toBe(500); + expect(requiredQuantity({ kind: "product", quantity: 2000 }, null)).toBe(0); + }); +}); + +describe("suggestionHidden", () => { + const d = (s: string) => new Date(s); + it("reddedilmemişse görünür", () => { + expect(suggestionHidden(null, null)).toBe(false); + }); + it("redden sonra alım yoksa gizli, alım gelince yeniden görünür", () => { + expect(suggestionHidden(d("2026-11-01"), null)).toBe(true); + expect(suggestionHidden(d("2026-11-01"), d("2026-10-20"))).toBe(true); + expect(suggestionHidden(d("2026-11-01"), d("2026-11-03"))).toBe(false); + }); +}); diff --git a/src/shared/shopping.ts b/src/shared/shopping.ts new file mode 100644 index 0000000..638fc6b --- /dev/null +++ b/src/shared/shopping.ts @@ -0,0 +1,41 @@ +import { QUANTITY_SCALE } from "./quantity"; + +/** Alışveriş listeleri (Bölüm 6.12): sabitler ve saf yardımcılar. */ + +export const SHOPPING_LIST_STATUSES = ["active", "completed"] as const; +export type ShoppingListStatus = (typeof SHOPPING_LIST_STATUSES)[number]; + +export const SHOPPING_ITEM_KINDS = ["type", "product", "text"] as const; +export type ShoppingItemKind = (typeof SHOPPING_ITEM_KINDS)[number]; + +export const SHOPPING_ITEM_SOURCES = ["manual", "suggestion"] as const; +export type ShoppingItemSource = (typeof SHOPPING_ITEM_SOURCES)[number]; + +/** Aile üyeleri aynı listeyi kullanır: sayfa odaklanınca ve bu aralıkla yenilenir. */ +export const SHOPPING_REFRESH_MS = 30_000; + +/** + * Planlayıcı için gereken miktar (ürün tipi biriminde, binde bir): + * - ürün tipi kalemi: girilen miktar; boşsa 0 (en az bir paket alınır), + * - ürün kalemi: paket adedi × paketin tip birimindeki karşılığı. + */ +export function requiredQuantity( + item: { kind: ShoppingItemKind; quantity: number | null }, + inTypeUnit: number | null, +): number { + if (item.kind === "product") { + if (!inTypeUnit) return 0; + const packages = item.quantity ?? QUANTITY_SCALE; + return Math.round((packages * inTypeUnit) / QUANTITY_SCALE); + } + return item.quantity ?? 0; +} + +/** + * Reddedilen eşik önerisi, reddedildikten sonra alım hareketi girilene (stok yenilenene) kadar + * gizli kalır; sonra ürün yine eşiğin altına düşerse öneri yeniden çıkar. + */ +export function suggestionHidden(dismissedAt: Date | null, lastPurchaseAt: Date | null): boolean { + if (!dismissedAt) return false; + return !lastPurchaseAt || lastPurchaseAt.getTime() <= dismissedAt.getTime(); +}