e67a1d5411
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
495 lines
13 KiB
TypeScript
495 lines
13 KiB
TypeScript
import { Types } from "mongoose";
|
||
import { ProductModel } from "@/models/product";
|
||
import { ProductPriceModel } from "@/models/product-price";
|
||
import { ProductTypeModel } from "@/models/product-type";
|
||
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;
|
||
}
|