Files
sase.tr/apps/api/src/part-prices/part-prices.logic.ts
Semih Yesilyurt 66b39862c8
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
fix(part-prices): araç-marka etiketlerini de orijinal say (FORD/GM/BMW/Mercedes/VW)
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>
2026-07-14 15:34:10 +03:00

342 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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" });
}