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;