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,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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user