feat(part-prices): parça kodu bazlı tedarikçi fiyat geçmişi + OEM sayfasında p50/p95/p99 grafiği
- pg: part_price_tracks + part_price_daily (0018) — (kod, kaynak, gün) başına stoktaki tekliflerin p50/p95/p99 + teklif sayısı; source='supplier' şimdilik, perakende ileride aynı tabloya 'retail' olarak girer. Tedarikçi kimliği yok. - API: GET /part-prices/series (ilk istekte takip history'sinden lazy-backfill, sonrası salt-pg + Redis) ve POST /part-prices/current-batch (sayfadaki kodlar için canlı güncel istatistik). P-servisi sözleşmesi: asla throw yok, fail-open. - Worker: part-price-refresh cron'u 19:30 Europe/Istanbul (takip sync'i 19:05'te bitiyor) — izlenen kodlara bugünün satırını upsert eder, sku_map'i artımlı bakar, Redis cache düşürür. SUPPLIER_PRICE_DB_* yoksa sessiz no-op. - Kaynak köprüsü: takip.sku_map (code_norm → product_id; tam sku / ilk-boşluk / ilk-tire sonrası normalize adayları) vmi MySQL'inde kurulu; 6,8M satır. - Web: OEM detayında "Tedarikçi fiyat analizi" kartı (güncel medyan + P95/P99 + teklif sayısı + 30g delta, 30G/90G/Tümü aralıklı step grafik, recharts) ve article/muadil/OE satırlarında fiyat çipi → dialog'da tam geçmiş. - Fix(p): td snapshot'ında gerçek üretici kodu articles.name'de (article_number %96 upstream sayısal ID) — sayfa artık kopyalanabilir gerçek kodu gösteriyor. - compose: SUPPLIER_PRICE_DB_ENABLED/URL api+worker bloklarına eklendi. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^9.1.0",
|
||||
"recharts": "^3.8.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remotion": "^4.0.422",
|
||||
"sileo": "^0.0.7",
|
||||
|
||||
136
apps/web/src/components/catalog/part-price-chart.tsx
Normal file
136
apps/web/src/components/catalog/part-price-chart.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
type ChartDatum,
|
||||
formatChartDate,
|
||||
formatDateLong,
|
||||
formatTry,
|
||||
formatTryCompact,
|
||||
} from "@/lib/part-prices";
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
/** Step grafiği — fiyatlar değişim noktalı geldiği için basamaklı çizilir;
|
||||
* P50 dolgu alanlı ana seri, P95 kesikli, P99 noktalı yardımcı çizgiler.
|
||||
* Renkler tema token'larından (koyu temada otomatik uyumlu). */
|
||||
|
||||
interface TooltipRowProps {
|
||||
label: string;
|
||||
value: number | null;
|
||||
swatchClass: string;
|
||||
}
|
||||
|
||||
function TooltipRow({ label, value, swatchClass }: TooltipRowProps) {
|
||||
if (value === null) return null;
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<span className={`inline-block h-0.5 w-3 rounded-full ${swatchClass}`} />
|
||||
{label}
|
||||
</span>
|
||||
<span className="font-mono font-medium tabular-nums">{formatTry(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PriceTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: ReadonlyArray<{ payload?: ChartDatum }>;
|
||||
}
|
||||
|
||||
function PriceTooltip({ active, payload }: PriceTooltipProps) {
|
||||
const datum = payload?.[0]?.payload;
|
||||
if (!active || !datum) return null;
|
||||
return (
|
||||
<div className="min-w-40 rounded-lg border border-border bg-popover px-3 py-2 text-xs shadow-md">
|
||||
<p className="mb-1.5 font-medium">{formatDateLong(datum.date)}</p>
|
||||
{datum.offerCount === 0 ? (
|
||||
<p className="text-muted-foreground">Stokta teklif yok</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<TooltipRow label="Medyan (P50)" value={datum.p50} swatchClass="bg-brand" />
|
||||
<TooltipRow label="P95" value={datum.p95} swatchClass="bg-muted-foreground" />
|
||||
<TooltipRow label="P99" value={datum.p99} swatchClass="bg-muted-foreground/60" />
|
||||
<p className="pt-0.5 text-muted-foreground">{datum.offerCount} stoktaki teklif</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PartPriceChart({ data }: { data: ChartDatum[] }) {
|
||||
return (
|
||||
<div className="h-52 w-full" aria-hidden>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={data} margin={{ top: 8, right: 8, bottom: 0, left: 4 }}>
|
||||
<defs>
|
||||
<linearGradient id="partPriceP50Fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-brand)" stopOpacity={0.22} />
|
||||
<stop offset="100%" stopColor="var(--color-brand)" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke="var(--color-border)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="ts"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={formatChartDate}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }}
|
||||
tickMargin={8}
|
||||
minTickGap={48}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatTryCompact}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }}
|
||||
width={64}
|
||||
domain={["auto", "auto"]}
|
||||
/>
|
||||
<Tooltip content={<PriceTooltip />} cursor={{ stroke: "var(--color-border)" }} />
|
||||
<Area
|
||||
type="stepAfter"
|
||||
dataKey="p50"
|
||||
stroke="var(--color-brand)"
|
||||
strokeWidth={2}
|
||||
fill="url(#partPriceP50Fill)"
|
||||
connectNulls={false}
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="stepAfter"
|
||||
dataKey="p95"
|
||||
stroke="var(--color-muted-foreground)"
|
||||
strokeWidth={1.25}
|
||||
strokeDasharray="5 4"
|
||||
connectNulls={false}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="stepAfter"
|
||||
dataKey="p99"
|
||||
stroke="var(--color-muted-foreground)"
|
||||
strokeOpacity={0.55}
|
||||
strokeWidth={1.25}
|
||||
strokeDasharray="2 4"
|
||||
connectNulls={false}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
apps/web/src/components/catalog/part-price-dialog.tsx
Normal file
64
apps/web/src/components/catalog/part-price-dialog.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { type PartPriceCurrent, formatTry } from "@/lib/part-prices";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
cn,
|
||||
} from "@sase/ui";
|
||||
import { ChartLine } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { PartPriceSection } from "./part-price-section";
|
||||
|
||||
/**
|
||||
* Parça satırındaki fiyat çipi: güncel medyan + grafik ikonu; tıklanınca o
|
||||
* kodun tam fiyat-geçmişi dialog'unu açar (seri ancak o zaman çekilir —
|
||||
* sayfadaki her satır için backfill tetiklememek bilinçli).
|
||||
*/
|
||||
interface PartPriceChipProps {
|
||||
code: string;
|
||||
brand?: string;
|
||||
current: PartPriceCurrent;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PartPriceChip({ code, brand, current, className }: PartPriceChipProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
setOpen(o);
|
||||
if (o) capture("part_price_chart_opened", { code, brand });
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title="Fiyat geçmişini gör"
|
||||
className={cn(
|
||||
"group/price inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-1.5 py-0.5 text-xs transition-colors hover:border-brand/40 hover:bg-brand-soft",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="font-mono font-medium tabular-nums">{formatTry(current.p50)}</span>
|
||||
<span className="text-muted-foreground">· {current.offerCount}</span>
|
||||
<ChartLine className="size-3 text-muted-foreground transition-colors group-hover/price:text-brand" />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex flex-wrap items-baseline gap-2">
|
||||
{brand && <span>{brand}</span>}
|
||||
<span className="font-mono">{code}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>Tedarikçi fiyat geçmişi</DialogDescription>
|
||||
</DialogHeader>
|
||||
{open && <PartPriceSection code={code} variant="plain" />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
199
apps/web/src/components/catalog/part-price-section.tsx
Normal file
199
apps/web/src/components/catalog/part-price-section.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import {
|
||||
type PartPriceSeriesView,
|
||||
computeDeltaPct,
|
||||
formatDateLong,
|
||||
formatTry,
|
||||
istanbulTodayIso,
|
||||
normPartCode,
|
||||
prepareChartData,
|
||||
} from "@/lib/part-prices";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { Skeleton, Tabs, TabsList, TabsTrigger, cn } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { PartPriceChart } from "./part-price-chart";
|
||||
|
||||
/**
|
||||
* Bir parça kodunun tedarikçi fiyat görünümü: güncel alan (medyan + P95/P99 +
|
||||
* teklif sayısı + 30 günlük değişim) ve p50/p95/p99 step grafiği. Veri yoksa
|
||||
* hiçbir şey çizmez (fail-open). Tedarikçi kimliği hiçbir yerde gösterilmez.
|
||||
*/
|
||||
|
||||
const RANGES = [
|
||||
{ key: "30", label: "30G", days: 30 },
|
||||
{ key: "90", label: "90G", days: 90 },
|
||||
{ key: "all", label: "Tümü", days: null },
|
||||
] as const;
|
||||
|
||||
type RangeKey = (typeof RANGES)[number]["key"];
|
||||
|
||||
export function usePartPriceSeries(code: string, enabled = true) {
|
||||
const codeNorm = normPartCode(code);
|
||||
return useQuery({
|
||||
queryKey: ["part-price-series", codeNorm],
|
||||
enabled: enabled && codeNorm.length >= 5,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
queryFn: () =>
|
||||
api.get<PartPriceSeriesView>(`/part-prices/series?code=${encodeURIComponent(code)}`),
|
||||
});
|
||||
}
|
||||
|
||||
function DeltaBadge({ pct }: { pct: number }) {
|
||||
// Alıcı bakışı: fiyat artışı kırmızı, düşüş yeşil. ±%0,5 altını "stabil" say.
|
||||
if (Math.abs(pct) < 0.5) return null;
|
||||
const up = pct > 0;
|
||||
const Icon = up ? TrendingUp : TrendingDown;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-medium",
|
||||
up ? "bg-destructive/10 text-destructive" : "bg-brand-soft text-brand",
|
||||
)}
|
||||
title="Son 30 gündeki medyan değişimi"
|
||||
>
|
||||
<Icon className="size-3.5" />%
|
||||
{Math.abs(pct).toLocaleString("tr-TR", { maximumFractionDigits: 1 })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface PartPriceSectionProps {
|
||||
code: string;
|
||||
/** Ana sayfa yerleşiminde kart çerçevesi; dialog içinde çıplak. */
|
||||
variant?: "card" | "plain";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PartPriceSection({ code, variant = "card", className }: PartPriceSectionProps) {
|
||||
const { data, isLoading } = usePartPriceSeries(code);
|
||||
const [range, setRange] = useState<RangeKey>("all");
|
||||
const todayIso = useMemo(() => istanbulTodayIso(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.matched) {
|
||||
capture("part_price_viewed", {
|
||||
code: data.codeNorm,
|
||||
points: data.series.length,
|
||||
offers: data.latest?.offerCount ?? 0,
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data?.matched) return [];
|
||||
const days = RANGES.find((r) => r.key === range)?.days ?? null;
|
||||
return prepareChartData(data.series, days, todayIso);
|
||||
}, [data, range, todayIso]);
|
||||
|
||||
if (isLoading) {
|
||||
// Kart yerleşiminde yükleme iskeleti GÖSTERME: çoğu kodun verisi yok ve
|
||||
// "iskelet görünüp kaybolma" titremesi yaratır — veri gelince belirir.
|
||||
// Dialog'da kullanıcı bilinçli tıkladı; iskelet beklenen geri bildirim.
|
||||
if (variant === "plain") {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!data?.matched || data.series.length === 0) return null;
|
||||
|
||||
const { latest } = data;
|
||||
const deltaPct = computeDeltaPct(data.series, 30, todayIso);
|
||||
const lastPriced = [...data.series].reverse().find((p) => p.p50 !== null) ?? null;
|
||||
const inStock = latest !== null && latest.p50 !== null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
variant === "card" && "rounded-2xl border border-border bg-background p-4 sm:p-5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Tedarikçi fiyat analizi</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Stoktaki tekliflerin dağılımı — P50 medyan · P95 · P99
|
||||
</p>
|
||||
</div>
|
||||
<Tabs value={range} onValueChange={(v) => setRange(v as RangeKey)}>
|
||||
<TabsList className="h-8">
|
||||
{RANGES.map((r) => (
|
||||
<TabsTrigger key={r.key} value={r.key} className="px-2.5 py-1 text-xs">
|
||||
{r.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* ─── Güncel fiyatlar ─────────────────────────────────────────── */}
|
||||
<div className="mt-4 flex flex-wrap items-end gap-x-6 gap-y-3">
|
||||
{inStock && latest.p50 !== null ? (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Güncel medyan
|
||||
</p>
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
<span className="text-2xl font-bold tabular-nums tracking-tight">
|
||||
{formatTry(latest.p50)}
|
||||
</span>
|
||||
{deltaPct !== null && <DeltaBadge pct={deltaPct} />}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Güncel durum
|
||||
</p>
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Şu an stokta teklif yok
|
||||
</span>
|
||||
{lastPriced?.p50 != null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
son fiyat {formatTry(lastPriced.p50)} ({formatDateLong(lastPriced.date)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{inStock && (
|
||||
<dl className="flex items-center gap-4 text-xs">
|
||||
{latest.p95 !== null && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">P95</dt>
|
||||
<dd className="font-mono font-medium tabular-nums">{formatTry(latest.p95)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{latest.p99 !== null && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">P99</dt>
|
||||
<dd className="font-mono font-medium tabular-nums">{formatTry(latest.p99)}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Teklif</dt>
|
||||
<dd className="font-medium tabular-nums">{latest.offerCount}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Grafik ──────────────────────────────────────────────────── */}
|
||||
<div className="mt-4">
|
||||
<PartPriceChart data={chartData} />
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
|
||||
Fiyatlar stoktaki tedarikçi tekliflerinin istatistiksel dağılımıdır; tedarikçi bilgisi
|
||||
paylaşılmaz. Son güncelleme: {formatDateLong(data.series[data.series.length - 1].date)}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
80
apps/web/src/lib/__tests__/part-prices.test.ts
Normal file
80
apps/web/src/lib/__tests__/part-prices.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type PartPricePoint,
|
||||
computeDeltaPct,
|
||||
formatTry,
|
||||
normPartCode,
|
||||
prepareChartData,
|
||||
} from "../part-prices";
|
||||
|
||||
const pt = (date: string, p50: number | null, offerCount = 3): PartPricePoint => ({
|
||||
date,
|
||||
p50,
|
||||
p95: p50 === null ? null : p50 * 1.5,
|
||||
p99: p50 === null ? null : p50 * 2,
|
||||
offerCount: p50 === null ? 0 : offerCount,
|
||||
});
|
||||
|
||||
const TODAY = "2026-06-12";
|
||||
|
||||
describe("prepareChartData", () => {
|
||||
const series = [pt("2026-01-29", 100), pt("2026-03-10", 120), pt("2026-06-01", 90)];
|
||||
|
||||
it("tüm seriyi bugüne uzatır", () => {
|
||||
const data = prepareChartData(series, null, TODAY);
|
||||
expect(data).toHaveLength(4);
|
||||
expect(data[0].date).toBe("2026-01-29");
|
||||
expect(data[3]).toMatchObject({ date: TODAY, p50: 90 });
|
||||
});
|
||||
|
||||
it("pencere kesiminde carry-forward çapa noktası koyar", () => {
|
||||
const data = prepareChartData(series, 30, TODAY); // pencere 2026-05-13'ten itibaren
|
||||
expect(data[0]).toMatchObject({ date: "2026-05-13", p50: 120 }); // çapa: son bilinen
|
||||
expect(data[1]).toMatchObject({ date: "2026-06-01", p50: 90 });
|
||||
expect(data[data.length - 1]).toMatchObject({ date: TODAY, p50: 90 });
|
||||
});
|
||||
|
||||
it("pencerede hiç nokta yoksa sabit çizgi üretir", () => {
|
||||
const flat = [pt("2026-02-01", 100)];
|
||||
const data = prepareChartData(flat, 7, TODAY);
|
||||
expect(data).toHaveLength(2);
|
||||
expect(data[0].p50).toBe(100);
|
||||
expect(data[1]).toMatchObject({ date: TODAY, p50: 100 });
|
||||
});
|
||||
|
||||
it("boş seri boş döner", () => {
|
||||
expect(prepareChartData([], null, TODAY)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDeltaPct", () => {
|
||||
it("pencere başı carry-forward tabanına göre yüzde hesaplar", () => {
|
||||
const series = [pt("2026-01-29", 100), pt("2026-06-01", 110)];
|
||||
expect(computeDeltaPct(series, 30, TODAY)).toBeCloseTo(10);
|
||||
});
|
||||
|
||||
it("seri pencere içinde başladıysa ilk fiyatlı noktayı taban alır", () => {
|
||||
const series = [pt("2026-06-05", 200), pt("2026-06-10", 150)];
|
||||
expect(computeDeltaPct(series, 30, TODAY)).toBeCloseTo(-25);
|
||||
});
|
||||
|
||||
it("null-fiyat (stok yok) noktalarını atlar", () => {
|
||||
const series = [pt("2026-01-29", 100), pt("2026-05-01", null), pt("2026-06-10", 130)];
|
||||
expect(computeDeltaPct(series, 90, TODAY)).toBeCloseTo(30);
|
||||
});
|
||||
|
||||
it("hiç fiyat yoksa null döner", () => {
|
||||
expect(computeDeltaPct([pt("2026-06-01", null)], 30, TODAY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normPartCode / formatTry", () => {
|
||||
it("kod normalizasyonu API ile aynı", () => {
|
||||
expect(normPartCode("1j0 973-702")).toBe("1J0973702");
|
||||
});
|
||||
|
||||
it("₺ biçimi: küçükte kuruş, binlikte tam sayı", () => {
|
||||
expect(formatTry(312.78)).toContain("312,78");
|
||||
expect(formatTry(54297)).not.toContain(",");
|
||||
});
|
||||
});
|
||||
155
apps/web/src/lib/part-prices.ts
Normal file
155
apps/web/src/lib/part-prices.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/** Tedarikçi fiyat görünümü yardımcıları — API'nin part-prices uçlarıyla eş tipler
|
||||
* + grafik hazırlama (carry-forward) ve 30 günlük değişim hesabı. */
|
||||
|
||||
export interface PartPricePoint {
|
||||
/** YYYY-MM-DD */
|
||||
date: string;
|
||||
p50: number | null;
|
||||
p95: number | null;
|
||||
p99: number | null;
|
||||
offerCount: number;
|
||||
}
|
||||
|
||||
export interface PartPriceSeriesView {
|
||||
matched: boolean;
|
||||
codeNorm: string;
|
||||
currency: "TRY";
|
||||
source: "supplier";
|
||||
series: PartPricePoint[];
|
||||
latest: PartPricePoint | null;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface PartPriceCurrent {
|
||||
p50: number;
|
||||
p95: number;
|
||||
p99: number;
|
||||
offerCount: number;
|
||||
}
|
||||
|
||||
export interface PartPriceBatchView {
|
||||
prices: Record<string, PartPriceCurrent>;
|
||||
}
|
||||
|
||||
/** API'nin normPartCode'u ve sayfanın normCode'uyla aynı kural. */
|
||||
export const normPartCode = (s: string) => s.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||
|
||||
export interface ChartDatum {
|
||||
/** epoch ms — XAxis type="number" gerçek zaman ölçeği için */
|
||||
ts: number;
|
||||
date: string;
|
||||
p50: number | null;
|
||||
p95: number | null;
|
||||
p99: number | null;
|
||||
offerCount: number;
|
||||
}
|
||||
|
||||
const toTs = (iso: string) => new Date(`${iso}T00:00:00Z`).getTime();
|
||||
|
||||
/**
|
||||
* Seyrek değişim-noktası serisini grafik verisine çevirir:
|
||||
* - `days` verilirse pencereyi keser ve pencere başına carry-forward çapa
|
||||
* noktası koyar (çizgi grafiğin ortasından başlamasın),
|
||||
* - son değeri bugüne uzatır (step çizgisi "şimdi"ye kadar sürer).
|
||||
*/
|
||||
export function prepareChartData(
|
||||
series: PartPricePoint[],
|
||||
days: number | null,
|
||||
todayIso: string,
|
||||
): ChartDatum[] {
|
||||
if (series.length === 0) return [];
|
||||
const todayTs = toTs(todayIso);
|
||||
const cutoffTs = days === null ? Number.NEGATIVE_INFINITY : todayTs - days * 86_400_000;
|
||||
|
||||
const out: ChartDatum[] = [];
|
||||
let anchor: PartPricePoint | null = null;
|
||||
for (const pt of series) {
|
||||
const ts = toTs(pt.date);
|
||||
if (ts < cutoffTs) {
|
||||
anchor = pt;
|
||||
continue;
|
||||
}
|
||||
if (anchor && out.length === 0 && ts > cutoffTs) {
|
||||
out.push({ ...anchor, ts: cutoffTs, date: new Date(cutoffTs).toISOString().slice(0, 10) });
|
||||
}
|
||||
anchor = null;
|
||||
out.push({ ...pt, ts });
|
||||
}
|
||||
// Pencere içinde hiç nokta yoksa son bilinen değer pencere boyunca sabittir.
|
||||
if (out.length === 0 && anchor) {
|
||||
out.push({ ...anchor, ts: cutoffTs, date: new Date(cutoffTs).toISOString().slice(0, 10) });
|
||||
}
|
||||
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.ts < todayTs) {
|
||||
out.push({ ...last, ts: todayTs, date: todayIso });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Son `days` gündeki medyan değişimi (%). Karşılaştırma tabanı pencere
|
||||
* başındaki carry-forward p50'dir; iki uç da fiyatlıysa hesaplanır. */
|
||||
export function computeDeltaPct(
|
||||
series: PartPricePoint[],
|
||||
days: number,
|
||||
todayIso: string,
|
||||
): number | null {
|
||||
const cutoffTs = toTs(todayIso) - days * 86_400_000;
|
||||
let base: number | null = null;
|
||||
let latest: number | null = null;
|
||||
for (const pt of series) {
|
||||
if (toTs(pt.date) <= cutoffTs && pt.p50 !== null) base = pt.p50;
|
||||
if (pt.p50 !== null) latest = pt.p50;
|
||||
}
|
||||
if (base === null) {
|
||||
// Pencereden eski hiç nokta yok — seri pencere içinde başlamış; ilk
|
||||
// fiyatlı noktayı taban al (yoksa delta anlamsız).
|
||||
const first = series.find((p) => p.p50 !== null);
|
||||
base = first?.p50 ?? null;
|
||||
}
|
||||
if (base === null || latest === null || base === 0) return null;
|
||||
return ((latest - base) / base) * 100;
|
||||
}
|
||||
|
||||
const tryFmt = new Intl.NumberFormat("tr-TR", {
|
||||
style: "currency",
|
||||
currency: "TRY",
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 0,
|
||||
});
|
||||
const tryFmtWhole = new Intl.NumberFormat("tr-TR", {
|
||||
style: "currency",
|
||||
currency: "TRY",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
const tryFmtCompact = new Intl.NumberFormat("tr-TR", {
|
||||
style: "currency",
|
||||
currency: "TRY",
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
/** ₺1.234,56 (küçük tutarlarda kuruş, ≥1000'de tam sayı). */
|
||||
export function formatTry(v: number): string {
|
||||
return v >= 1000 ? tryFmtWhole.format(v) : tryFmt.format(v);
|
||||
}
|
||||
|
||||
/** Eksen etiketi: ₺1,2 B gibi kompakt. */
|
||||
export function formatTryCompact(v: number): string {
|
||||
return tryFmtCompact.format(v);
|
||||
}
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" });
|
||||
const dateFmtLong = new Intl.DateTimeFormat("tr-TR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
export const formatChartDate = (ts: number) => dateFmt.format(new Date(ts));
|
||||
export const formatDateLong = (iso: string) => dateFmtLong.format(new Date(`${iso}T00:00:00Z`));
|
||||
|
||||
/** Bugünün YYYY-MM-DD'si (İstanbul) — API'nin istanbulToday'iyle aynı kural. */
|
||||
export function istanbulTodayIso(now: Date = new Date()): string {
|
||||
return now.toLocaleDateString("en-CA", { timeZone: "Europe/Istanbul" });
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { OemSuggestionsSection } from "@/components/catalog/oem-suggestions-section";
|
||||
import { OemVoteCard } from "@/components/catalog/oem-vote-card";
|
||||
import { PartPriceChip } from "@/components/catalog/part-price-dialog";
|
||||
import { PartPriceSection } from "@/components/catalog/part-price-section";
|
||||
import { useExpertAccess } from "@/hooks/use-expert-access";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { type PartPriceBatchView, normPartCode } from "@/lib/part-prices";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { Badge, Button, Input, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -148,6 +151,30 @@ function OemDetailPage() {
|
||||
api.get<CatalogVehicle[]>(`/parts/oem-vehicles?code=${encodeURIComponent(code)}`),
|
||||
});
|
||||
|
||||
// Sayfadaki tüm parça kodlarının güncel tedarikçi fiyatı (tek batch isteği).
|
||||
// Eşleşmeyen kod haritada yok → o satıra fiyat çipi çizilmez (fail-open).
|
||||
const priceCodes = useMemo(() => {
|
||||
const codes = new Set<string>([code]);
|
||||
if (data?.matched) {
|
||||
for (const a of data.articles) codes.add(a.articleNumber);
|
||||
for (const p of data.aftermarketParts) codes.add(p.articleNumber);
|
||||
for (const oe of data.oeCrossReferences) codes.add(oe.code);
|
||||
}
|
||||
return [...codes].slice(0, 400);
|
||||
}, [data, code]);
|
||||
|
||||
const { data: priceBatch } = useQuery({
|
||||
queryKey: ["part-prices-batch", code, priceCodes.length],
|
||||
enabled: !isLoading,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
queryFn: () =>
|
||||
api.post<PartPriceBatchView>("/part-prices/current-batch", { codes: priceCodes }),
|
||||
});
|
||||
const priceOf = useCallback(
|
||||
(raw: string) => priceBatch?.prices?.[normPartCode(raw)],
|
||||
[priceBatch],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
capture("oem_detail_viewed", {
|
||||
@@ -221,6 +248,10 @@ function OemDetailPage() {
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* ─── Tedarikçi fiyat geçmişi (sorgulanan kodun kendisi) ──────────
|
||||
Kendi verisi yoksa kendini gizler; P eşleşmesinden bağımsız. */}
|
||||
<PartPriceSection code={code} />
|
||||
|
||||
{/* ─── Loading ────────────────────────────────────────────────────── */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4">
|
||||
@@ -310,6 +341,17 @@ function OemDetailPage() {
|
||||
{a.name && a.name !== a.articleNumber && (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{a.name}</p>
|
||||
)}
|
||||
{(() => {
|
||||
const cur = priceOf(a.articleNumber);
|
||||
return cur ? (
|
||||
<PartPriceChip
|
||||
code={a.articleNumber}
|
||||
brand={a.brand}
|
||||
current={cur}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -324,14 +366,22 @@ function OemDetailPage() {
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredAftermarket.map((p) => (
|
||||
<tr key={`${p.brand}-${p.articleNumber}`} className="hover:bg-accent/50">
|
||||
<td className="px-4 py-2 font-medium">{p.brand}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<CopyCode code={p.articleNumber} className="hover:text-foreground" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredAftermarket.map((p) => {
|
||||
const cur = priceOf(p.articleNumber);
|
||||
return (
|
||||
<tr key={`${p.brand}-${p.articleNumber}`} className="hover:bg-accent/50">
|
||||
<td className="px-4 py-2 font-medium">{p.brand}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{cur && (
|
||||
<PartPriceChip code={p.articleNumber} brand={p.brand} current={cur} />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<CopyCode code={p.articleNumber} className="hover:text-foreground" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -343,15 +393,19 @@ function OemDetailPage() {
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">Muadil orijinal (OE) kodları</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filteredOe.map((oe) => (
|
||||
<span
|
||||
key={`${oe.brand}-${oe.code}`}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border bg-background px-2.5 py-1.5"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">{oe.brand}</span>
|
||||
<CopyCode code={oe.code} className="hover:text-foreground" />
|
||||
</span>
|
||||
))}
|
||||
{filteredOe.map((oe) => {
|
||||
const cur = priceOf(oe.code);
|
||||
return (
|
||||
<span
|
||||
key={`${oe.brand}-${oe.code}`}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border bg-background px-2.5 py-1.5"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">{oe.brand}</span>
|
||||
<CopyCode code={oe.code} className="hover:text-foreground" />
|
||||
{cur && <PartPriceChip code={oe.code} brand={oe.brand} current={cur} />}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user