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:
2026-11-06 12:27:51 +03:00
parent 78503b1787
commit e67a1d5411
16 changed files with 1212 additions and 6 deletions
+124
View File
@@ -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;