e67a1d5411
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
125 lines
4.4 KiB
TypeScript
125 lines
4.4 KiB
TypeScript
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<T> = {
|
||
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<T extends PriceRecordLike>(
|
||
records: readonly T[],
|
||
today: string,
|
||
): EffectivePrice<T> | 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> = 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<T extends Offer>(offers: readonly T[]): RankedOffer<T>[] {
|
||
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;
|