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

@@ -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>
)}