feat(prices): fiyat kayıtları, birim fiyat motoru ve analizde aylık maliyet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ describe("seed", () => {
|
||||
expect(summary.created.stock).toBe(19);
|
||||
expect(summary.created.stores).toBe(8);
|
||||
expect(summary.created.products).toBe(22);
|
||||
expect(summary.created.prices).toBeGreaterThan(200);
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
|
||||
});
|
||||
@@ -59,6 +60,7 @@ describe("seed", () => {
|
||||
stock: 0,
|
||||
stores: 0,
|
||||
products: 0,
|
||||
prices: 0,
|
||||
});
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
||||
|
||||
+6
-1
@@ -13,7 +13,7 @@ import { runMigrations } from "../migrations";
|
||||
import { seedContacts } from "./contacts";
|
||||
import { seedAssets } from "./assets";
|
||||
import { seedBudgets } from "./budgets";
|
||||
import { seedShopping } from "./shopping";
|
||||
import { seedProductPrices, seedShopping } from "./shopping";
|
||||
import { seedStock } from "./stock";
|
||||
import { seedInKind } from "./in-kind";
|
||||
import { seedInstallments } from "./installments";
|
||||
@@ -60,6 +60,7 @@ export type SeedSummary = {
|
||||
stock: number;
|
||||
stores: number;
|
||||
products: number;
|
||||
prices: number;
|
||||
};
|
||||
accounts: SeedAccountRow[];
|
||||
};
|
||||
@@ -89,6 +90,7 @@ const DOMAIN_COLLECTIONS = [
|
||||
"stockCounts",
|
||||
"stores",
|
||||
"products",
|
||||
"productPrices",
|
||||
];
|
||||
|
||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||
@@ -147,6 +149,7 @@ export async function runSeed({
|
||||
stock: 0,
|
||||
stores: 0,
|
||||
products: 0,
|
||||
prices: 0,
|
||||
},
|
||||
accounts: [],
|
||||
};
|
||||
@@ -336,6 +339,8 @@ export async function runSeed({
|
||||
summary.created.products = shopping.products;
|
||||
if (shopping.stores || shopping.products)
|
||||
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}`);
|
||||
|
||||
// 7) Geliştirme hesapları tablosu
|
||||
const all = [
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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";
|
||||
|
||||
@@ -326,3 +329,166 @@ export async function seedShopping(
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user