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
+92
View File
@@ -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]);
});
});
+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;
+41
View File
@@ -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<typeof productPriceFormSchema>;
export type ProductPriceForm = z.output<typeof productPriceFormSchema>;