fix(part-prices): fiyat kimliği marka+kod oldu — kısa kodlarda marka karışması giderildi
Kısa sayısal kodlar markalar arası çakışıyor ve FARKLI fiziksel parçaların
fiyatları tek havuzda karışıyordu (vaka: FEBI 27155 ₺160 iken çip GROS+İBRAŞ+
NIFEA karışımı ₺566 medyan gösterdi).
- sku_map'e brand_norm eklendi (takip p10 kolonunun marka kısmı; yoksa sku
öneki) — vmi'de yeniden kuruldu (6,8M satır, %99,4 markalı)
- filterOffersForBrand: önek/sıralı-altdizi marka eşleme (BCH→BOSCH,
BLP→BLUEPRINT, BRA→İBRAŞ); markasız sorgu yalnızca uzun/benzersiz kodlarda
(≥8 ya da ≥5+harf+rakam) tüm teklifleri kullanır; kısa salt-sayısal kod
uyumsuzsa boş döner (yanlış veri göstermek yok)
- pg kimliği (code_norm, brand_norm) — 0019 drop+recreate (yalnız 3 test kodu
vardı, lazy-backfill yeniden doldurur)
- API: series?code&brand; current-batch artık { parts: [{code, brand}] } alır,
cevap "CODE::BRAND" anahtarlı; Redis anahtarları v2
- Web: satır çipleri ve dialog markayı geçirir; ana OEM kodu markasız kalır
(uzun-kod kuralıyla eşleşir); OE çiplerinde dağıtıcı etiketi fallback'i
(MAIS taşıyan tekliflere RENAULT isteği uzun kodda izinli)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -57,7 +57,7 @@ export function PartPriceChip({ code, brand, current, className }: PartPriceChip
|
||||
</DialogTitle>
|
||||
<DialogDescription>Tedarikçi fiyat geçmişi</DialogDescription>
|
||||
</DialogHeader>
|
||||
{open && <PartPriceSection code={code} variant="plain" />}
|
||||
{open && <PartPriceSection code={code} brand={brand} variant="plain" />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -29,14 +29,19 @@ const RANGES = [
|
||||
|
||||
type RangeKey = (typeof RANGES)[number]["key"];
|
||||
|
||||
export function usePartPriceSeries(code: string, enabled = true) {
|
||||
export function usePartPriceSeries(code: string, brand?: string, enabled = true) {
|
||||
const codeNorm = normPartCode(code);
|
||||
const brandNorm = normPartCode(brand ?? "");
|
||||
return useQuery({
|
||||
queryKey: ["part-price-series", codeNorm],
|
||||
queryKey: ["part-price-series", codeNorm, brandNorm],
|
||||
enabled: enabled && codeNorm.length >= 5,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
queryFn: () =>
|
||||
api.get<PartPriceSeriesView>(`/part-prices/series?code=${encodeURIComponent(code)}`),
|
||||
api.get<PartPriceSeriesView>(
|
||||
`/part-prices/series?code=${encodeURIComponent(code)}${
|
||||
brand ? `&brand=${encodeURIComponent(brand)}` : ""
|
||||
}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,13 +66,21 @@ function DeltaBadge({ pct }: { pct: number }) {
|
||||
|
||||
interface PartPriceSectionProps {
|
||||
code: string;
|
||||
/** Parça markası — kısa kodlarda teklifleri doğru markaya süzer; OEM
|
||||
* detayın ana kodu gibi markasız bağlamlarda boş bırakılır. */
|
||||
brand?: 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);
|
||||
export function PartPriceSection({
|
||||
code,
|
||||
brand,
|
||||
variant = "card",
|
||||
className,
|
||||
}: PartPriceSectionProps) {
|
||||
const { data, isLoading } = usePartPriceSeries(code, brand);
|
||||
const [range, setRange] = useState<RangeKey>("all");
|
||||
const todayIso = useMemo(() => istanbulTodayIso(), []);
|
||||
|
||||
@@ -75,6 +88,7 @@ export function PartPriceSection({ code, variant = "card", className }: PartPric
|
||||
if (data?.matched) {
|
||||
capture("part_price_viewed", {
|
||||
code: data.codeNorm,
|
||||
brand: data.brandNorm || undefined,
|
||||
points: data.series.length,
|
||||
offers: data.latest?.offerCount ?? 0,
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface PartPricePoint {
|
||||
export interface PartPriceSeriesView {
|
||||
matched: boolean;
|
||||
codeNorm: string;
|
||||
brandNorm: string;
|
||||
currency: "TRY";
|
||||
source: "supplier";
|
||||
series: PartPricePoint[];
|
||||
@@ -31,9 +32,13 @@ export interface PartPriceBatchView {
|
||||
prices: Record<string, PartPriceCurrent>;
|
||||
}
|
||||
|
||||
/** API'nin normPartCode'u ve sayfanın normCode'uyla aynı kural. */
|
||||
/** API'nin normPartCode'u ve sayfanın normCode'uyla aynı kural (marka dahil). */
|
||||
export const normPartCode = (s: string) => s.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||
|
||||
/** Batch cevabının anahtar kuralı — API'nin partPriceKey'iyle birebir. */
|
||||
export const partPriceKey = (code: string, brand?: string) =>
|
||||
`${normPartCode(code)}::${normPartCode(brand ?? "")}`;
|
||||
|
||||
export interface ChartDatum {
|
||||
/** epoch ms — XAxis type="number" gerçek zaman ölçeği için */
|
||||
ts: number;
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { type PartPriceBatchView, partPriceKey } from "@/lib/part-prices";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { Badge, Button, Input, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -151,27 +151,37 @@ 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]);
|
||||
// Sayfadaki tüm parçaların (kod + marka) güncel tedarikçi fiyatı (tek batch
|
||||
// isteği). Marka şart: kısa sayısal kodlar markalar arası çakışır (FEBI
|
||||
// 27155 ≠ GROS 27155) — sunucu teklifleri markaya süzer. Eşleşmeyen parça
|
||||
// haritada yok → o satıra fiyat çipi çizilmez (fail-open).
|
||||
const priceParts = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const parts: Array<{ code: string; brand?: string }> = [];
|
||||
const push = (c: string, b?: string) => {
|
||||
const k = partPriceKey(c, b);
|
||||
if (seen.has(k)) return;
|
||||
seen.add(k);
|
||||
parts.push(b ? { code: c, brand: b } : { code: c });
|
||||
};
|
||||
push(code); // sayfanın ana OEM kodu — markasız (uzun/benzersiz kodlar eşleşir)
|
||||
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);
|
||||
for (const a of data.articles) push(a.articleNumber, a.brand);
|
||||
for (const p of data.aftermarketParts) push(p.articleNumber, p.brand);
|
||||
for (const oe of data.oeCrossReferences) push(oe.code, oe.brand);
|
||||
}
|
||||
return [...codes].slice(0, 400);
|
||||
return parts.slice(0, 400);
|
||||
}, [data, code]);
|
||||
|
||||
const { data: priceBatch } = useQuery({
|
||||
queryKey: ["part-prices-batch", code, priceCodes.length],
|
||||
queryKey: ["part-prices-batch", code, priceParts.length],
|
||||
enabled: !isLoading,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
queryFn: () =>
|
||||
api.post<PartPriceBatchView>("/part-prices/current-batch", { codes: priceCodes }),
|
||||
api.post<PartPriceBatchView>("/part-prices/current-batch", { parts: priceParts }),
|
||||
});
|
||||
const priceOf = useCallback(
|
||||
(raw: string) => priceBatch?.prices?.[normPartCode(raw)],
|
||||
(rawCode: string, rawBrand?: string) => priceBatch?.prices?.[partPriceKey(rawCode, rawBrand)],
|
||||
[priceBatch],
|
||||
);
|
||||
|
||||
@@ -342,7 +352,7 @@ function OemDetailPage() {
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{a.name}</p>
|
||||
)}
|
||||
{(() => {
|
||||
const cur = priceOf(a.articleNumber);
|
||||
const cur = priceOf(a.articleNumber, a.brand);
|
||||
return cur ? (
|
||||
<PartPriceChip
|
||||
code={a.articleNumber}
|
||||
@@ -367,7 +377,7 @@ function OemDetailPage() {
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredAftermarket.map((p) => {
|
||||
const cur = priceOf(p.articleNumber);
|
||||
const cur = priceOf(p.articleNumber, p.brand);
|
||||
return (
|
||||
<tr key={`${p.brand}-${p.articleNumber}`} className="hover:bg-accent/50">
|
||||
<td className="px-4 py-2 font-medium">{p.brand}</td>
|
||||
@@ -394,7 +404,7 @@ function OemDetailPage() {
|
||||
<h2 className="text-sm font-semibold">Muadil orijinal (OE) kodları</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filteredOe.map((oe) => {
|
||||
const cur = priceOf(oe.code);
|
||||
const cur = priceOf(oe.code, oe.brand);
|
||||
return (
|
||||
<span
|
||||
key={`${oe.brand}-${oe.code}`}
|
||||
|
||||
Reference in New Issue
Block a user