Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Kullanıcı düzeltmesi: kokpit tur ham araç-marka etiketlerini "yansanayi" sayıyordu — yanlış. FORD/GM/BMW/Mercedes/Volkswagen/Renault… OE etiketidir. Orijinal tespiti artık kokpit tur='orjinal' setine değil, OE aile üyeliğine dayanır: OE_SUPPLY_LABELS OE_FAMILIES'ten TÜRETİLİR (+ jenerik ORJINAL), böylece OE-tespiti ile aile-filtresi drift etmez ve tüm araç markaları orijinal sayılır. Gerçek yan sanayi PARÇA markaları (Bosch/Febi/Valeo/TRW) hiçbir ailede yok → orijinal değil. GM Opel ailesine eklendi (TR'de Opel OE'si). Redis v3→v4 (eski "miss" değerleri bayat). kokpit'e DOKUNULMADI. Ölçülen etki (dev): grafikli kod %0,77→%0,84; artış sase kataloğu ile takip beslemesinin bu markalardaki kod kesişimiyle sınırlı (ör. takip'te 4.859 Ford- stokta koddan 335'i sase kataloğunda). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
342 lines
14 KiB
TypeScript
342 lines
14 KiB
TypeScript
/**
|
||
* Tedarikçi fiyat serisi saf mantığı — DB'siz, yan etkisiz.
|
||
*
|
||
* Kaynak model (takip MySQL):
|
||
* - `products` : güncel durum (ürün başına son fiyat + stok; her gün 19:00
|
||
* İstanbul'da sync biter).
|
||
* - `product_history`: DEĞİŞİKLİK logu — bir ürünün satırı yalnızca fiyatı
|
||
* veya stoğu değiştiği gün yazılır (prev_* bir önceki değer;
|
||
* ürün takibe o gün girdiyse prev'ler 0). Tam-gün snapshot
|
||
* YOKTUR; günlük durum carry-forward ile kurulur.
|
||
*
|
||
* Bir parça kodunun günlük istatistiği: o gün stokta (stock > 0) ve geçerli
|
||
* fiyatlı (price > 0) tedarikçi tekliflerinin p50/p95/p99'u + teklif sayısı.
|
||
* Teklif yoksa fiyatlar null, offerCount 0 — "stok yok" da seriye işlenir.
|
||
*/
|
||
|
||
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`. 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.
|
||
|
||
// ─── Orijinal (OE) etiketleri ve marka aileleri ────────────────────────────
|
||
// Markasız/araç-markalı bağlam (OEM detay kartı) yalnız ORİJİNAL teklifleri
|
||
// gösterir: orijinal + yan sanayi tek havuzda medyanlanınca hiçbir gerçek
|
||
// ürünün fiyatı olmayan "hayalet fiyat" çıkar (2026-07-14: 9827622780 →
|
||
// 4.495₺ = PSA 5.531 ile OPAR 3.459'un ortası).
|
||
//
|
||
// Bir OEM kodu tek bir araç markasına aittir → teklifleri o markanın OE
|
||
// ailesine süzeriz (aynı koda düşen PSA vs OPAR karışmaz). `family → üyeler`;
|
||
// üyeler hem araç markası adları (istek/sase brands) hem dağıtıcı etiketleridir
|
||
// (teklif/takip: MAIS=Renault, OPAR=Fiat, PSA, VECO=Iveco, GM=Opel).
|
||
//
|
||
// ORİJİNAL TESPİTİ: bir teklif etiketi orijinal sayılır ancak bir OE ailesinin
|
||
// üyesiyse ya da jenerik "ORJINAL" ise. Kaynak = sase araç markaları + Türkiye
|
||
// OE-dağıtıcı etiketleri. NOT (2026-07-14 kullanıcı düzeltmesi): ham araç-marka
|
||
// etiketleri (FORD, GM, BMW, MERCEDES, VOLKSWAGEN, RENAULT, FIAT…) ORİJİNALDİR
|
||
// — kokpit `tur` bunları 'yansanayi' sayıyordu, bu YANLIŞ; burada aileleriyle
|
||
// birlikte orijinal kabul edilir. Gerçek yan sanayi PARÇA markaları (Bosch/
|
||
// Febi/Valeo/TRW…) hiçbir ailede değildir → orijinal sayılmaz.
|
||
const OE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
||
RENAULT: ["RENAULT", "DACIA", "ALPINE", "MAIS"],
|
||
FIAT: ["FIAT", "ALFAROMEO", "LANCIA", "ABARTH", "OPAR", "TOFAS"],
|
||
PSA: ["PEUGEOT", "CITROEN", "DS", "PSA"],
|
||
OPEL: ["OPEL", "VAUXHALL", "GM"], // GM = General Motors, TR'de Opel OE'si
|
||
VAG: ["VOLKSWAGEN", "VW", "AUDI", "SEAT", "SKODA", "CUPRA", "BENTLEY"],
|
||
FORD: ["FORD"],
|
||
BMW: ["BMW", "MINI"],
|
||
MERCEDES: ["MERCEDESBENZ", "MERCEDES", "SMART"],
|
||
TOYOTA: ["TOYOTA", "TOYOTAORJINAL", "LEXUS"],
|
||
HONDA: ["HONDA"],
|
||
NISSAN: ["NISSAN", "INFINITI"],
|
||
HYUNDAI: ["HYUNDAI"],
|
||
KIA: ["KIA"],
|
||
MAZDA: ["MAZDA"],
|
||
VOLVO: ["VOLVO", "POLESTAR"],
|
||
PORSCHE: ["PORSCHE"],
|
||
JLR: ["JAGUAR", "LANDROVER"],
|
||
MAN: ["MAN"],
|
||
MITSUBISHI: ["MITSUBISHI"],
|
||
SUBARU: ["SUBARU"],
|
||
SUZUKI: ["SUZUKI"],
|
||
IVECO: ["IVECO", "VECO", "OEVECO"],
|
||
// sase araç listesinde olmayan ama teklif/istek olarak gelebilecek markalar.
|
||
CHRYSLER: ["CHRYSLER", "DODGE", "JEEP", "MOPAR", "LINCOLN", "BUICK"],
|
||
CHEVROLET: ["CHEVROLET"],
|
||
DAEWOO: ["DAEWOO"],
|
||
DAIHATSU: ["DAIHATSU"],
|
||
DFM: ["DFM"],
|
||
LADA: ["LADA"],
|
||
PROTON: ["PROTON"],
|
||
ROVER: ["ROVER", "RANGE"],
|
||
SAAB: ["SAAB"],
|
||
SSANGYONG: ["SSANGYONG"],
|
||
TATA: ["TATA"],
|
||
TESLA: ["TESLA"],
|
||
TOGG: ["TOGG"],
|
||
};
|
||
|
||
const OE_FAMILY_OF: ReadonlyMap<string, string> = new Map(
|
||
Object.entries(OE_FAMILIES).flatMap(([family, members]) =>
|
||
members.map((m) => [m, family] as [string, string]),
|
||
),
|
||
);
|
||
|
||
/** Markanın (araç ya da dağıtıcı etiketi) OE ailesi; bilinmiyorsa null.
|
||
* Jenerik "ORJINAL" hiçbir aileye ait değildir (markası belirsiz) → null. */
|
||
export function oeFamilyOf(brandNorm: string): string | null {
|
||
return OE_FAMILY_OF.get(brandNorm) ?? null;
|
||
}
|
||
|
||
// Orijinal etiket seti = tüm OE aile üyeleri + jenerik "ORJINAL" (orijinal ama
|
||
// aile-belirsiz: markasız/taban görünümde sayılır, aile filtresinde elenir).
|
||
// Aileden TÜRETİLİR → OE-tespiti ile aile-filtresi asla drift etmez; yeni bir
|
||
// marka eklemek için tek yer OE_FAMILIES.
|
||
const OE_SUPPLY_LABELS: ReadonlySet<string> = new Set([
|
||
...Object.values(OE_FAMILIES).flat(),
|
||
"ORJINAL",
|
||
]);
|
||
|
||
/** Teklifin etiketi orijinal mi (bir OE ailesinin üyesi ya da jenerik ORJINAL). */
|
||
export function isOeSupplyLabel(brandNorm: string): boolean {
|
||
return OE_SUPPLY_LABELS.has(brandNorm);
|
||
}
|
||
|
||
/** Yalnız orijinal (OE) etiketli teklifler. */
|
||
export function selectOeOffers<T extends { brandNorm: string }>(offers: T[]): T[] {
|
||
return offers.filter((o) => isOeSupplyLabel(o.brandNorm));
|
||
}
|
||
|
||
// ─── Redis cache anahtarları ───────────────────────────────────────────────
|
||
// Service ve worker processor'ı AYNI üreticileri kullanır (literal kopya
|
||
// drift'i olmasın). v4 = OE seti araç-marka etiketlerini (FORD/GM/BMW/MERCEDES/
|
||
// VW…) de kapsayacak şekilde genişledi → v3'teki "miss" değerleri bayat.
|
||
export const PART_PRICE_CACHE_VERSION = "v4";
|
||
export const partPriceSeriesCacheKey = (codeNorm: string, brandNorm: string) =>
|
||
`partprice:series:${PART_PRICE_CACHE_VERSION}:${codeNorm}::${brandNorm}`;
|
||
export const partPriceCurrentCacheKey = (codeNorm: string, brandNorm: string) =>
|
||
`partprice:cur:${PART_PRICE_CACHE_VERSION}:${codeNorm}::${brandNorm}`;
|
||
|
||
/** 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:
|
||
* - istenen marka bir ARAÇ/OE markasıysa (OEM kartına o kodun araç markası
|
||
* geçer, ör. PEUGEOT) → yalnız o markanın OE ailesinden orijinal teklifler
|
||
* (PEUGEOT → PSA ailesi; OPAR/MAIS gibi başka aile dağıtıcıları elenir).
|
||
* Böylece aynı koda düşen PSA vs OPAR karışıp medyanı bozmaz. Jenerik
|
||
* "ORJINAL" (aile belirsiz) bu süzgeçte elenir,
|
||
* - istenen marka bir YAN SANAYİ markasıysa (FEBI çipi) → etiketi uyumlu
|
||
* teklifler (kısaltma/önek eşleşmesi); OE'ye düşme YOK,
|
||
* - marka istenmedi (kodun markası bilinmiyor) → yalnız orijinal (tüm OE
|
||
* aileleri) — güvenli taban; yan sanayi karışımı hayalet medyan üretirdi,
|
||
* - kısa/çakışmaya açık kodlarda (allowBrandless=false) araç-markası ve
|
||
* markasız yolları kapalıdır (yanlış veri göstermekten iyidir).
|
||
*/
|
||
export function filterOffersForBrand<T extends { brandNorm: string }>(
|
||
offers: T[],
|
||
requestedBrandNorm: string,
|
||
codeNorm: string,
|
||
): T[] {
|
||
if (requestedBrandNorm) {
|
||
const family = oeFamilyOf(requestedBrandNorm);
|
||
if (family) {
|
||
if (!allowBrandless(codeNorm)) return [];
|
||
return offers.filter(
|
||
(o) => isOeSupplyLabel(o.brandNorm) && oeFamilyOf(o.brandNorm) === family,
|
||
);
|
||
}
|
||
// Aile eşlemesi yok → yan sanayi markası: etiket uyumuna göre süz.
|
||
return offers.filter((o) => brandCompatible(o.brandNorm, requestedBrandNorm));
|
||
}
|
||
return allowBrandless(codeNorm) ? selectOeOffers(offers) : [];
|
||
}
|
||
|
||
export interface SupplierOffer {
|
||
productId: number;
|
||
price: number;
|
||
stock: number;
|
||
}
|
||
|
||
export interface HistoryEvent {
|
||
productId: number;
|
||
/** YYYY-MM-DD */
|
||
date: string;
|
||
price: number;
|
||
stock: number;
|
||
prevPrice: number;
|
||
prevStock: number;
|
||
}
|
||
|
||
export interface DailyPoint {
|
||
/** YYYY-MM-DD */
|
||
date: string;
|
||
p50: number | null;
|
||
p95: number | null;
|
||
p99: number | null;
|
||
offerCount: number;
|
||
}
|
||
|
||
/** PERCENTILE_CONT (lineer interpolasyon). `sorted` artan sıralı ve boş değil. */
|
||
export function percentile(sorted: number[], p: number): number {
|
||
const n = sorted.length;
|
||
if (n === 1) return sorted[0];
|
||
const rank = (p / 100) * (n - 1);
|
||
const lo = Math.floor(rank);
|
||
const hi = Math.ceil(rank);
|
||
if (lo === hi) return sorted[lo];
|
||
return sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo);
|
||
}
|
||
|
||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||
|
||
/** Geçerli (price>0, stock>0) tekliflerden günlük istatistik. Teklif yoksa
|
||
* null-fiyatlı sıfır satırı döner (seriye "stok yok" olarak işlenir). */
|
||
export function computeStats(offers: Iterable<{ price: number; stock: number }>): {
|
||
p50: number | null;
|
||
p95: number | null;
|
||
p99: number | null;
|
||
offerCount: number;
|
||
} {
|
||
const prices: number[] = [];
|
||
for (const o of offers) {
|
||
if (o.stock > 0 && o.price > 0) prices.push(o.price);
|
||
}
|
||
if (prices.length === 0) return { p50: null, p95: null, p99: null, offerCount: 0 };
|
||
prices.sort((a, b) => a - b);
|
||
return {
|
||
p50: round2(percentile(prices, 50)),
|
||
p95: round2(percentile(prices, 95)),
|
||
p99: round2(percentile(prices, 99)),
|
||
offerCount: prices.length,
|
||
};
|
||
}
|
||
|
||
const sameStats = (a: DailyPoint, b: DailyPoint) =>
|
||
a.p50 === b.p50 && a.p95 === b.p95 && a.p99 === b.p99 && a.offerCount === b.offerCount;
|
||
|
||
/**
|
||
* Change-log'dan sıkıştırılmış günlük seri kurar: yalnızca istatistiğin
|
||
* değiştiği günler için nokta üretir (grafik step-after çizer, ara günler
|
||
* carry-forward'dur).
|
||
*
|
||
* - Bir ürünün ilk event'inden ÖNCEKİ durumu = (prevPrice, prevStock); ürün
|
||
* takibe o gün girdiyse (prev'ler 0) öncesinde piyasada yok sayılır.
|
||
* - Hiç event'i olmayan ürünler tracking başından beri değişmemiştir →
|
||
* güncel değerleri tüm pencere boyunca sabittir.
|
||
* - Son nokta her zaman `current` (products tablosu = bugünün gerçeği) ile
|
||
* tutarlı hale getirilir; fark varsa `todayIso` tarihli nokta eklenir.
|
||
*
|
||
* `windowStart` = takip'in veri başlangıcı; baseline (event-öncesi) durumun
|
||
* tarihi olarak kullanılır. Event'ler tarih artan sırada gelmeli (SQL ORDER BY).
|
||
*/
|
||
export function reconstructDailySeries(
|
||
events: HistoryEvent[],
|
||
current: SupplierOffer[],
|
||
windowStart: string,
|
||
todayIso: string,
|
||
): DailyPoint[] {
|
||
// Ürün başına güncel durum — event'i olmayan ürünlerin sabit değeri ve
|
||
// baseline'da "var mı yok mu" kararı için.
|
||
const state = new Map<number, { price: number; stock: number }>();
|
||
const hasEvents = new Set<number>();
|
||
for (const e of events) hasEvents.add(e.productId);
|
||
for (const c of current) {
|
||
if (!hasEvents.has(c.productId)) state.set(c.productId, { price: c.price, stock: c.stock });
|
||
}
|
||
|
||
// Baseline: event'li ürünler ilk event'lerinin prev değerleriyle başlar
|
||
// (prev'leri 0 olanlar — takibe sonradan girenler — baseline'da yok).
|
||
const firstSeen = new Set<number>();
|
||
for (const e of events) {
|
||
if (firstSeen.has(e.productId)) continue;
|
||
firstSeen.add(e.productId);
|
||
if (e.prevPrice > 0 || e.prevStock > 0) {
|
||
state.set(e.productId, { price: e.prevPrice, stock: e.prevStock });
|
||
}
|
||
}
|
||
|
||
const series: DailyPoint[] = [];
|
||
const emit = (date: string) => {
|
||
const point: DailyPoint = { date, ...computeStats(state.values()) };
|
||
const last = series[series.length - 1];
|
||
if (last && sameStats(last, point)) return;
|
||
if (last && last.date === date) {
|
||
series[series.length - 1] = point;
|
||
return;
|
||
}
|
||
series.push(point);
|
||
};
|
||
|
||
// Baseline'da en az bir geçerli teklif varsa pencere başına nokta koy.
|
||
const firstEventDate = events[0]?.date;
|
||
if (firstEventDate !== windowStart && computeStats(state.values()).offerCount > 0) {
|
||
emit(windowStart);
|
||
}
|
||
|
||
let cursor: string | null = null;
|
||
for (const e of events) {
|
||
if (cursor !== null && e.date !== cursor) emit(cursor);
|
||
cursor = e.date;
|
||
state.set(e.productId, { price: e.price, stock: e.stock });
|
||
}
|
||
if (cursor !== null) emit(cursor);
|
||
|
||
// Bugünün gerçeği: products tablosu. History'den türeyen son durumla
|
||
// çelişiyorsa (ör. history LIMIT'e takıldı ya da sync arası) bugünü düzelt.
|
||
state.clear();
|
||
for (const c of current) state.set(c.productId, { price: c.price, stock: c.stock });
|
||
const todayPoint: DailyPoint = { date: todayIso, ...computeStats(state.values()) };
|
||
const last = series[series.length - 1];
|
||
if (!last || !sameStats(last, todayPoint)) {
|
||
if (last && last.date === todayIso) series[series.length - 1] = todayPoint;
|
||
else if (todayPoint.offerCount > 0 || last) series.push(todayPoint);
|
||
}
|
||
|
||
return series;
|
||
}
|
||
|
||
/** Bugünün YYYY-MM-DD'si, İstanbul takvimine göre (takip sync'i 19:00 İstanbul
|
||
* civarı biter; gece yarısı kaymalarında gün etiketi TR gününe sabitlenir). */
|
||
export function istanbulToday(now: Date = new Date()): string {
|
||
return now.toLocaleDateString("en-CA", { timeZone: "Europe/Istanbul" });
|
||
}
|