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

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