diff --git a/scripts/migrations/014-product-prices.ts b/scripts/migrations/014-product-prices.ts new file mode 100644 index 0000000..6c12c12 --- /dev/null +++ b/scripts/migrations/014-product-prices.ts @@ -0,0 +1,10 @@ +import { ProductPriceModel } from "@/models/product-price"; +import type { Migration } from "./types"; + +export const migration: Migration = { + name: "014-product-prices", + description: "ürün fiyat kayıtları (productPrices) index'leri", + async up() { + await ProductPriceModel.createIndexes(); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index d90024a..dc8e080 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -13,6 +13,7 @@ import { migration as m010 } from "./010-budgets"; import { migration as m011 } from "./011-stock"; import { migration as m012 } from "./012-stock-counts"; import { migration as m013 } from "./013-stores-products"; +import { migration as m014 } from "./014-product-prices"; import type { Migration } from "./types"; /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ @@ -30,6 +31,7 @@ export const MIGRATIONS: Migration[] = [ m011, m012, m013, + m014, ]; const COLLECTION = "migrations"; diff --git a/scripts/seed/run.int.test.ts b/scripts/seed/run.int.test.ts index 92b7dc6..a3e466a 100644 --- a/scripts/seed/run.int.test.ts +++ b/scripts/seed/run.int.test.ts @@ -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); diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts index 284a447..0227767 100644 --- a/scripts/seed/run.ts +++ b/scripts/seed/run.ts @@ -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["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 = [ diff --git a/scripts/seed/shopping.ts b/scripts/seed/shopping.ts index cdabf25..52da88f 100644 --- a/scripts/seed/shopping.ts +++ b/scripts/seed/shopping.ts @@ -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 = { + "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; +} diff --git a/src/features/prices/actions.ts b/src/features/prices/actions.ts new file mode 100644 index 0000000..70e489e --- /dev/null +++ b/src/features/prices/actions.ts @@ -0,0 +1,44 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { tr } from "@/i18n/tr"; +import { fail, type ActionResult } from "@/lib/action-result"; +import { getHouseholdActionContext } from "@/lib/dal"; +import { productPriceFormSchema } from "@/shared/schemas/product-price"; +import { addProductPrice, deleteProductPrice } from "./service"; + +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); + +function fieldErrors(error: z.ZodError) { + const out: Record = {}; + for (const issue of error.issues) { + const key = issue.path.join("."); + if (key && !out[key]) out[key] = issue.message; + } + return out; +} + +async function run( + schema: z.ZodType, + input: unknown, + fn: (userId: string, householdId: string, data: T) => Promise>, +): Promise> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = schema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + const result = await fn(ctx.user.id, ctx.household.id, parsed.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} + +export async function addProductPriceAction(input: unknown) { + return run(productPriceFormSchema, input, (u, h, d) => addProductPrice(u, h, d)); +} + +export async function deleteProductPriceAction(priceId: unknown) { + const id = idSchema.safeParse(priceId); + if (!id.success) return fail(tr.common.notFound); + return run(z.unknown(), null, (u, h) => deleteProductPrice(u, h, id.data)); +} diff --git a/src/features/prices/options.ts b/src/features/prices/options.ts new file mode 100644 index 0000000..1f045ce --- /dev/null +++ b/src/features/prices/options.ts @@ -0,0 +1,18 @@ +import type { ProductView } from "@/features/products/service"; + +/** Fiyat formu ürün seçeneği (birim fiyat önizlemesi için dönüşümle). İstemcide de kullanılır. */ +export type PriceProductOption = { + id: string; + label: string; + packageLabel: string; + inTypeUnit: number | null; + typeUnit: string; +}; + +export const priceProductOption = (p: ProductView): PriceProductOption => ({ + id: p.id, + label: p.label, + packageLabel: p.packageLabel, + inTypeUnit: p.inTypeUnit, + typeUnit: p.typeUnit, +}); diff --git a/src/features/prices/service.int.test.ts b/src/features/prices/service.int.test.ts new file mode 100644 index 0000000..4cfe4bc --- /dev/null +++ b/src/features/prices/service.int.test.ts @@ -0,0 +1,150 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getStockAnalysis } from "@/features/stock/service"; +import { AuditLogModel } from "@/models/audit-log"; +import { ProductModel } from "@/models/product"; +import { ProductTypeModel } from "@/models/product-type"; +import { StoreModel } from "@/models/store"; +import { todayIstanbul } from "@/shared/dates"; +import { addDays } from "@/shared/recurrence"; +import { productPriceFormSchema } from "@/shared/schemas/product-price"; +import { setupSeededDb, teardownDb } from "@/test/seeded-db"; +import { + addProductPrice, + deleteProductPrice, + getProductPrices, + getTypePriceComparison, +} from "./service"; + +let ids: Awaited>; +let cakir = ""; +let ayse = ""; +let zeynep = ""; +let fatma = ""; +const today = todayIstanbul(); + +beforeAll(async () => { + ids = await setupSeededDb(); + cakir = ids.household("Çakır Ailesi"); + ayse = ids.user("ayse"); + zeynep = ids.user("zeynep"); + fatma = ids.user("fatma"); +}); +afterAll(teardownDb); + +const ref = ( + model: typeof ProductModel | typeof StoreModel | typeof ProductTypeModel, + seedRef: string, +) => + (model as typeof ProductModel) + .findOne({ seedRef }) + .lean() + .then((x) => String(x!._id)); + +describe("birim fiyat karşılaştırması", () => { + it("toz deterjan: birim fiyata göre sıralı, en uygun işaretli, süren kampanya", async () => { + const c = (await getTypePriceComparison( + ayse, + cakir, + await ref(ProductTypeModel, "toz-deterjan"), + ))!; + expect(c.typeUnit).toBe("yıkama"); + const units = c.offers.map((o) => o.unitPriceMinor!); + expect(units).toEqual([...units].sort((a, b) => a - b)); + expect(c.offers[0]!.cheapest).toBe(true); + expect(c.offers.filter((o) => o.cheapest)).toHaveLength(1); + const ariel = c.offers.find( + (o) => o.productLabel.startsWith("Ariel") && o.store.label.startsWith("Migros"), + )!; + expect(ariel.price).toMatchObject({ isPromo: true, promoUntil: addDays(today, 6) }); + expect(ariel.promoEnded).toBe(false); + // Birim fiyat = paket / 33 yıkama. + expect(ariel.unitPriceMinor!).toBeCloseTo((ariel.price.priceMinor * 1000) / 33_000, 6); + expect(c.products).toHaveLength(3); + expect( + await getTypePriceComparison(fatma, cakir, await ref(ProductTypeModel, "toz-deterjan")), + ).toBeNull(); + }); + + it("süresi biten kampanyada normal fiyata dönülür; eski fiyat işaretlenir", async () => { + const sut = (await getProductPrices(ayse, cakir, await ref(ProductModel, "pinar-sut")))!; + const a101 = sut.offers.find((o) => o.store.label.startsWith("A101"))!; + expect(a101).toMatchObject({ fellBack: true, promoEnded: false }); + expect(a101.price.isPromo).toBe(false); + expect(a101.price.date).toBe(addDays(today, -30)); + expect(sut.history.some((h) => h.isPromo && h.promoEnded)).toBe(true); + expect(sut.series.length).toBeGreaterThanOrEqual(3); + expect(sut.points.length).toBeGreaterThan(5); + + const reis = (await getProductPrices(ayse, cakir, await ref(ProductModel, "reis-mercimek")))!; + expect(reis.offers.find((o) => o.store.label.startsWith("Metro"))!.stale).toBe(true); + }); +}); + +describe("fiyat girişi", () => { + it("ekle, gelecek tarih ve arşivli mağaza reddi, izleyici yazamaz, sil + denetim", async () => { + const productId = await ref(ProductModel, "omo-6kg"); + const storeId = await ref(StoreModel, "sok"); + const form = (over: Record = {}) => + productPriceFormSchema.parse({ + productId, + storeId, + date: today, + price: "449,90", + isPromo: false, + promoUntil: "", + note: "", + ...over, + }); + expect( + productPriceFormSchema.safeParse({ ...form(), price: "0", isPromo: false }).success, + ).toBe(false); + expect( + productPriceFormSchema.safeParse({ + productId, + storeId, + date: today, + price: "10", + isPromo: true, + promoUntil: addDays(today, -1), + }).success, + ).toBe(false); + + expect((await addProductPrice(ayse, cakir, form({ date: addDays(today, 1) }))).ok).toBe(false); + expect((await addProductPrice(zeynep, cakir, form())).ok).toBe(false); + + const r = await addProductPrice(ayse, cakir, form()); + expect(r.ok).toBe(true); + let d = (await getProductPrices(ayse, cakir, productId))!; + const sok = d.offers.find((o) => o.store.id === storeId)!; + expect(sok.price).toMatchObject({ priceMinor: 449_90, date: today, source: "manual" }); + // 6 kg = 40 yıkama → 11,2475 TL/yıkama. + expect(sok.unitPriceMinor).toBeCloseTo(1124.75, 6); + + const priceId = r.ok ? r.data.id : ""; + expect((await deleteProductPrice(fatma, cakir, priceId)).ok).toBe(false); + expect((await deleteProductPrice(ayse, cakir, priceId)).ok).toBe(true); + expect(await AuditLogModel.exists({ action: "price.delete", targetId: priceId })).toBeTruthy(); + d = (await getProductPrices(ayse, cakir, productId))!; + expect(d.history.some((h) => h.id === priceId)).toBe(false); + + await StoreModel.updateOne({ _id: storeId }, { $set: { archivedAt: new Date() } }); + expect(await addProductPrice(ayse, cakir, form())).toMatchObject({ + ok: false, + fieldErrors: { storeId: expect.any(String) }, + }); + await StoreModel.updateOne({ _id: storeId }, { $set: { archivedAt: null } }); + }); +}); + +describe("tüketim analizinde maliyet", () => { + it("aylık ortalama tüketim × son birim fiyat", async () => { + const a = (await getStockAnalysis(ayse, cakir))!; + const deterjan = a.rows.find((r) => r.name === "Toz Deterjan")!; + expect(deterjan.unitPrice).not.toBeNull(); + // Maliyet yuvarlanmamış ortalamadan; gösterilen ortalama yuvarlıdır (≤ %1 fark). + const expected = (deterjan.avgMonthlyUsed! * deterjan.unitPrice!.minor) / 1000; + expect(Math.abs(deterjan.avgMonthlyCostMinor! - expected) / expected).toBeLessThan(0.01); + // Ürünü (marka/paket) olmayan tipte maliyet yok. + expect(a.rows.find((r) => r.name === "Domates")!.avgMonthlyCostMinor).toBeNull(); + }); +}); diff --git a/src/features/prices/service.ts b/src/features/prices/service.ts new file mode 100644 index 0000000..a510386 --- /dev/null +++ b/src/features/prices/service.ts @@ -0,0 +1,436 @@ +import "server-only"; +import { Types } from "mongoose"; +import { hasHouseholdRole } from "@/features/households/roles"; +import { resolveMembership, type Membership } from "@/features/households/service"; +import { listProducts, type ProductView } from "@/features/products/service"; +import { storeLabel } from "@/features/stores/service"; +import { priceProductOption, type PriceProductOption } from "./options"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { writeAudit } from "@/lib/audit"; +import { toObjectId } from "@/lib/object-id"; +import { ProductModel } from "@/models/product"; +import { ProductPriceModel } from "@/models/product-price"; +import { ProductTypeModel } from "@/models/product-type"; +import { StoreModel } from "@/models/store"; +import { todayIstanbul } from "@/shared/dates"; +import { + effectivePrice, + promoEnded, + rankOffers, + seriesSlot, + unitPrice, + type PriceSource, + type RankedOffer, +} from "@/shared/prices"; +import { packageInTypeUnit } from "@/shared/products"; +import type { ProductPriceForm } from "@/shared/schemas/product-price"; +import { compareTr } from "@/shared/search"; + +/** + * Ürün fiyatları (Bölüm 6.11). Hane bazlı; okuma viewer, yazma member. Birim fiyat ürünün + * dönüşümüyle okuma sırasında hesaplanır (saf motor: shared/prices). + */ + +const t = tr.productPrices; + +type PriceLean = { + _id: Types.ObjectId; + productId: Types.ObjectId; + storeId: Types.ObjectId; + date: string; + priceMinor: number; + isPromo: boolean; + promoUntil: string | null; + source: PriceSource; + note: string; + createdAt: Date; +}; + +type StoreLean = { + _id: Types.ObjectId; + name: string; + branch: string; + archivedAt: Date | null; + createdAt: Date; +}; + +export type StoreRef = { id: string; label: string; slot: number; archived: boolean }; + +export type PriceRow = { + id: string; + productId: string; + store: StoreRef; + date: string; + priceMinor: number; + isPromo: boolean; + promoUntil: string | null; + promoEnded: boolean; + source: PriceSource; + note: string; + /** Ürün tipi biriminde 1 birimin fiyatı (kesirli kuruş; dönüşüm yoksa null). */ + unitPriceMinor: number | null; +}; + +export type StoreOffer = { + /** Teklif anahtarı: ürün + mağaza. */ + key: string; + productId: string; + productLabel: string; + packageLabel: string; + store: StoreRef; + price: PriceRow; + /** Son kayıt süresi biten kampanyaydı; önceki normal fiyat gösteriliyor. */ + fellBack: boolean; + promoEnded: boolean; + ageDays: number; + stale: boolean; + unitPriceMinor: number | null; +}; + +export type ProductPriceDetail = { + product: ProductView; + offers: RankedOffer[]; + /** Tüm kayıtlar, yeniden eskiye. */ + history: PriceRow[]; + /** Grafik: mağaza serileri ve tarih noktaları (paket fiyatı, kuruş). */ + series: StoreRef[]; + points: Record[]; + stores: StoreRef[]; + canWrite: boolean; + today: string; +}; + +export type TypePriceComparison = { + typeUnit: string; + offers: RankedOffer[]; + /** Fiyat girişi seçenekleri. */ + products: PriceProductOption[]; + stores: StoreRef[]; + canWrite: boolean; +}; + +/** Hanenin tüm mağazaları; renk yuvası ekleniş sırasına göre sabit. */ +async function loadStores(householdId: Types.ObjectId) { + const rows = await StoreModel.find({ householdId }) + .select({ name: 1, branch: 1, archivedAt: 1, createdAt: 1 }) + .sort({ createdAt: 1, _id: 1 }) + .lean(); + return new Map( + rows.map((s, i) => [ + String(s._id), + { + id: String(s._id), + label: storeLabel(s), + slot: seriesSlot(i), + archived: Boolean(s.archivedAt), + } satisfies StoreRef, + ]), + ); +} + +const sortRecords = (a: PriceLean, b: PriceLean) => + a.date === b.date ? a.createdAt.getTime() - b.createdAt.getTime() : a.date < b.date ? -1 : 1; + +async function loadPrices(householdId: Types.ObjectId, productIds: Types.ObjectId[]) { + const rows = await ProductPriceModel.find({ + householdId, + productId: { $in: productIds }, + deletedAt: null, + }).lean(); + return rows.sort(sortRecords); +} + +function toRow(r: PriceLean, store: StoreRef, inTypeUnit: number | null, today: string): PriceRow { + return { + id: String(r._id), + productId: String(r.productId), + store, + date: r.date, + priceMinor: r.priceMinor, + isPromo: r.isPromo, + promoUntil: r.promoUntil, + promoEnded: promoEnded(r, today), + source: r.source, + note: r.note, + unitPriceMinor: inTypeUnit ? unitPrice(r.priceMinor, inTypeUnit) : null, + }; +} + +/** Ürün × mağaza başına geçerli fiyat teklifleri (sıralanmamış). */ +function offersFor( + products: ProductView[], + prices: PriceLean[], + stores: Map, + today: string, +): StoreOffer[] { + const groups = new Map(); + for (const r of prices) { + const key = `${r.productId}:${r.storeId}`; + groups.set(key, [...(groups.get(key) ?? []), r]); + } + const byId = new Map(products.map((p) => [p.id, p])); + const out: StoreOffer[] = []; + for (const [key, records] of groups) { + const p = byId.get(String(records[0]!.productId)); + const store = stores.get(String(records[0]!.storeId)); + if (!p || !store) continue; + const eff = effectivePrice(records, today)!; + const row = toRow(eff.record, store, p.inTypeUnit, today); + out.push({ + key, + productId: p.id, + productLabel: p.label, + packageLabel: p.packageLabel, + store, + price: row, + fellBack: eff.fellBack, + promoEnded: eff.promoEnded, + ageDays: eff.ageDays, + stale: eff.stale, + unitPriceMinor: row.unitPriceMinor, + }); + } + return out; +} + +const activeStores = (stores: Map) => + [...stores.values()].filter((s) => !s.archived).sort((a, b) => compareTr(a.label, b.label)); + +/** /stok/urunler/:id — ürünün mağaza bazında fiyatları, geçmişi ve grafiği. */ +export async function getProductPrices( + userId: string, + householdId: string, + productId: string, +): Promise { + const m = await resolveMembership(userId, householdId, "viewer"); + if (!m) return null; + const id = toObjectId(productId); + if (!id) return null; + const doc = await ProductModel.findOne({ _id: id, householdId: m.householdId }) + .select({ productTypeId: 1 }) + .lean<{ productTypeId: Types.ObjectId }>(); + if (!doc) return null; + const list = await listProducts(userId, householdId, { + productTypeId: String(doc.productTypeId), + includeArchived: true, + }); + const product = list?.products.find((p) => p.id === productId); + if (!product) return null; + const today = todayIstanbul(); + const [stores, prices] = await Promise.all([ + loadStores(m.householdId), + loadPrices(m.householdId, [id]), + ]); + const history = prices + .map((r) => toRow(r, stores.get(String(r.storeId))!, product.inTypeUnit, today)) + .filter((r) => r.store) + .reverse(); + const seriesIds = [...new Set(prices.map((r) => String(r.storeId)))]; + const series = seriesIds + .map((sid) => stores.get(sid)!) + .filter(Boolean) + .sort((a, b) => a.slot - b.slot); + // Aynı gün aynı mağazada birden çok kayıt: son giriş geçerli. + const byDate = new Map>(); + for (const r of prices) { + const point = byDate.get(r.date) ?? { date: r.date }; + point[`s${String(r.storeId)}`] = r.priceMinor; + byDate.set(r.date, point); + } + // Son nokta bugün: her mağazanın geçerli fiyatı (çizgi bugüne uzar; biten kampanyada normal fiyat). + const offers = offersFor([product], prices, stores, today); + if (offers.length) { + const point = byDate.get(today) ?? { date: today }; + for (const o of offers) point[`s${o.store.id}`] ??= o.price.priceMinor; + byDate.set(today, point); + } + return { + product, + offers: rankOffers(offers), + history, + series, + points: [...byDate.values()].sort((a, b) => (String(a.date) < String(b.date) ? -1 : 1)), + stores: activeStores(stores), + canWrite: hasHouseholdRole(m.role, "member"), + today, + }; +} + +/** Ürün tipi detayı: tüm marka/paket ve mağazalar birim fiyata göre. */ +export async function getTypePriceComparison( + userId: string, + householdId: string, + productTypeId: string, +): Promise { + const m = await resolveMembership(userId, householdId, "viewer"); + if (!m) return null; + const list = await listProducts(userId, householdId, { productTypeId }); + if (!list) return null; + const type = await ProductTypeModel.findOne({ + _id: new Types.ObjectId(productTypeId), + householdId: m.householdId, + }) + .select({ unit: 1 }) + .lean<{ unit: string }>(); + if (!type) return null; + const today = todayIstanbul(); + const [stores, prices] = await Promise.all([ + loadStores(m.householdId), + loadPrices( + m.householdId, + list.products.map((p) => new Types.ObjectId(p.id)), + ), + ]); + return { + typeUnit: type.unit, + offers: rankOffers( + offersFor(list.products, prices, stores, today).filter((o) => !o.store.archived), + ), + products: list.products.map(priceProductOption), + stores: activeStores(stores), + canWrite: list.canWrite, + }; +} + +/** + * Ürün tipi başına son bilinen birim fiyat (en yeni geçerli fiyat kaydı; tüm marka/mağazalar). + * Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır. + */ +export async function latestTypeUnitPrices( + householdId: Types.ObjectId, + productTypeIds: Types.ObjectId[], + today = todayIstanbul(), +): Promise> { + const [products, types] = await Promise.all([ + ProductModel.find({ householdId, productTypeId: { $in: productTypeIds } }) + .select({ productTypeId: 1, packageQuantity: 1, packageUnit: 1, typeQuantity: 1 }) + .lean< + { + _id: Types.ObjectId; + productTypeId: Types.ObjectId; + packageQuantity: number; + packageUnit: string; + typeQuantity: number | null; + }[] + >(), + ProductTypeModel.find({ householdId, _id: { $in: productTypeIds } }) + .select({ unit: 1 }) + .lean<{ _id: Types.ObjectId; unit: string }[]>(), + ]); + const unitOf = new Map(types.map((x) => [String(x._id), x.unit])); + const prices = await loadPrices( + householdId, + products.map((p) => p._id), + ); + const groups = new Map(); + for (const r of prices) { + const key = `${r.productId}:${r.storeId}`; + groups.set(key, [...(groups.get(key) ?? []), r]); + } + const productById = new Map(products.map((p) => [String(p._id), p])); + const out = new Map(); + for (const records of groups.values()) { + const p = productById.get(String(records[0]!.productId))!; + const typeId = String(p.productTypeId); + const inType = packageInTypeUnit({ + packageQuantity: p.packageQuantity, + packageUnit: p.packageUnit, + typeUnit: unitOf.get(typeId) ?? "", + typeQuantity: p.typeQuantity, + }); + if (!inType) continue; + const eff = effectivePrice(records, today)!; + const prev = out.get(typeId); + if (!prev || eff.record.date > prev.date) + out.set(typeId, { + unitPriceMinor: unitPrice(eff.record.priceMinor, inType), + date: eff.record.date, + }); + } + return out; +} + +// --- Yazma --- + +async function activeRefs(m: Membership, input: Pick) { + const [product, store] = await Promise.all([ + ProductModel.exists({ + _id: new Types.ObjectId(input.productId), + householdId: m.householdId, + archivedAt: null, + }), + StoreModel.exists({ + _id: new Types.ObjectId(input.storeId), + householdId: m.householdId, + archivedAt: null, + }), + ]); + if (!product) return fail(t.validation.product, { productId: t.validation.product }); + if (!store) return fail(t.validation.store, { storeId: t.validation.store }); + return null; +} + +/** Fiyat kaydı ekler. Gelecek tarihli fiyat girilemez. */ +export async function addProductPrice( + actorId: string, + householdId: string, + input: ProductPriceForm, + source: PriceSource = "manual", +): Promise> { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + if (input.date > todayIstanbul()) + return fail(t.validation.futureDate, { date: t.validation.futureDate }); + const bad = await activeRefs(m, input); + if (bad) return bad; + const doc = await ProductPriceModel.create({ + householdId: m.householdId, + productId: new Types.ObjectId(input.productId), + storeId: new Types.ObjectId(input.storeId), + date: input.date, + priceMinor: input.priceMinor, + isPromo: input.isPromo, + promoUntil: input.promoUntil, + source, + note: input.note, + createdBy: new Types.ObjectId(actorId), + }); + return ok({ id: String(doc._id) }); +} + +export async function deleteProductPrice( + actorId: string, + householdId: string, + priceId: string, +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const id = toObjectId(priceId); + const row = id + ? await ProductPriceModel.findOne({ + _id: id, + householdId: m.householdId, + deletedAt: null, + }).lean() + : null; + if (!row) return fail(tr.common.notFound); + const actor = new Types.ObjectId(actorId); + await ProductPriceModel.updateOne({ _id: row._id }, { $set: { deletedAt: new Date() } }); + await writeAudit({ + action: "price.delete", + actorId: actor, + householdId: m.householdId, + targetType: "productPrice", + targetId: priceId, + diff: { + productId: String(row.productId), + storeId: String(row.storeId), + date: row.date, + priceMinor: row.priceMinor, + }, + }); + return ok(undefined); +} + +export type { PriceProductOption }; diff --git a/src/features/stock/service.ts b/src/features/stock/service.ts index 229227f..b31b546 100644 --- a/src/features/stock/service.ts +++ b/src/features/stock/service.ts @@ -12,6 +12,7 @@ import { } from "@/features/categories/tree"; import { hasHouseholdRole } from "@/features/households/roles"; import { resolveMembership, type Membership } from "@/features/households/service"; +import { latestTypeUnitPrices } from "@/features/prices/service"; import { tr } from "@/i18n/tr"; import { fail, ok, type ActionResult } from "@/lib/action-result"; import { writeAudit } from "@/lib/audit"; @@ -24,6 +25,7 @@ import { StockCountModel } from "@/models/stock-count"; import { StockMovementModel } from "@/models/stock-movement"; import { todayIstanbul } from "@/shared/dates"; import { shiftMonth } from "@/shared/budget"; +import { QUANTITY_SCALE } from "@/shared/quantity"; import type { ProductTypeForm, StockCategoryForm, @@ -1242,6 +1244,10 @@ export type StockAnalysisRow = { avgMonthlyWasted: number | null; /** Fire / (kullanım + fire), %. */ wastePct: number | null; + /** Son bilinen birim fiyat (ürün tipi biriminde, kesirli kuruş) ve tarihi. */ + unitPrice: { minor: number; date: string } | null; + /** Aylık ortalama maliyet tahmini: ortalama tüketim × son birim fiyat (kuruş). */ + avgMonthlyCostMinor: number | null; /** Bu ay (bugüne kadar). */ thisMonth: MonthUsage; }; @@ -1264,10 +1270,11 @@ export async function getStockAnalysis( shiftMonth(thisMonth, i - ANALYSIS_MONTHS), ); const m = (await resolveMembership(userId, householdId, "viewer"))!; - const moves = await loadMoves( - m.householdId, - data.items.map((i) => new Types.ObjectId(i.id)), - ); + const ids = data.items.map((i) => new Types.ObjectId(i.id)); + const [moves, prices] = await Promise.all([ + loadMoves(m.householdId, ids), + latestTypeUnitPrices(m.householdId, ids, data.today), + ]); const rows = data.items.map((item): StockAnalysisRow => { const list = moves.get(item.id) ?? []; const [usage, current] = [monthlyUsage(list, months), monthlyUsage(list, [thisMonth])[0]!]; @@ -1277,6 +1284,8 @@ export async function getStockAnalysis( const sum = (k: "used" | "wasted") => full.reduce((s, u) => s + u[k], 0); const used = sum("used"); const wasted = sum("wasted"); + const price = prices.get(item.id) ?? null; + const avgUsed = full.length ? used / full.length : null; return { id: item.id, name: item.name, @@ -1287,6 +1296,11 @@ export async function getStockAnalysis( avgMonthlyUsed: full.length ? roundForDisplay(used / full.length) : null, avgMonthlyWasted: full.length ? roundForDisplay(wasted / full.length) : null, wastePct: used + wasted > 0 ? Math.round((wasted * 100) / (used + wasted)) : null, + unitPrice: price ? { minor: price.unitPriceMinor, date: price.date } : null, + avgMonthlyCostMinor: + price && avgUsed !== null + ? Math.round((avgUsed * price.unitPriceMinor) / QUANTITY_SCALE) + : null, thisMonth: current, }; }); diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index 6e10a0b..c4def65 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -981,7 +981,11 @@ export const tr = { analysis: { title: "Tüketim analizi", description: - "Ürün tipi başına aylık tüketim ve fire. Tüketim: girilen tüketimler ve sayımda çıkan eksikler. Maliyet, ürün fiyatları (Faz 12) gelince eklenecek.", + "Ürün tipi başına aylık tüketim, fire ve tahmini maliyet. Tüketim: girilen tüketimler ve sayımda çıkan eksikler. Maliyet: aylık ortalama tüketim × son bilinen birim fiyat.", + avgCost: "Aylık ort. maliyet", + costHint: (price: string, date: string) => `Son birim fiyat ${price} (${date})`, + noPrice: "Fiyat yok", + totalCost: (total: string) => `Fiyatı bilinen ürünlerde aylık tahmini maliyet: ${total}`, back: "Ev stoğuna dön", product: "Ürün tipi", avgUsed: "Aylık ort. tüketim", @@ -1348,6 +1352,65 @@ export const tr = { archived: "Ürün arşivli.", }, }, + productPrices: { + add: "Fiyat gir", + addTitle: "Fiyat gir", + comparisonTitle: "Birim fiyat karşılaştırması", + comparisonDescription: (unit: string) => + `Marka ve mağazaların son bilinen fiyatları, 1 ${unit} başına sıralı. Süresi biten kampanyada önceki normal fiyat kullanılır.`, + noPrices: "Henüz fiyat girilmedi.", + noProducts: "Fiyat girmek için önce bir ürün (marka/paket) ekleyin.", + cheapest: "En uygun", + diff: (pct: number) => `%${pct} pahalı`, + perUnit: "Birim fiyat", + packagePrice: "Paket fiyatı", + store: "Mağaza", + product: "Ürün", + date: "Tarih", + promo: "Kampanya", + promoUntil: (date: string) => `${date} tarihine kadar`, + promoEnded: "Kampanya bitti", + fellBack: "Kampanya bitti, normal fiyat", + stale: (days: number) => `${days} gün önce`, + staleHint: "90 günden eski fiyat; güncel olmayabilir.", + noConversion: "Dönüşüm yok", + sources: { manual: "Elle", receipt: "Fiş", provider: "Sağlayıcı" } as Record, + detail: { + back: "Ürün kataloğu", + typeLink: (name: string) => `Ürün tipi: ${name}`, + currentTitle: "Mağazalardaki fiyatlar", + currentDescription: "Her mağazadaki geçerli (son) fiyat; birim fiyata göre sıralı.", + chartTitle: "Fiyat geçmişi", + chartDescription: "Mağaza başına paket fiyatı; fiyat yeni kayda kadar geçerli sayılır.", + chartLabel: (name: string) => `${name}: mağaza başına fiyat geçmişi`, + historyTitle: "Fiyat kayıtları", + deletePrice: "Fiyat kaydını sil", + deleteTitle: "Fiyat kaydı silinsin mi?", + deleteDescription: "Kayıt fiyat geçmişinden ve karşılaştırmadan çıkarılır.", + images: "Görseller", + barcode: "Barkod", + package: "Paket", + }, + fields: { + product: "Ürün", + store: "Mağaza", + date: "Tarih", + price: "Paket fiyatı (₺)", + isPromo: "Kampanya / indirim", + promoUntil: "Kampanya bitişi", + promoUntilHint: "İsteğe bağlı. Bitince karşılaştırmada önceki normal fiyat kullanılır.", + note: "Not", + unitPreview: (value: string) => `Birim fiyat: ${value}`, + }, + toast: { saved: "Fiyat kaydedildi.", deleted: "Fiyat kaydı silindi." }, + validation: { + product: "Aktif bir ürün seçin", + store: "Aktif bir mağaza seçin", + price: "Geçerli bir fiyat girin (ör. 89,90)", + promoUntil: "Kampanya bitişi fiyat tarihinden önce olamaz", + futureDate: "Gelecek tarihli fiyat girilemez", + }, + }, prices: { title: "Fiyat tablosu", description: diff --git a/src/models/audit-log.ts b/src/models/audit-log.ts index 1abbc93..f7f3336 100644 --- a/src/models/audit-log.ts +++ b/src/models/audit-log.ts @@ -44,6 +44,7 @@ export const AUDIT_ACTIONS = [ "store.unarchive", "product.archive", "product.unarchive", + "price.delete", ] as const; export type AuditAction = (typeof AUDIT_ACTIONS)[number]; diff --git a/src/models/product-price.ts b/src/models/product-price.ts new file mode 100644 index 0000000..8eb9474 --- /dev/null +++ b/src/models/product-price.ts @@ -0,0 +1,38 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; + +/** + * Ürün fiyat kaydı (Bölüm 6.11): ürün × mağaza × tarih × paket fiyatı (kuruş). Kampanya işareti + * ve geçerlilik sonu; kaynak `manual` | `receipt` | `provider:`. Hane bazlı. Birim fiyat + * okuma sırasında ürünün dönüşümüyle hesaplanır (saklanmaz). + */ +const productPriceSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + productId: { type: Schema.Types.ObjectId, required: true }, + storeId: { type: Schema.Types.ObjectId, required: true }, + /** "YYYY-MM-DD" (İstanbul). */ + date: { type: String, required: true }, + priceMinor: { type: Number, required: true, min: 1 }, + isPromo: { type: Boolean, default: false }, + promoUntil: { type: String, default: null }, + source: { type: String, required: true, match: /^(manual|receipt|provider:[a-z0-9-]{1,40})$/ }, + note: { type: String, default: "", maxlength: 300 }, + deletedAt: { type: Date, default: null }, + seedRef: { type: String, default: null }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "productPrices" }, +); + +productPriceSchema.index({ householdId: 1, productId: 1, deletedAt: 1, date: 1, createdAt: 1 }); +productPriceSchema.index({ householdId: 1, storeId: 1, date: -1 }); +productPriceSchema.index( + { householdId: 1, seedRef: 1 }, + { unique: true, partialFilterExpression: { seedRef: { $type: "string" } } }, +); + +export type ProductPrice = InferSchemaType; + +export const ProductPriceModel: Model = + (models.ProductPrice as Model | undefined) ?? + model("ProductPrice", productPriceSchema); diff --git a/src/shared/prices.test.ts b/src/shared/prices.test.ts new file mode 100644 index 0000000..bcc9beb --- /dev/null +++ b/src/shared/prices.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + effectivePrice, + formatUnitPrice, + PRICE_SOURCE_PATTERN, + rankOffers, + seriesSlot, + unitPrice, + unitPriceBase, +} from "./prices"; + +const r = ( + date: string, + priceMinor: number, + isPromo = false, + promoUntil: string | null = null, +) => ({ + date, + priceMinor, + isPromo, + promoUntil, +}); + +describe("birim fiyat", () => { + it("paket fiyatı / ürün tipi birimindeki miktar; yuvarlama yalnızca gösterimde", () => { + // Ariel 5 kg = 33 yıkama, 519,90 TL → 15,754… TL/yıkama. + const u = unitPrice(519_90, 33_000); + expect(u).toBeCloseTo(1575.4545, 3); + expect(formatUnitPrice(u, "yıkama")).toMatch(/15,75\/yıkama$/); + // 500 g beyaz peynir 210 TL, ürün tipi g olsaydı: 0,42 TL/g → 420 TL/kg. + expect(formatUnitPrice(unitPrice(210_00, 500_000), "g")).toMatch(/420,00\/kg$/); + expect(unitPriceBase("ml")).toEqual({ unit: "lt", factor: 1000 }); + expect(unitPriceBase("adet")).toEqual({ unit: "adet", factor: 1 }); + }); + + it("kaynak biçimi", () => { + expect( + ["manual", "receipt", "provider:marketfiyati"].every((s) => PRICE_SOURCE_PATTERN.test(s)), + ).toBe(true); + expect(PRICE_SOURCE_PATTERN.test("provider:")).toBe(false); + expect(PRICE_SOURCE_PATTERN.test("scrape")).toBe(false); + }); +}); + +describe("geçerli fiyat", () => { + const today = "2026-11-06"; + + it("son kayıt; süresi biten kampanyada önceki normal fiyata dönülür", () => { + expect(effectivePrice([], today)).toBeNull(); + const normal = effectivePrice([r("2026-10-01", 100_00), r("2026-11-01", 110_00)], today)!; + expect(normal).toMatchObject({ record: { priceMinor: 110_00 }, fellBack: false, ageDays: 5 }); + const ended = effectivePrice( + [r("2026-10-01", 100_00), r("2026-10-20", 80_00, true, "2026-10-31")], + today, + )!; + expect(ended).toMatchObject({ + record: { priceMinor: 100_00 }, + fellBack: true, + promoEnded: false, + }); + const active = effectivePrice( + [r("2026-10-01", 100_00), r("2026-11-02", 80_00, true, "2026-11-10")], + today, + )!; + expect(active).toMatchObject({ record: { priceMinor: 80_00 }, fellBack: false }); + // Dönülecek normal fiyat yoksa kampanya fiyatı işaretle. + const only = effectivePrice([r("2026-10-20", 80_00, true, "2026-10-31")], today)!; + expect(only).toMatchObject({ fellBack: false, promoEnded: true }); + expect(effectivePrice([r("2026-07-01", 1)], today)!.stale).toBe(true); + expect(effectivePrice([r("2026-09-01", 1)], today)!.stale).toBe(false); + }); +}); + +describe("sıralama", () => { + it("birim fiyata göre artan, eşitler en uygun, hesaplanamayan sonda", () => { + const ranked = rankOffers([ + { id: "a", unitPriceMinor: 1_500 }, + { id: "b", unitPriceMinor: null }, + { id: "c", unitPriceMinor: 1_200 }, + { id: "d", unitPriceMinor: 1_200 }, + { id: "e", unitPriceMinor: 1_800 }, + ]); + expect(ranked.map((o) => [o.id, o.rank, o.cheapest, o.diffPct])).toEqual([ + ["c", 1, true, 0], + ["d", 2, true, 0], + ["a", 3, false, 25], + ["e", 4, false, 50], + ["b", null, false, null], + ]); + expect([seriesSlot(0), seriesSlot(7), seriesSlot(8)]).toEqual([1, 8, 1]); + }); +}); diff --git a/src/shared/prices.ts b/src/shared/prices.ts new file mode 100644 index 0000000..5ccf51f --- /dev/null +++ b/src/shared/prices.ts @@ -0,0 +1,124 @@ +import { daysBetween } from "./budget"; +import { formatMoney } from "./format"; +import { QUANTITY_SCALE } from "./quantity"; + +/** + * Ürün fiyatı ve birim fiyat motoru (Bölüm 6.11). Saf fonksiyonlar. + * + * Fiyat paket fiyatıdır (kuruş). Birim fiyat, ürün tipinin biriminde 1 birimin fiyatıdır: + * birim fiyat = paket fiyatı × 1000 / (paketin ürün tipi birimindeki miktarı, binde bir) + * Kesirli kuruş olarak tutulur; yuvarlama yalnızca gösterimde. Gösterimde g → kg, ml → lt. + */ + +export const PRICE_SOURCE_PATTERN = /^(manual|receipt|provider:[a-z0-9-]{1,40})$/; +export type PriceSource = "manual" | "receipt" | `provider:${string}`; + +/** Bu kadar günden eski fiyat "eski" sayılır. */ +export const PRICE_STALE_DAYS = 90; + +export function unitPrice(priceMinor: number, packageInTypeUnit: number): number { + return (priceMinor * QUANTITY_SCALE) / packageInTypeUnit; +} + +/** Gösterim birimi: küçük birimler büyüğe (g → kg, ml → lt). */ +export function unitPriceBase(typeUnit: string): { unit: string; factor: number } { + if (typeUnit === "g") return { unit: "kg", factor: 1000 }; + if (typeUnit === "ml") return { unit: "lt", factor: 1000 }; + return { unit: typeUnit, factor: 1 }; +} + +/** 4250 kuruş/kg → "₺42,50/kg"; g biriminde 4,25 kuruş/g → "₺42,50/kg". */ +export function formatUnitPrice(unitPriceMinor: number, typeUnit: string): string { + const base = unitPriceBase(typeUnit); + return `${formatMoney(Math.round(unitPriceMinor * base.factor))}/${base.unit}`; +} + +export type PriceRecordLike = { + date: string; + priceMinor: number; + isPromo: boolean; + /** Kampanya geçerlilik sonu (dahil); yoksa süresiz. */ + promoUntil: string | null; +}; + +export const promoEnded = (r: PriceRecordLike, today: string) => + r.isPromo && r.promoUntil !== null && r.promoUntil < today; + +export type EffectivePrice = { + record: T; + /** Son kayıt süresi biten kampanyaydı; önceki normal fiyata dönüldü. */ + fellBack: boolean; + /** Geçerli fiyat süresi biten kampanya (dönülecek normal fiyat yok). */ + promoEnded: boolean; + ageDays: number; + stale: boolean; +}; + +/** + * Geçerli fiyat: tarih (ve giriş) sırasındaki son kayıt. Son kayıt süresi biten kampanyaysa, + * ondan önceki son kampanyasız (veya hâlâ geçerli kampanyalı) kayda dönülür; yoksa kampanya + * fiyatı işaretle kullanılır. `records` eskiden yeniye sıralı olmalı. + */ +export function effectivePrice( + records: readonly T[], + today: string, +): EffectivePrice | null { + const latest = records.at(-1); + if (!latest) return null; + let record = latest; + let fellBack = false; + if (promoEnded(latest, today)) { + const prev = [...records].reverse().find((r) => !promoEnded(r, today)); + if (prev) { + record = prev; + fellBack = true; + } + } + const ageDays = record.date <= today ? daysBetween(record.date, today) - 1 : 0; + return { + record, + fellBack, + promoEnded: promoEnded(record, today), + ageDays, + stale: ageDays > PRICE_STALE_DAYS, + }; +} + +export type Offer = { unitPriceMinor: number | null }; + +export type RankedOffer = T & { + rank: number | null; + cheapest: boolean; + /** En uygundan yüzde fark (tamsayı); en uygunda 0. */ + diffPct: number | null; +}; + +/** + * Birim fiyata göre sıralar (artan); birim fiyatı hesaplanamayanlar (dönüşüm eksik) sonda. + * En düşük birim fiyatlı tüm teklifler (eşitlik dahil) "en uygun". + */ +export function rankOffers(offers: readonly T[]): RankedOffer[] { + const priced = offers.filter((o) => o.unitPriceMinor !== null); + const min = priced.length ? Math.min(...priced.map((o) => o.unitPriceMinor!)) : null; + const sorted = [...offers].sort((a, b) => { + if (a.unitPriceMinor === null || b.unitPriceMinor === null) + return a.unitPriceMinor === null ? (b.unitPriceMinor === null ? 0 : 1) : -1; + return a.unitPriceMinor - b.unitPriceMinor; + }); + let rank = 0; + return sorted.map((o) => { + if (o.unitPriceMinor === null || min === null) + return { ...o, rank: null, cheapest: false, diffPct: null }; + rank++; + return { + ...o, + rank, + cheapest: Math.abs(o.unitPriceMinor - min) < 1e-9, + diffPct: min > 0 ? Math.round(((o.unitPriceMinor - min) * 100) / min) : 0, + }; + }); +} + +/** Grafik/legend için mağaza renk yuvası (1–8): mağazanın hanedeki ekleniş sırasına göre sabit. */ +export const SERIES_SLOTS = 8; +export const seriesSlot = (storeIndex: number) => (storeIndex % SERIES_SLOTS) + 1; diff --git a/src/shared/schemas/product-price.ts b/src/shared/schemas/product-price.ts new file mode 100644 index 0000000..0cc2a26 --- /dev/null +++ b/src/shared/schemas/product-price.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import { tr } from "../../i18n/tr"; +import { parseMoney } from "../money"; + +const v = tr.validation; +const s = tr.productPrices.validation; +const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date); +const id = (message: string) => z.string().regex(/^[a-f0-9]{24}$/i, message); + +/** Elle fiyat girişi (kaynak: manual). Paket fiyatı TL; kampanyada geçerlilik sonu isteğe bağlı. */ +export const productPriceFormSchema = z + .object({ + productId: id(s.product), + storeId: id(s.store), + date: ymd, + price: z + .string() + .trim() + .transform((x, ctx) => { + const minor = parseMoney(x); + if (minor === null || minor <= 0) { + ctx.addIssue({ code: "custom", message: s.price }); + return z.NEVER; + } + return minor; + }), + isPromo: z.boolean(), + promoUntil: z.preprocess((x) => (x === "" || x === undefined ? null : x), ymd.nullable()), + note: z.string().trim().max(300).default(""), + }) + .superRefine((x, ctx) => { + if (x.isPromo && x.promoUntil && x.promoUntil < x.date) + ctx.addIssue({ code: "custom", path: ["promoUntil"], message: s.promoUntil }); + }) + .transform(({ price, ...x }) => ({ + ...x, + priceMinor: price, + promoUntil: x.isPromo ? x.promoUntil : null, + })); +export type ProductPriceFormInput = z.input; +export type ProductPriceForm = z.output;