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:
2026-06-12 14:55:44 +03:00
parent 51d8104cdc
commit f394897a08
13 changed files with 434 additions and 142 deletions

View File

@@ -0,0 +1,32 @@
-- Parça fiyat anahtarına marka eklendi: kısa sayısal kodlar (FEBI 27155 vs
-- GROS 27155 vs İBRAŞ 27155) markalar arası çakışıyor ve FARKLI fiziksel
-- parçaların fiyatları tek havuzda karışıyordu. Kimlik artık (kod, marka);
-- markasız izleme yalnızca uzun/benzersiz kodlar için ('' brand_norm).
-- Tablolar 0018'den bu yana yalnızca dev'de üç test kodu içeriyordu — veri
-- taşımak yerine drop+create (lazy-backfill ilk görüntülemede yeniden doldurur).
DROP TABLE IF EXISTS "part_price_daily";
--> statement-breakpoint
DROP TABLE IF EXISTS "part_price_tracks";
--> statement-breakpoint
CREATE TABLE "part_price_tracks" (
"code_norm" varchar(64) NOT NULL,
"brand_norm" varchar(64) DEFAULT '' NOT NULL,
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"backfilled_at" timestamp with time zone,
"last_refreshed_at" timestamp with time zone,
CONSTRAINT "part_price_tracks_code_norm_brand_norm_pk" PRIMARY KEY("code_norm","brand_norm")
);
--> statement-breakpoint
CREATE TABLE "part_price_daily" (
"code_norm" varchar(64) NOT NULL,
"brand_norm" varchar(64) DEFAULT '' NOT NULL,
"source" varchar(16) DEFAULT 'supplier' NOT NULL,
"date" date NOT NULL,
"p50" numeric(15, 4),
"p95" numeric(15, 4),
"p99" numeric(15, 4),
"offer_count" integer DEFAULT 0 NOT NULL,
CONSTRAINT "part_price_daily_code_norm_brand_norm_source_date_pk" PRIMARY KEY("code_norm","brand_norm","source","date")
);
--> statement-breakpoint
CREATE INDEX "part_price_daily_code_date_idx" ON "part_price_daily" USING btree ("code_norm","brand_norm","date");

View File

@@ -134,6 +134,13 @@
"when": 1781481600000,
"tag": "0018_part_price_history",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1781485200000,
"tag": "0019_part_price_brand",
"breakpoints": true
}
]
}

View File

@@ -774,25 +774,33 @@ export const oemExpertRewards = pgTable(
);
// ─── Part Price History (tedarikçi fiyat verisi) ──────
// Parça kodu bazlı piyasa fiyat geçmişi; kaynak takip MySQL'i (Tailscale).
// tracks = izlenen kodlar: ilk seriyi API lazy-backfill yazar (kod ilk kez
// görüntülendiğinde), günlük cron yalnızca buradaki kodlara bugünün satırını
// ekler. daily = (kod, kaynak, gün) başına stoktaki tekliflerin p50/p95/p99
// dağılımı + teklif sayısı; sıfır-teklif günleri null fiyat + offerCount 0
// ile saklanır ki "stok yok" bilgisi de seriye işlensin. source şimdilik hep
// 'supplier'; perakende fiyatı geldiğinde aynı tabloya 'retail' satırları
// girer. Tedarikçi kimliği bilinçli olarak hiçbir yerde tutulmaz.
export const partPriceTracks = pgTable("part_price_tracks", {
codeNorm: varchar("code_norm", { length: 64 }).primaryKey(),
firstSeenAt: timestamp("first_seen_at", { withTimezone: true }).defaultNow().notNull(),
backfilledAt: timestamp("backfilled_at", { withTimezone: true }),
lastRefreshedAt: timestamp("last_refreshed_at", { withTimezone: true }),
});
// Parça bazlı piyasa fiyat geçmişi; kaynak takip MySQL'i (Tailscale). Kimlik
// (kod, marka): kısa sayısal kodlar markalar arası çakışır (FEBI 27155 ≠
// GROS 27155) — marka olmadan percentile anlamsızlaşır; markasız ('' brand)
// izleme yalnızca uzun/benzersiz kodlar içindir (OEM detayın ana kodu).
// tracks = izlenen parçalar: ilk seriyi API lazy-backfill yazar, günlük cron
// yalnızca buradakilere bugünün satırını ekler. daily = (kod, marka, kaynak,
// gün) başına stoktaki tekliflerin p50/p95/p99 + teklif sayısı; sıfır-teklif
// günleri null fiyat + offerCount 0 ile saklanır. source şimdilik hep
// 'supplier'; perakende geldiğinde aynı tabloya 'retail' girer. Tedarikçi
// kimliği bilinçli olarak hiçbir yerde tutulmaz.
export const partPriceTracks = pgTable(
"part_price_tracks",
{
codeNorm: varchar("code_norm", { length: 64 }).notNull(),
brandNorm: varchar("brand_norm", { length: 64 }).default("").notNull(),
firstSeenAt: timestamp("first_seen_at", { withTimezone: true }).defaultNow().notNull(),
backfilledAt: timestamp("backfilled_at", { withTimezone: true }),
lastRefreshedAt: timestamp("last_refreshed_at", { withTimezone: true }),
},
(table) => [primaryKey({ columns: [table.codeNorm, table.brandNorm] })],
);
export const partPriceDaily = pgTable(
"part_price_daily",
{
codeNorm: varchar("code_norm", { length: 64 }).notNull(),
brandNorm: varchar("brand_norm", { length: 64 }).default("").notNull(),
source: varchar("source", { length: 16 }).default("supplier").notNull(),
date: date("date").notNull(),
p50: numeric("p50", { precision: 15, scale: 4 }),
@@ -801,7 +809,7 @@ export const partPriceDaily = pgTable(
offerCount: integer("offer_count").default(0).notNull(),
},
(table) => [
primaryKey({ columns: [table.codeNorm, table.source, table.date] }),
index("part_price_daily_code_date_idx").on(table.codeNorm, table.date),
primaryKey({ columns: [table.codeNorm, table.brandNorm, table.source, table.date] }),
index("part_price_daily_code_date_idx").on(table.codeNorm, table.brandNorm, table.date),
],
);

View File

@@ -3,7 +3,11 @@ import { asc, inArray, sql } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import Redis from "ioredis";
import { partPriceDaily, partPriceTracks } from "../../database/schema/core";
import { computeStats, istanbulToday } from "../../part-prices/part-prices.logic";
import {
computeStats,
filterOffersForBrand,
istanbulToday,
} from "../../part-prices/part-prices.logic";
import { createSupplierPriceSource } from "../../part-prices/supplier-price-source";
type Database = PostgresJsDatabase<Record<string, unknown>>;
@@ -15,10 +19,11 @@ const BATCH = 300;
* Günlük tedarikçi fiyat tazeleme (19:30 Europe/Istanbul — takip sync'i
* ~19:05'te biter):
* 1. sku_map artımlı bakım (son 3 günde güncellenen ürünler, INSERT IGNORE).
* 2. İzlenen her kod için bugünün p50/p95/p99'unu takip.products'tan hesapla,
* 2. İzlenen her (kod, marka) için bugünün p50/p95/p99'unu takip.products'tan
* hesapla (teklifler markaya süzülür — kısa kodlar markalar arası çakışır),
* part_price_daily'ye upsert et (teklif kalmadıysa null-fiyatlı satır —
* "stok yok" da seridir).
* 3. Tazelenen kodların Redis cache'ini düşür.
* 3. Tazelenen parçaların Redis cache'ini düşür.
* SUPPLIER_PRICE_DB_* yoksa sessiz no-op (dev/prod'da env'le açılır).
*/
export async function processPartPriceRefresh(
@@ -45,31 +50,34 @@ export async function processPartPriceRefresh(
await source.refreshSkuMap();
const tracked = await db
.select({ codeNorm: partPriceTracks.codeNorm })
.select({ codeNorm: partPriceTracks.codeNorm, brandNorm: partPriceTracks.brandNorm })
.from(partPriceTracks)
.where(sql`${partPriceTracks.backfilledAt} IS NOT NULL`)
.orderBy(asc(partPriceTracks.codeNorm))
.orderBy(asc(partPriceTracks.codeNorm), asc(partPriceTracks.brandNorm))
.limit(TRACKED_CAP);
const today = istanbulToday();
let refreshed = 0;
for (let i = 0; i < tracked.length; i += BATCH) {
const chunk = tracked.slice(i, i + BATCH).map((t) => t.codeNorm);
const rows = await source.fetchCurrentOfferRows(chunk);
const byCode = new Map<string, { price: number; stock: number }[]>();
const chunk = tracked.slice(i, i + BATCH);
const codes = [...new Set(chunk.map((t) => t.codeNorm))];
const rows = await source.fetchCurrentOfferRows(codes);
const byCode = new Map<string, { brandNorm: string; price: number; stock: number }[]>();
for (const r of rows) {
let list = byCode.get(r.codeNorm);
if (!list) {
list = [];
byCode.set(r.codeNorm, list);
}
list.push({ price: r.price, stock: 1 });
list.push({ brandNorm: r.brandNorm, price: r.price, stock: 1 });
}
const values = chunk.map((codeNorm) => {
const stats = computeStats(byCode.get(codeNorm) ?? []);
const values = chunk.map((t) => {
const offers = filterOffersForBrand(byCode.get(t.codeNorm) ?? [], t.brandNorm, t.codeNorm);
const stats = computeStats(offers);
return {
codeNorm,
codeNorm: t.codeNorm,
brandNorm: t.brandNorm,
source: "supplier" as const,
date: today,
p50: stats.p50 === null ? null : String(stats.p50),
@@ -83,7 +91,12 @@ export async function processPartPriceRefresh(
.insert(partPriceDaily)
.values(values)
.onConflictDoUpdate({
target: [partPriceDaily.codeNorm, partPriceDaily.source, partPriceDaily.date],
target: [
partPriceDaily.codeNorm,
partPriceDaily.brandNorm,
partPriceDaily.source,
partPriceDaily.date,
],
set: {
p50: sql`excluded.p50`,
p95: sql`excluded.p95`,
@@ -91,13 +104,19 @@ export async function processPartPriceRefresh(
offerCount: sql`excluded.offer_count`,
},
});
// Bileşik anahtarla tek tek değil çift-kolon inArray ile güncellenemez;
// chunk küçük (≤300) — kod listesi yeterli (marka varyantları birlikte
// tazelenir, fazladan timestamp güncellemesi zararsız).
await db
.update(partPriceTracks)
.set({ lastRefreshedAt: new Date() })
.where(inArray(partPriceTracks.codeNorm, chunk));
.where(inArray(partPriceTracks.codeNorm, codes));
// Cache düşür — bir sonraki sayfa görüntülemesi taze pg satırını okur.
const keys = chunk.flatMap((n) => [`partprice:series:v1:${n}`, `partprice:cur:v1:${n}`]);
const keys = chunk.flatMap((t) => [
`partprice:series:v2:${t.codeNorm}::${t.brandNorm}`,
`partprice:cur:v2:${t.codeNorm}::${t.brandNorm}`,
]);
if (keys.length > 0) await redis.del(...keys);
refreshed += chunk.length;

View File

@@ -6,23 +6,25 @@ export class PartPricesController {
constructor(private readonly partPrices: PartPricesService) {}
/**
* Bir parça kodunun tedarikçi fiyat serisi (p50/p95/p99, günlük değişim
* noktaları). `GET /part-prices/series?code=0986452041` → her durumda 200;
* eşleşmeme/kapalı kaynak `matched: false`. İlk istek lazy-backfill yapar.
* Bir parçanın (kod + opsiyonel marka) tedarikçi fiyat serisi (p50/p95/p99,
* günlük değişim noktaları). `GET /part-prices/series?code=27155&brand=FEBI
* BILSTEIN` → her durumda 200; eşleşmeme/kapalı kaynak `matched: false`.
* Marka verilmezse yalnızca uzun/benzersiz kodlar eşleşir (kısa sayısal
* kodlar markalar arası çakışır). İlk istek lazy-backfill yapar.
*/
@Get("series")
async series(@Query("code") code: string) {
return this.partPrices.getSeries(code ?? "");
async series(@Query("code") code: string, @Query("brand") brand?: string) {
return this.partPrices.getSeries(code ?? "", brand);
}
/**
* Sayfada görünen kodların güncel istatistikleri (tek istek).
* `POST /part-prices/current-batch { codes: [...] }` →
* `{ prices: { CODENORM: { p50, p95, p99, offerCount } } }` — eşleşmeyen
* kodlar haritaya girmez, UI o satıra fiyat çizmez.
* Sayfada görünen (kod, marka) çiftlerinin güncel istatistikleri.
* `POST /part-prices/current-batch { parts: [{ code, brand? }] }` →
* `{ prices: { "CODENORM::BRANDNORM": { p50, p95, p99, offerCount } } }` —
* eşleşmeyenler haritaya girmez, UI o satıra fiyat çizmez.
*/
@Post("current-batch")
async currentBatch(@Body("codes") codes: string[]) {
return this.partPrices.getCurrentBatch(Array.isArray(codes) ? codes : []);
async currentBatch(@Body("parts") parts: Array<{ code?: unknown; brand?: unknown }>) {
return this.partPrices.getCurrentBatch(Array.isArray(parts) ? parts : []);
}
}

View File

@@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest";
import {
type HistoryEvent,
type SupplierOffer,
allowBrandless,
brandCompatible,
computeStats,
filterOffersForBrand,
istanbulToday,
normPartCode,
percentile,
@@ -118,6 +121,73 @@ describe("reconstructDailySeries", () => {
});
});
describe("brandCompatible", () => {
it("önek ve kısaltmaları tanır", () => {
expect(brandCompatible("BOSCH", "BOSCH")).toBe(true);
expect(brandCompatible("BCH", "BOSCH")).toBe(true); // sıralı altdizi
expect(brandCompatible("B", "BOSCH")).toBe(true); // tek harf önek
expect(brandCompatible("BLP", "BLUEPRINT")).toBe(true);
expect(brandCompatible("BLUEPRNT", "BLUEPRINT")).toBe(true); // Türkçe ı düşmüş
expect(brandCompatible("FEBI", "FEBIBILSTEIN")).toBe(true);
expect(brandCompatible("MANN", "MANNFILTER")).toBe(true);
expect(brandCompatible("BRA", "IBRAS")).toBe(true); // İBRAŞ → BRA
});
it("alakasız markaları reddeder", () => {
expect(brandCompatible("GROS", "FEBIBILSTEIN")).toBe(false);
expect(brandCompatible("NIFEA", "FEBIBILSTEIN")).toBe(false);
expect(brandCompatible("MAIS", "RENAULT")).toBe(false); // dağıtıcı — uzun-kod fallback'i halleder
expect(brandCompatible("", "BOSCH")).toBe(false);
});
});
describe("allowBrandless", () => {
it("uzun kodlar ve harf+rakam karışımı serbest, kısa salt-sayısal yasak", () => {
expect(allowBrandless("8200768913")).toBe(true); // 10 hane OE
expect(allowBrandless("46805832")).toBe(true); // 8 hane OE
expect(allowBrandless("W7008")).toBe(true); // harf+rakam
expect(allowBrandless("0249C6")).toBe(true); // PSA kısa OE, harf içerir
expect(allowBrandless("27155")).toBe(false); // kısa salt-sayısal — çakışma sınıfı
expect(allowBrandless("615014")).toBe(false);
});
});
describe("filterOffersForBrand", () => {
// 2026-06-12 vakası: 27155 kodu FEBI/GROS/İBRAŞ/NIFEA'da FARKLI parçalar.
const offers = [
{ brandNorm: "FEBI", price: 160 },
{ brandNorm: "GROS", price: 224 },
{ brandNorm: "IBR", price: 909 },
{ brandNorm: "NIFEA", price: 1229 },
];
it("kısa kodda istenen markanın tekliflerine süzer", () => {
const out = filterOffersForBrand(offers, "FEBIBILSTEIN", "27155");
expect(out).toEqual([{ brandNorm: "FEBI", price: 160 }]);
});
it("kısa kod + uyumsuz marka → boş (yanlış veri göstermez)", () => {
expect(filterOffersForBrand(offers, "TRW", "27155")).toEqual([]);
});
it("kısa kod + markasız istek → boş", () => {
expect(filterOffersForBrand(offers, "", "27155")).toEqual([]);
});
it("uzun kodda markasız istek tüm teklifleri kullanır (OE vakası)", () => {
const oe = [
{ brandNorm: "MAIS", price: 195 },
{ brandNorm: "RENAULT", price: 247 },
];
expect(filterOffersForBrand(oe, "", "8200768913")).toHaveLength(2);
});
it("uzun kod + uyumsuz marka etiketi → dağıtıcı fallback'i (tümü)", () => {
const oe = [{ brandNorm: "MAIS", price: 195 }];
expect(filterOffersForBrand(oe, "RENAULT", "8200768913")).toHaveLength(1);
});
});
describe("istanbulToday", () => {
it("UTC gece yarısı civarında İstanbul gününü döner", () => {
// 2026-06-11 22:30 UTC = 2026-06-12 01:30 İstanbul

View File

@@ -17,11 +17,68 @@
export const PART_CODE_MIN_NORM_LEN = 5;
/** Web'in normCode'u ve P servisinin norm'uyla birebir aynı kural:
* `1J0 973 702` / `1j0-973-702` → `1J0973702`. */
* `1J0 973 702` / `1j0-973-702` → `1J0973702`. Markalar için de kullanılır
* ("FEBI BILSTEIN" → FEBIBILSTEIN; Türkçe harfler düşer: "İBRAŞ" → BRA). */
export function normPartCode(code: string): string {
return (code ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "");
}
// ─── Marka eşleme ──────────────────────────────────────────────────────────
// Kısa sayısal kodlar (FEBI 27155 / GROS 27155 / İBRAŞ 27155) markalar arası
// çakışır — bunlar FARKLI fiziksel parçalardır; tek havuzda percentile almak
// fiyatı anlamsızlaştırır (2026-06-12 vakası). Bu yüzden teklifler taşıdıkları
// marka etiketiyle (takip p10 markası ya da sku öneki) istenen markaya
// süzülür. Tedarikçiler markayı kısaltarak yazar (BCH/B→Bosch, BLP→Blue
// Print, IBR→İbraş) — eşleşme önek VEYA sıralı-altdizi ile yapılır.
/** a'nın tüm karakterleri b içinde aynı sırayla geçiyor mu (BCH ⊂ BOSCH). */
export function inOrderSubsequence(a: string, b: string): boolean {
let i = 0;
for (const ch of b) {
if (ch === a[i]) i++;
if (i === a.length) return true;
}
return i === a.length;
}
/** Teklifin marka etiketi istenen markayla uyumlu mu (ikisi de normalize). */
export function brandCompatible(offerBrand: string, requestedBrand: string): boolean {
if (!offerBrand || !requestedBrand) return false;
if (offerBrand.startsWith(requestedBrand) || requestedBrand.startsWith(offerBrand)) return true;
// Kısaltma: en az 2 karakter, istenen markanın sıralı altdizisi (BLP→BLUEPRINT).
return offerBrand.length >= 2 && inOrderSubsequence(offerBrand, requestedBrand);
}
/** Marka süzgeci olmadan tüm teklifleri kullanmak güvenli mi? Uzun kodlar
* (OE numaraları) pratikte benzersizdir; kısa SALT-SAYISAL kodlar markalar
* arası çakışmanın ta kendisidir → markasız gösterilmez. */
export function allowBrandless(codeNorm: string): boolean {
if (codeNorm.length >= 8) return true;
return (
codeNorm.length >= PART_CODE_MIN_NORM_LEN && /[A-Z]/.test(codeNorm) && /[0-9]/.test(codeNorm)
);
}
/**
* Teklifleri istenen markaya süz:
* - marka istendi → uyumlular; hiçbiri uymuyorsa ve kod markasız-güvenliyse
* hepsi (OE/dağıtıcı etiketi vakası: RENAULT istenir, teklifler MAIS taşır);
* kısa kodda boş (yanlış veri göstermekten iyidir),
* - marka istenmedi → kod markasız-güvenliyse hepsi, değilse boş.
*/
export function filterOffersForBrand<T extends { brandNorm: string }>(
offers: T[],
requestedBrandNorm: string,
codeNorm: string,
): T[] {
if (requestedBrandNorm) {
const compat = offers.filter((o) => brandCompatible(o.brandNorm, requestedBrandNorm));
if (compat.length > 0) return compat;
return allowBrandless(codeNorm) ? offers : [];
}
return allowBrandless(codeNorm) ? offers : [];
}
export interface SupplierOffer {
productId: number;
price: number;

View File

@@ -6,7 +6,7 @@ import {
type OnModuleInit,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { asc, eq } from "drizzle-orm";
import { and, asc, eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { partPriceDaily, partPriceTracks } from "../database/schema/core";
import { RedisService } from "../redis/redis.service";
@@ -14,6 +14,7 @@ import {
type DailyPoint,
PART_CODE_MIN_NORM_LEN,
computeStats,
filterOffersForBrand,
istanbulToday,
normPartCode,
reconstructDailySeries,
@@ -26,6 +27,7 @@ export const SUPPLIER_PRICE_EPOCH = "2026-01-29";
export interface PartPriceSeriesView {
matched: boolean;
codeNorm: string;
brandNorm: string;
currency: "TRY";
source: "supplier";
series: DailyPoint[];
@@ -42,29 +44,36 @@ export interface PartPriceCurrent {
}
export interface PartPriceBatchView {
/** code_norm → güncel istatistik (eşleşmeyen kodlar haritada yer almaz). */
/** `${codeNorm}::${brandNorm}` → güncel istatistik (eşleşmeyenler yok). */
prices: Record<string, PartPriceCurrent>;
}
const SERIES_CACHE_TTL = 3600; // 1h — cron 19:30'da tazeler, anahtarları siler
const SERIES_MISS_TTL = 1800;
const BATCH_CACHE_TTL = 1800;
const MAX_BATCH_CODES = 400;
const MAX_BATCH_PARTS = 400;
const seriesKey = (norm: string) => `partprice:series:v1:${norm}`;
const currentKey = (norm: string) => `partprice:cur:v1:${norm}`;
export const partPriceKey = (codeNorm: string, brandNorm: string) => `${codeNorm}::${brandNorm}`;
const seriesKey = (codeNorm: string, brandNorm: string) =>
`partprice:series:v2:${codeNorm}::${brandNorm}`;
const currentKey = (codeNorm: string, brandNorm: string) =>
`partprice:cur:v2:${codeNorm}::${brandNorm}`;
/**
* Parça kodu bazlı tedarikçi fiyat görünümleri. P-servisi sözleşmesi: asla
* throw etmez — kapalı kaynak, kısa kod, bağlantı hatası ve eşleşmeme hepsi
* boş görünüme düşer (UI tek empty-state yolu görür).
* Parça (kod + marka) bazlı tedarikçi fiyat görünümleri. P-servisi sözleşmesi:
* asla throw etmez — kapalı kaynak, kısa kod, bağlantı hatası ve eşleşmeme
* hepsi boş görünüme düşer (UI tek empty-state yolu görür).
*
* - Seri: kod ilk kez istendiğinde takip history'sinden lazy-backfill edilir
* Kimlik (kod, marka): kısa sayısal kodlar markalar arası çakışır (FEBI 27155
* ≠ GROS 27155 ≠ İBRAŞ 27155 — farklı fiziksel parçalar). Teklifler sku_map'in
* marka etiketiyle istenen markaya süzülür (filterOffersForBrand); markasız
* sorgu yalnızca uzun/benzersiz kodlarda tüm teklifleri kullanır.
*
* - Seri: parça ilk kez istendiğinde takip history'sinden lazy-backfill edilir
* ve pg'ye (part_price_tracks + part_price_daily) kalıcı yazılır; sonraki
* istekler salt-pg okur. Günlük cron izlenen kodlara bugünü ekler.
* - Batch (sayfadaki kod listesi): canlı MySQL'den tek sorgu + Redis cache;
* pg'ye iz BIRAKMAZ (tracking yalnızca seri isteğiyle başlar — sayfa başına
* yüzlerce kodu sonsuza dek cron'lamamak için).
* istekler salt-pg okur. Günlük cron izlenen parçalara bugünü ekler.
* - Batch (sayfadaki satırlar): canlı MySQL'den tek sorgu + Redis cache;
* pg'ye iz BIRAKMAZ (tracking yalnızca seri isteğiyle başlar).
*/
@Injectable()
export class PartPricesService implements OnModuleInit, OnModuleDestroy {
@@ -95,10 +104,11 @@ export class PartPricesService implements OnModuleInit, OnModuleDestroy {
}
}
private miss(codeNorm: string): PartPriceSeriesView {
private miss(codeNorm: string, brandNorm: string): PartPriceSeriesView {
return {
matched: false,
codeNorm,
brandNorm,
currency: "TRY",
source: "supplier",
series: [],
@@ -107,19 +117,22 @@ export class PartPricesService implements OnModuleInit, OnModuleDestroy {
};
}
async getSeries(rawCode: string): Promise<PartPriceSeriesView> {
async getSeries(rawCode: string, rawBrand?: string): Promise<PartPriceSeriesView> {
const codeNorm = normPartCode(rawCode);
if (codeNorm.length < PART_CODE_MIN_NORM_LEN) return this.miss(codeNorm);
const brandNorm = normPartCode(rawBrand ?? "");
if (codeNorm.length < PART_CODE_MIN_NORM_LEN) return this.miss(codeNorm, brandNorm);
const cached = await this.redis.getJson<PartPriceSeriesView>(seriesKey(codeNorm));
const cached = await this.redis.getJson<PartPriceSeriesView>(seriesKey(codeNorm, brandNorm));
if (cached) return cached;
try {
// İzlenen kod → pg'den oku (hızlı yol; cron güncel tutuyor).
// İzlenen parça → pg'den oku (hızlı yol; cron güncel tutuyor).
const [track] = await this.db
.select()
.from(partPriceTracks)
.where(eq(partPriceTracks.codeNorm, codeNorm))
.where(
and(eq(partPriceTracks.codeNorm, codeNorm), eq(partPriceTracks.brandNorm, brandNorm)),
)
.limit(1);
let view: PartPriceSeriesView;
@@ -127,7 +140,9 @@ export class PartPricesService implements OnModuleInit, OnModuleDestroy {
const rows = await this.db
.select()
.from(partPriceDaily)
.where(eq(partPriceDaily.codeNorm, codeNorm))
.where(
and(eq(partPriceDaily.codeNorm, codeNorm), eq(partPriceDaily.brandNorm, brandNorm)),
)
.orderBy(asc(partPriceDaily.date));
const series: DailyPoint[] = rows
.filter((r) => r.source === "supplier")
@@ -141,6 +156,7 @@ export class PartPricesService implements OnModuleInit, OnModuleDestroy {
view = {
matched: series.length > 0,
codeNorm,
brandNorm,
currency: "TRY",
source: "supplier",
series,
@@ -148,27 +164,30 @@ export class PartPricesService implements OnModuleInit, OnModuleDestroy {
truncated: false,
};
} else {
view = await this.backfill(codeNorm);
view = await this.backfill(codeNorm, brandNorm);
}
await this.redis.setJson(
seriesKey(codeNorm),
seriesKey(codeNorm, brandNorm),
view,
view.matched ? SERIES_CACHE_TTL : SERIES_MISS_TTL,
);
return view;
} catch (err) {
this.logger.warn(`[part-prices] series failed (code=${codeNorm}): ${(err as Error).message}`);
return this.miss(codeNorm);
return this.miss(codeNorm, brandNorm);
}
}
/** İlk görüntülenme: takip history'sinden seriyi kur, pg'ye kalıcı yaz. */
private async backfill(codeNorm: string): Promise<PartPriceSeriesView> {
if (!this.source) return this.miss(codeNorm);
private async backfill(codeNorm: string, brandNorm: string): Promise<PartPriceSeriesView> {
if (!this.source) return this.miss(codeNorm, brandNorm);
const { ids, truncated: idsTruncated } = await this.source.fetchProductIds(codeNorm);
if (ids.length === 0) return this.miss(codeNorm);
const { rows: mapped, truncated: idsTruncated } =
await this.source.fetchMappedProducts(codeNorm);
const selected = filterOffersForBrand(mapped, brandNorm, codeNorm);
if (selected.length === 0) return this.miss(codeNorm, brandNorm);
const ids = selected.map((m) => m.productId);
const [{ events, truncated: histTruncated }, current] = await Promise.all([
this.source.fetchHistory(ids),
@@ -176,19 +195,20 @@ export class PartPricesService implements OnModuleInit, OnModuleDestroy {
]);
const series = reconstructDailySeries(events, current, SUPPLIER_PRICE_EPOCH, istanbulToday());
if (series.length === 0) return this.miss(codeNorm);
if (series.length === 0) return this.miss(codeNorm, brandNorm);
// Kalıcılaştır — yarış durumunda (iki istek aynı anda backfill eder)
// ON CONFLICT'ler ikinci yazımı sessizce yutar.
await this.db
.insert(partPriceTracks)
.values({ codeNorm, backfilledAt: new Date() })
.values({ codeNorm, brandNorm, backfilledAt: new Date() })
.onConflictDoUpdate({
target: partPriceTracks.codeNorm,
target: [partPriceTracks.codeNorm, partPriceTracks.brandNorm],
set: { backfilledAt: new Date() },
});
const rows = series.map((pt) => ({
codeNorm,
brandNorm,
source: "supplier" as const,
date: pt.date,
p50: pt.p50 === null ? null : String(pt.p50),
@@ -206,6 +226,7 @@ export class PartPricesService implements OnModuleInit, OnModuleDestroy {
return {
matched: true,
codeNorm,
brandNorm,
currency: "TRY",
source: "supplier",
series,
@@ -215,67 +236,86 @@ export class PartPricesService implements OnModuleInit, OnModuleDestroy {
}
/**
* Sayfadaki kodlar için güncel istatistik. Canlı MySQL + Redis; pg'ye
* dokunmaz. Kaynak kapalı/ulaşılamaz → boş harita (fail-open).
* Sayfadaki (kod, marka) çiftleri için güncel istatistik. Canlı MySQL +
* Redis; pg'ye dokunmaz. Kaynak kapalı/ulaşılamaz → boş harita (fail-open).
*/
async getCurrentBatch(rawCodes: string[]): Promise<PartPriceBatchView> {
const norms = [
...new Set(
(Array.isArray(rawCodes) ? rawCodes : [])
.filter((c): c is string => typeof c === "string")
.map(normPartCode)
.filter((n) => n.length >= PART_CODE_MIN_NORM_LEN && n.length <= 64),
),
].slice(0, MAX_BATCH_CODES);
if (norms.length === 0) return { prices: {} };
async getCurrentBatch(
rawParts: Array<{ code?: unknown; brand?: unknown }>,
): Promise<PartPriceBatchView> {
const parts = new Map<string, { codeNorm: string; brandNorm: string }>();
for (const p of Array.isArray(rawParts) ? rawParts : []) {
if (typeof p?.code !== "string") continue;
const codeNorm = normPartCode(p.code);
const brandNorm = typeof p.brand === "string" ? normPartCode(p.brand) : "";
if (codeNorm.length < PART_CODE_MIN_NORM_LEN || codeNorm.length > 64) continue;
const key = partPriceKey(codeNorm, brandNorm);
if (!parts.has(key)) parts.set(key, { codeNorm, brandNorm });
if (parts.size >= MAX_BATCH_PARTS) break;
}
if (parts.size === 0) return { prices: {} };
const prices: Record<string, PartPriceCurrent> = {};
const pending: string[] = [];
const pending: Array<{ key: string; codeNorm: string; brandNorm: string }> = [];
await Promise.all(
norms.map(async (n) => {
const hit = await this.redis.getJson<PartPriceCurrent | { miss: true }>(currentKey(n));
[...parts.entries()].map(async ([key, p]) => {
const hit = await this.redis.getJson<PartPriceCurrent | { miss: true }>(
currentKey(p.codeNorm, p.brandNorm),
);
if (hit === null) {
pending.push(n);
pending.push({ key, ...p });
} else if (!("miss" in hit)) {
prices[n] = hit;
prices[key] = hit;
}
}),
);
if (pending.length > 0 && this.source) {
try {
const rows = await this.source.fetchCurrentOfferRows(pending);
const byCode = new Map<string, { price: number; stock: number }[]>();
const codes = [...new Set(pending.map((p) => p.codeNorm))];
const rows = await this.source.fetchCurrentOfferRows(codes);
const byCode = new Map<string, { brandNorm: string; price: number; stock: number }[]>();
for (const r of rows) {
let list = byCode.get(r.codeNorm);
if (!list) {
list = [];
byCode.set(r.codeNorm, list);
}
list.push({ price: r.price, stock: 1 });
list.push({ brandNorm: r.brandNorm, price: r.price, stock: 1 });
}
await Promise.all(
pending.map(async (n) => {
const offers = byCode.get(n);
const stats = offers ? computeStats(offers) : null;
if (stats && stats.p50 !== null) {
pending.map(async (p) => {
const offers = filterOffersForBrand(
byCode.get(p.codeNorm) ?? [],
p.brandNorm,
p.codeNorm,
);
const stats = computeStats(offers);
if (stats.p50 !== null) {
const current: PartPriceCurrent = {
p50: stats.p50,
p95: stats.p95 as number,
p99: stats.p99 as number,
offerCount: stats.offerCount,
};
prices[n] = current;
await this.redis.setJson(currentKey(n), current, BATCH_CACHE_TTL);
prices[p.key] = current;
await this.redis.setJson(
currentKey(p.codeNorm, p.brandNorm),
current,
BATCH_CACHE_TTL,
);
} else {
await this.redis.setJson(currentKey(n), { miss: true }, BATCH_CACHE_TTL);
await this.redis.setJson(
currentKey(p.codeNorm, p.brandNorm),
{ miss: true },
BATCH_CACHE_TTL,
);
}
}),
);
} catch (err) {
this.logger.warn(
`[part-prices] batch failed (${pending.length} codes): ${(err as Error).message}`,
`[part-prices] batch failed (${pending.length} parts): ${(err as Error).message}`,
);
}
}

View File

@@ -5,9 +5,10 @@ import type { HistoryEvent, SupplierOffer } from "./part-prices.logic";
* takip MySQL'ine (Tailscale, read-mostly) framework-bağımsız erişim — Nest
* servisi de worker processor'ı da bunu kullanır (Novu istemcisi kalıbı).
*
* Sorgu yolu: `sku_map(code_norm → product_id)` köprü tablosu (vmi üzerinde
* kurulu; tedarikçi SKU'larının "tam / ilk-boşluk-sonrası / ilk-tire-sonrası"
* normalize adaylarını indeksler) → `products` (güncel fiyat+stok) ve
* Sorgu yolu: `sku_map(code_norm → product_id, brand_norm)` köprü tablosu (vmi
* üzerinde kurulu; tedarikçi SKU'larının "tam / ilk-boşluk-sonrası /
* ilk-tire-sonrası" normalize adaylarını, markayı takip p10 kolonundan —
* yoksa sku önekinden — alarak indeksler) → `products` (güncel fiyat+stok) ve
* `product_history` (değişiklik logu). Tek yazma işi `refreshSkuMap` —
* idempotent INSERT IGNORE, son 3 günde güncellenen ürünleri haritaya ekler.
*/
@@ -18,17 +19,25 @@ const MAX_PRODUCTS_PER_CODE = 2000;
const MAX_HISTORY_ROWS = 150_000;
const MAX_BATCH_OFFER_ROWS = 60_000;
export interface CurrentStatsRow {
/** sku_map satırı: ürünün marka etiketiyle birlikte. */
export interface MappedProduct {
productId: number;
brandNorm: string;
}
export interface OfferRow {
codeNorm: string;
brandNorm: string;
price: number;
}
export interface SupplierPriceSource {
fetchProductIds(codeNorm: string): Promise<{ ids: number[]; truncated: boolean }>;
fetchMappedProducts(codeNorm: string): Promise<{ rows: MappedProduct[]; truncated: boolean }>;
fetchCurrentOffers(ids: number[]): Promise<SupplierOffer[]>;
fetchHistory(ids: number[]): Promise<{ events: HistoryEvent[]; truncated: boolean }>;
/** Batch: stoktaki tekliflerin (code_norm, price) satırları istatistik JS'te. */
fetchCurrentOfferRows(codeNorms: string[]): Promise<CurrentStatsRow[]>;
/** Batch: stoktaki tekliflerin (code_norm, brand_norm, price) satırları
* marka süzgeci ve istatistik JS'te (part-prices.logic). */
fetchCurrentOfferRows(codeNorms: string[]): Promise<OfferRow[]>;
/** sku_map artımlı bakım: son 3 günde güncellenen ürünleri haritaya ekler. */
refreshSkuMap(): Promise<void>;
end(): Promise<void>;
@@ -43,14 +52,17 @@ export function createSupplierPriceSource(url: string): SupplierPriceSource {
});
return {
async fetchProductIds(codeNorm) {
async fetchMappedProducts(codeNorm) {
const [rows] = await pool.query<RowDataPacket[]>(
"SELECT product_id FROM sku_map WHERE code_norm = ? LIMIT ?",
"SELECT product_id, brand_norm FROM sku_map WHERE code_norm = ? LIMIT ?",
[codeNorm, MAX_PRODUCTS_PER_CODE + 1],
);
const truncated = rows.length > MAX_PRODUCTS_PER_CODE;
return {
ids: rows.slice(0, MAX_PRODUCTS_PER_CODE).map((r) => Number(r.product_id)),
rows: rows.slice(0, MAX_PRODUCTS_PER_CODE).map((r) => ({
productId: Number(r.product_id),
brandNorm: String(r.brand_norm ?? ""),
})),
truncated,
};
},
@@ -90,28 +102,44 @@ export function createSupplierPriceSource(url: string): SupplierPriceSource {
async fetchCurrentOfferRows(codeNorms) {
if (codeNorms.length === 0) return [];
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT m.code_norm, p.price
`SELECT m.code_norm, m.brand_norm, p.price
FROM sku_map m JOIN products p ON p.id = m.product_id
WHERE m.code_norm IN (?) AND p.stock > 0 AND p.price > 0
LIMIT ?`,
[codeNorms, MAX_BATCH_OFFER_ROWS],
);
return rows.map((r) => ({ codeNorm: String(r.code_norm), price: Number(r.price) }));
return rows.map((r) => ({
codeNorm: String(r.code_norm),
brandNorm: String(r.brand_norm ?? ""),
price: Number(r.price),
}));
},
async refreshSkuMap() {
// sku → code_norm adayları: tam, ilk boşluk sonrası, ilk tire sonrası.
// İlk kurulumla (vmi /root/build_sku_map.sql) birebir aynı çıkarım kuralı.
// sku → code_norm adayları: tam, ilk boşluk sonrası, ilk tire sonrası;
// marka = p10'un son-token-öncesi kısmı, yoksa sku öneki. İlk kurulumla
// (vmi /root/build_sku_map.sql) birebir aynı çıkarım kuralı.
await pool.query(
`INSERT IGNORE INTO sku_map (code_norm, product_id)
SELECT c.code_norm, c.pid FROM (
SELECT UPPER(REGEXP_REPLACE(sku, '[^A-Za-z0-9]', '')) AS code_norm, id AS pid
`INSERT IGNORE INTO sku_map (code_norm, product_id, brand_norm)
SELECT c.code_norm, c.pid, c.brand_norm FROM (
SELECT UPPER(REGEXP_REPLACE(sku, '[^A-Za-z0-9]', '')) AS code_norm, id AS pid,
UPPER(REGEXP_REPLACE(CASE
WHEN p10 IS NOT NULL AND p10 LIKE '% %' THEN LEFT(p10, CHAR_LENGTH(p10) - CHAR_LENGTH(SUBSTRING_INDEX(p10, ' ', -1)) - 1)
WHEN sku LIKE '% %' THEN SUBSTRING_INDEX(sku, ' ', 1)
WHEN sku LIKE '%-%' THEN SUBSTRING_INDEX(sku, '-', 1)
ELSE '' END, '[^A-Za-z0-9]', '')) AS brand_norm
FROM products WHERE updated_at >= CURDATE() - INTERVAL 3 DAY
UNION ALL
SELECT UPPER(REGEXP_REPLACE(SUBSTRING(sku, LOCATE(' ', sku) + 1), '[^A-Za-z0-9]', '')), id
SELECT UPPER(REGEXP_REPLACE(SUBSTRING(sku, LOCATE(' ', sku) + 1), '[^A-Za-z0-9]', '')), id,
UPPER(REGEXP_REPLACE(CASE
WHEN p10 IS NOT NULL AND p10 LIKE '% %' THEN LEFT(p10, CHAR_LENGTH(p10) - CHAR_LENGTH(SUBSTRING_INDEX(p10, ' ', -1)) - 1)
ELSE SUBSTRING_INDEX(sku, ' ', 1) END, '[^A-Za-z0-9]', ''))
FROM products WHERE updated_at >= CURDATE() - INTERVAL 3 DAY AND sku LIKE '% %'
UNION ALL
SELECT UPPER(REGEXP_REPLACE(SUBSTRING(sku, LOCATE('-', sku) + 1), '[^A-Za-z0-9]', '')), id
SELECT UPPER(REGEXP_REPLACE(SUBSTRING(sku, LOCATE('-', sku) + 1), '[^A-Za-z0-9]', '')), id,
UPPER(REGEXP_REPLACE(CASE
WHEN p10 IS NOT NULL AND p10 LIKE '% %' THEN LEFT(p10, CHAR_LENGTH(p10) - CHAR_LENGTH(SUBSTRING_INDEX(p10, ' ', -1)) - 1)
ELSE SUBSTRING_INDEX(sku, '-', 1) END, '[^A-Za-z0-9]', ''))
FROM products WHERE updated_at >= CURDATE() - INTERVAL 3 DAY AND sku LIKE '%-%'
) c
WHERE CHAR_LENGTH(c.code_norm) BETWEEN 5 AND 64`,

View File

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

View File

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

View File

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

View File

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