Files
butcem/scripts/seed/shopping.ts
T

495 lines
13 KiB
TypeScript
Raw 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 { 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;
}