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:
@@ -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<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;
|
||||
Reference in New Issue
Block a user