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:
2026-06-12 13:33:37 +03:00
parent 2c848df97d
commit 532e1ad9ef
27 changed files with 2047 additions and 22 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}