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:
@@ -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