- 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>
137 lines
4.4 KiB
TypeScript
137 lines
4.4 KiB
TypeScript
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>
|
||
);
|
||
}
|