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:
32
apps/api/drizzle/0019_part_price_brand.sql
Normal file
32
apps/api/drizzle/0019_part_price_brand.sql
Normal 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");
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 : []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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