Files
suphicakir 8ab2244755 feat(shopping): alışveriş listeleri, stok eşiği önerileri ve akıllı mağaza önerisi motoru
- shoppingLists koleksiyonu (ürün tipi / marka / serbest metin kalemleri), migration 015
- planShopping saf fonksiyonu: paket yuvarlama, zahmet ve hacimli cezası, teslimat, yemek kartı önceliği, ek mağaza eşiği
- eşik önerileri onay/red (red stok yenilenene kadar), ürün tipine hacimli işareti
- fiyat servisinde ortak loadTypeOffers; seed listeleri ve testler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:28:54 +03:00

581 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, Types.ObjectId>,
users: Map<string, string>,
): 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<string, number> = {
"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<string, number> = {
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<string, Types.ObjectId>,
users: Map<string, string>,
): Promise<number> {
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<string, Types.ObjectId>,
users: Map<string, string>,
): Promise<number> {
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<string, unknown>[] => {
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;
}