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"; import { todayIstanbul } from "@/shared/dates"; import { addDays } from "@/shared/recurrence"; import { foldTr } from "@/shared/search"; import type { StoreKind } from "@/shared/stores"; /** * Örnek mağazalar ve ürünler (Faz 12). Çakır Ailesi (Kadıköy) için marketler, pazar ve online * platform; stok ürün tiplerine bağlı marka/paketler. Elle dönüşüm gereken örnekler: deterjan * (kg → yıkama), tablet paketi (paket → tablet), diş macunu/şampuan (ml → adet). Görsel yok. * Idempotent (`seedRef`). Miktarlar binde bir, tutarlar kuruş. */ type SeedStore = { ref: string; name: string; kind: StoreKind; branch?: string; distanceM?: number; carry: number; deliveryFee?: number; freeDeliveryMin?: number; mealCards?: MealCardProvider[]; note?: string; }; export const STORES: SeedStore[] = [ { ref: "migros", name: "Migros", kind: "market", branch: "Bahariye", distanceM: 1_200, carry: 2, mealCards: ["pluxee", "multinet", "edenred", "setcard"], }, { ref: "a101", name: "A101", kind: "market", branch: "Moda", distanceM: 400, carry: 1 }, { ref: "bim", name: "BİM", kind: "market", branch: "Yeldeğirmeni", distanceM: 650, carry: 2 }, { ref: "sok", name: "Şok", kind: "market", branch: "Rasimpaşa", distanceM: 300, carry: 1, mealCards: ["multinet", "pluxee"], }, { ref: "carrefoursa", name: "CarrefourSA", kind: "market", branch: "Acıbadem", distanceM: 3_800, carry: 4, mealCards: ["pluxee", "multinet", "edenred", "setcard", "metropol"], note: "Hafta sonu arabayla; büyük alışveriş.", }, { ref: "sali-pazari", name: "Salı Pazarı", kind: "bazaar", branch: "Kadıköy", distanceM: 1_900, carry: 4, note: "Salı ve cuma; sebze-meyve.", }, { ref: "getir-buyuk", name: "Getir Büyük", kind: "online", carry: 1, deliveryFee: 39_90, freeDeliveryMin: 750_00, mealCards: ["multinet"], }, { ref: "metro", name: "Metro Toptancı", kind: "market", branch: "Kozyatağı", distanceM: 9_500, carry: 5, }, ]; type SeedProduct = { ref: string; /** Stok seed'indeki ürün tipi `seedRef`'i. */ type: string; brand: string; name?: string; barcode?: string; qty: number; unit: string; /** Elle dönüşüm (bir paket = bu kadar ürün tipi birimi). */ typeQty?: number; }; const K = 1000; export const PRODUCTS: SeedProduct[] = [ { ref: "ariel-5kg", type: "toz-deterjan", brand: "Ariel", name: "Matik Dağ Esintisi", qty: 5 * K, unit: "kg", typeQty: 33 * K, barcode: "8690000100015", }, { ref: "omo-6kg", type: "toz-deterjan", brand: "Omo", name: "Matik Active Fresh", qty: 6 * K, unit: "kg", typeQty: 40 * K, }, { ref: "bingo-4kg", type: "toz-deterjan", brand: "Bingo", name: "Matik", qty: 4 * K, unit: "kg", typeQty: 26 * K, }, { ref: "fairy-50", type: "tablet-deterjan", brand: "Fairy", name: "Platinum Plus", qty: 1 * K, unit: "paket", typeQty: 50 * K, barcode: "8690000100022", }, { ref: "finish-40", type: "tablet-deterjan", brand: "Finish", name: "Quantum", qty: 40 * K, unit: "tablet", }, { ref: "yayla-mercimek", type: "kirmizi-mercimek", brand: "Yayla", name: "Kırmızı Mercimek", qty: 1 * K, unit: "kg", }, { ref: "reis-mercimek", type: "kirmizi-mercimek", brand: "Reis", name: "Kırmızı Mercimek", qty: 1 * K, unit: "kg", }, { ref: "duru-mercimek", type: "kirmizi-mercimek", brand: "Duru", name: "Kırmızı Mercimek", qty: 2_500, unit: "kg", }, { ref: "yayla-baldo", type: "pirinc", brand: "Yayla", name: "Baldo", qty: 2_500, unit: "kg" }, { ref: "reis-baldo", type: "pirinc", brand: "Reis", name: "Baldo", qty: 1 * K, unit: "kg" }, { ref: "pinar-sut", type: "sut", brand: "Pınar", name: "Tam Yağlı Süt", qty: 1 * K, unit: "lt", barcode: "8690000100039", }, { ref: "sutas-sut", type: "sut", brand: "Sütaş", name: "Tam Yağlı Süt", qty: 1 * K, unit: "lt" }, { ref: "icim-sut-500", type: "sut", brand: "İçim", name: "Yarım Yağlı Süt", qty: 500 * K, unit: "ml", }, { ref: "keskinoglu-30", type: "yumurta", brand: "Keskinoğlu", name: "L Boy 30'lu", qty: 30 * K, unit: "adet", }, { ref: "koy-yumurta-15", type: "yumurta", brand: "Köy Yumurtası", name: "15'li", qty: 15 * K, unit: "adet", }, { ref: "pinar-peynir", type: "beyaz-peynir", brand: "Pınar", name: "Beyaz Peynir", qty: 500 * K, unit: "g", }, { ref: "yumos-1440", type: "yumusatici", brand: "Yumoş", name: "Extra Lilyum", qty: 1_440 * K, unit: "ml", }, { ref: "selpak-32", type: "tuvalet-kagidi", brand: "Selpak", name: "32'li", qty: 32 * K, unit: "rulo", }, { ref: "solo-24", type: "tuvalet-kagidi", brand: "Solo", name: "24'lü", qty: 24 * K, unit: "rulo", }, { ref: "colgate-75", type: "dis-macunu", brand: "Colgate", name: "Total 75 ml", qty: 75 * K, unit: "ml", typeQty: 1 * K, }, { ref: "sensodyne-75", type: "dis-macunu", brand: "Sensodyne", name: "Repair 75 ml", qty: 75 * K, unit: "ml", typeQty: 1 * K, }, { ref: "elidor-500", type: "sampuan", brand: "Elidor", name: "500 ml", qty: 500 * K, unit: "ml", typeQty: 1 * K, }, ]; export async function seedShopping( householdIds: Map, users: Map, ): Promise<{ stores: number; products: number }> { const actor = new Types.ObjectId(users.get("ayse")!); const householdId = householdIds.get("Çakır Ailesi")!; let stores = 0; for (const s of STORES) { if (await StoreModel.exists({ householdId, seedRef: s.ref })) continue; await StoreModel.create({ householdId, name: s.name, nameKey: foldTr(s.name), kind: s.kind, branch: s.branch ?? "", distanceM: s.kind === "online" ? null : (s.distanceM ?? null), carryDifficulty: s.carry, deliveryFeeMinor: s.kind === "online" ? (s.deliveryFee ?? null) : null, freeDeliveryMinMinor: s.kind === "online" ? (s.freeDeliveryMin ?? null) : null, mealCardAccepted: Boolean(s.mealCards?.length), mealCardProviders: s.mealCards ?? [], note: s.note ?? "", seedRef: s.ref, createdBy: actor, updatedBy: actor, }); stores++; } const types = await ProductTypeModel.find({ householdId, seedRef: { $type: "string" } }) .select({ seedRef: 1, unit: 1 }) .lean<{ _id: Types.ObjectId; seedRef: string; unit: string }[]>(); const typeByRef = new Map(types.map((x) => [x.seedRef, x])); let products = 0; for (const p of PRODUCTS) { const type = typeByRef.get(p.type); if (!type || (await ProductModel.exists({ householdId, seedRef: p.ref }))) continue; if (!autoConvertible(p.unit, type.unit) && !p.typeQty) throw new Error(`Seed ürünü ${p.ref}: ${p.unit} → ${type.unit} dönüşümü eksik`); await ProductModel.create({ householdId, productTypeId: type._id, brand: p.brand, name: p.name ?? "", searchKey: foldTr(`${p.brand} ${p.name ?? ""}`), barcode: p.barcode ?? null, packageQuantity: p.qty, packageUnit: p.unit, typeQuantity: autoConvertible(p.unit, type.unit) ? null : p.typeQty, seedRef: p.ref, createdBy: actor, updatedBy: actor, }); products++; } return { stores, products }; } // --- Fiyatlar (Faz 12b) --- /** Bugünkü paket fiyatı (kuruş, Kasım 2026 tahmini). */ const CURRENT_PRICE: Record = { "ariel-5kg": 519_90, "omo-6kg": 559_90, "bingo-4kg": 329_90, "fairy-50": 479_90, "finish-40": 419_90, "yayla-mercimek": 84_90, "reis-mercimek": 79_90, "duru-mercimek": 189_90, "yayla-baldo": 259_90, "reis-baldo": 109_90, "pinar-sut": 41_90, "sutas-sut": 39_90, "icim-sut-500": 23_90, "keskinoglu-30": 174_90, "koy-yumurta-15": 119_90, "pinar-peynir": 209_90, "yumos-1440": 139_90, "selpak-32": 329_90, "solo-24": 229_90, "colgate-75": 84_90, "sensodyne-75": 159_90, "elidor-500": 119_90, }; /** Mağaza fiyat çarpanı (indirim marketi ucuz, online pahalı). */ const STORE_FACTOR: Record = { migros: 1.06, a101: 0.94, bim: 0.91, sok: 0.96, carrefoursa: 1.0, "getir-buyuk": 1.12, metro: 0.88, }; const PRICE_STORES = Object.keys(STORE_FACTOR); /** Aylık fiyat artışı. */ const MONTHLY_INFLATION = 0.025; /** x,90 biçimine yuvarla (ör. 87,34 → 86,90 / 87,90). */ const ninety = (minor: number) => Math.max(90, Math.round((minor - 90) / 100) * 100 + 90); type SeedPrice = { ref: string; product: string; store: string; daysAgo: number; priceMinor: number; isPromo?: boolean; promoUntilDaysAgo?: number; }; /** * Her ürün 3 mağazada (ürün sırasına göre dönüşümlü), son 6 ayda ~45 günde bir fiyat. * Kampanyalar: Ariel @ Migros hâlâ süren (%20), Pınar süt @ A101 süresi bitmiş (önceki normal * fiyata dönülür). Reis mercimek @ Metro yalnızca eski kayıtlarla ("eski fiyat"). */ export function seedPrices(): SeedPrice[] { const out: SeedPrice[] = []; PRODUCTS.forEach((p, i) => { const current = CURRENT_PRICE[p.ref]!; const stores = [0, 2, 4].map((k) => PRICE_STORES[(i + k) % PRICE_STORES.length]!); stores.forEach((store, si) => { for (let step = 0; step < 4; step++) { const daysAgo = 170 - step * 45 - si * 7 - (i % 5); if (daysAgo < 0) continue; if (p.ref === "reis-mercimek" && store === "metro" && daysAgo < 100) continue; const months = daysAgo / 30; const price = (current * STORE_FACTOR[store]!) / (1 + MONTHLY_INFLATION) ** months; out.push({ ref: `${p.ref}:${store}:${step}`, product: p.ref, store, daysAgo, priceMinor: ninety(price), }); } }); }); out.push( { ref: "ariel-5kg:migros:promo", product: "ariel-5kg", store: "migros", daysAgo: 4, priceMinor: ninety(CURRENT_PRICE["ariel-5kg"]! * 1.06 * 0.8), isPromo: true, promoUntilDaysAgo: -6, }, { ref: "pinar-sut:a101:normal", product: "pinar-sut", store: "a101", daysAgo: 30, priceMinor: ninety(CURRENT_PRICE["pinar-sut"]! * 0.94), }, { ref: "pinar-sut:a101:promo", product: "pinar-sut", store: "a101", daysAgo: 12, priceMinor: ninety(CURRENT_PRICE["pinar-sut"]! * 0.75), isPromo: true, promoUntilDaysAgo: 3, }, ); return out; } export async function seedProductPrices( householdIds: Map, users: Map, ): Promise { const actor = new Types.ObjectId(users.get("ayse")!); const householdId = householdIds.get("Çakır Ailesi")!; const [products, stores] = await Promise.all([ ProductModel.find({ householdId, seedRef: { $type: "string" } }) .select({ seedRef: 1 }) .lean<{ _id: Types.ObjectId; seedRef: string }[]>(), StoreModel.find({ householdId, seedRef: { $type: "string" } }) .select({ seedRef: 1 }) .lean<{ _id: Types.ObjectId; seedRef: string }[]>(), ]); const productByRef = new Map(products.map((p) => [p.seedRef, p._id])); const storeByRef = new Map(stores.map((x) => [x.seedRef, x._id])); const existing = new Set( ( await ProductPriceModel.find({ householdId, seedRef: { $type: "string" } }) .select({ seedRef: 1 }) .lean<{ seedRef: string }[]>() ).map((x) => x.seedRef), ); const today = todayIstanbul(); const base = Date.now() - 200 * 86_400_000; const docs = seedPrices() .filter((x) => !existing.has(x.ref) && productByRef.has(x.product) && storeByRef.has(x.store)) .sort((a, b) => b.daysAgo - a.daysAgo) .map((x, i) => { const at = new Date(base + i * 1000); return { householdId, productId: productByRef.get(x.product)!, storeId: storeByRef.get(x.store)!, date: addDays(today, -x.daysAgo), priceMinor: x.priceMinor, isPromo: x.isPromo ?? false, promoUntil: x.promoUntilDaysAgo === undefined ? null : addDays(today, -x.promoUntilDaysAgo), source: "manual", note: "", deletedAt: null, seedRef: x.ref, createdBy: actor, createdAt: at, updatedAt: at, }; }); 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; }