fix(part-prices): OEM ana kartında yalnız orijinal (OE) fiyatları göster
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

OEM detay ana fiyat kartı seriyi markasız istiyordu; allowBrandless uzun
kodlarda orijinal + yan sanayi tekliflerini tek havuzda medyanlıyordu →
hiçbir satıcının fiyatı olmayan "hayalet medyan" ve marka stok girip-
çıkmasından fiyat değişmeden sahte grafik sıçraması (2026-07-14 ölçümü:
9827622780 pg p50 4.495₺ = PSA 5.531 ile OPAR 3.459'un ortası).

- filterOffersForBrand: markasız istek artık yalnız orijinal (OE/dağıtıcı
  etiketli) teklifleri kullanır; araç-markalı OE çapraz-ref çipleri de
  OE-only'ye iner, yan sanayi markasına orijinal fallback'i kaldırıldı.
- Orijinal sınıflaması otoritesi = kokpit marka tur='orjinal' seti (39
  marka, statik; PSA/MAIS/OPAR/ORJINAL... — FORD/GM/BMW/Mercedes/VW kokpit'te
  yansanayi, bilinçli dışarıda). Runtime kokpit bağımlılığı yok.
- Web: kart başlığı "Orijinal (OE) fiyat analizi" + açıklama/dipnot orijinal
  bilgisini yazıyor; ana OEM kodunun ölü markasız batch push'u kaldırıldı.
- Redis cache v2→v3 (anahtar üreticileri export'lanıp worker ile paylaşıldı);
  migration 0032 eski karma-havuz part_price satırlarını siler → OE-only
  kurallarla lazy re-backfill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 13:28:27 +03:00
parent 32655ead7b
commit d0caf57e89
8 changed files with 251 additions and 26 deletions

View File

@@ -6,10 +6,12 @@ import {
brandCompatible,
computeStats,
filterOffersForBrand,
isOeSupplyLabel,
istanbulToday,
normPartCode,
percentile,
reconstructDailySeries,
selectOeOffers,
} from "./part-prices.logic";
describe("normPartCode", () => {
@@ -174,17 +176,68 @@ describe("filterOffersForBrand", () => {
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 },
it("uzun kodda markasız istek YALNIZ orijinal teklifleri kullanır", () => {
// 2026-07-14 vakası: orijinal+yan sanayi tek havuzda "hayalet medyan"
// üretiyordu (9827622780 → 4.495₺, hiçbir satıcının fiyatı değil).
// kokpit tur'una göre MAIS+OPAR orijinal; SAGEMFRANS/FEBI değil.
const mixed = [
{ brandNorm: "MAIS", price: 245 },
{ brandNorm: "OPAR", price: 247 },
{ brandNorm: "SAGEMFRANS", price: 210 },
{ brandNorm: "FEBI", price: 160 },
];
expect(filterOffersForBrand(oe, "", "8200768913")).toHaveLength(2);
const out = filterOffersForBrand(mixed, "", "8200768913");
expect(out.map((o) => o.brandNorm).sort()).toEqual(["MAIS", "OPAR"]);
});
it("uzun kod + uyumsuz marka etiketi → dağıtıcı fallback'i (tümü)", () => {
it("markasız istekte hiç orijinal teklif yoksa boş (yan sanayi OE gibi gösterilmez)", () => {
const amOnly = [{ brandNorm: "BRUCKE", price: 890 }];
expect(filterOffersForBrand(amOnly, "", "46456072")).toEqual([]);
});
it("uzun kod + araç markası (OE çapraz-ref çipi) → yalnız orijinal teklifler", () => {
const oe = [
{ brandNorm: "MAIS", price: 195 },
{ brandNorm: "FEBI", price: 90 },
];
// RENAULT çipi: MAIS orijinal → gelir, FEBI (yan sanayi) elenir.
expect(filterOffersForBrand(oe, "RENAULT", "8200768913")).toEqual([
{ brandNorm: "MAIS", price: 195 },
]);
});
it("yan sanayi markasına orijinal fallback'i YOK — uyumsuzsa boş", () => {
const oe = [{ brandNorm: "MAIS", price: 195 }];
expect(filterOffersForBrand(oe, "RENAULT", "8200768913")).toHaveLength(1);
// FEBI çipi: MAIS'e (orijinal) düşmez, yan sanayi altında OE fiyatı gösterilmez.
expect(filterOffersForBrand(oe, "FEBIBILSTEIN", "8200768913")).toEqual([]);
});
});
describe("isOeSupplyLabel / selectOeOffers (kaynak: kokpit tur='orjinal')", () => {
it("dağıtıcı ve orijinal etiketleri tanır", () => {
expect(isOeSupplyLabel("MAIS")).toBe(true); // Renault dağıtıcısı
expect(isOeSupplyLabel("OPAR")).toBe(true); // Fiat/Tofaş
expect(isOeSupplyLabel("PSA")).toBe(true);
expect(isOeSupplyLabel("ORJINAL")).toBe(true); // kokpit tur='orjinal'
expect(isOeSupplyLabel("VECO")).toBe(true); // Iveco OE alias'ı
});
it("kokpit'in yansanayi/esdeger saydığı etiketler orijinal DEĞİL", () => {
// kokpit ham FORD/GM/BMW/Mercedes/VW etiketlerini yansanayi sayar.
expect(isOeSupplyLabel("FORD")).toBe(false);
expect(isOeSupplyLabel("GM")).toBe(false);
expect(isOeSupplyLabel("BOSCH")).toBe(false); // esdeger
expect(isOeSupplyLabel("FEBI")).toBe(false); // esdeger
expect(isOeSupplyLabel("")).toBe(false);
});
it("selectOeOffers karma havuzdan yalnız orijinalleri seçer", () => {
const out = selectOeOffers([
{ brandNorm: "OPAR", price: 767 },
{ brandNorm: "FORD", price: 480 },
{ brandNorm: "IBR", price: 909 },
]);
expect(out).toEqual([{ brandNorm: "OPAR", price: 767 }]);
});
});

View File

@@ -31,6 +31,145 @@ export function normPartCode(code: string): string {
// 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/dağıtıcı) etiketleri ─────────────────────────────────────
// Markasız bağlam (OEM detayın ana kodu) 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 ve marka stok girip-çıkması fiyat değişmeden
// sahte sıçrama çizer (2026-07-14 ölçümü: 9827622780 → pg p50 4.495₺ = PSA
// 5.531 ile OPAR 3.459'un ortası).
//
// OTORİTE = kokpit (otoyedekparca.co) marka `tur` sınıflaması: orjinal /
// esdeger / yansanayi. Aşağıdaki set = tur='orjinal' markalarının normalize
// adları (39 marka), 2026-07-14 çekimi. Yenilemek için (kokpit_center PG):
// SELECT UPPER(regexp_replace(name,'[^A-Za-z0-9]','','g'))
// FROM product_service_manufacturers WHERE is_deleted=false AND tur='orjinal';
// NOT: kokpit Türkiye pazarını yansıtır — orijinal = DAĞITICI etiketi
// (Mais=Renault, Opar=Fiat, Psa, Opel) + ithal-araç OE markaları. Ham FORD/
// GM/BMW/MERCEDES/VOLKSWAGEN etiketleri kokpit'te 'yansanayi'dir, bilinçli
// dışarıda. Runtime kokpit bağımlılığı YOK (statik liste; P entegrasyonu gibi
// fail-open değil, çekirdek mantık) — kokpit listesi değişince buradan yenile.
const OE_SUPPLY_LABELS: ReadonlySet<string> = new Set([
"AUDI",
"BERCITROEN",
"BUICK",
"CHEVROLET",
"CHRYSLER",
"DACIA",
"DAEWOO",
"DAIHATSU",
"DFM",
"DODGE",
"HONDA",
"JEEP",
"KIA",
"LADA",
"LEXUS",
"LINCOLN",
"MAIS",
"MAZDA",
"MINI",
"NISSAN",
"OEVECO",
"OPAR",
"OPEL",
"ORJINAL",
"PORSCHE",
"PROTON",
"PSA",
"RANGE",
"ROVER",
"SAAB",
"SEAT",
"SKODA",
"SSANGYONG",
"TATA",
"TESLA",
"TOGG",
"TOYOTA",
"TOYOTAORJINAL",
"VOLVO",
// Alias: takip verisi Iveco OE'sini "VECO" olarak etiketliyor; kokpit
// karşılığı "Oe-İveco" (OEVECO). Aynı fiziksel orijinal → dahil.
"VECO",
]);
/** Teklifin etiketi orijinal (OE/dağıtıcı) mı — kokpit `tur='orjinal'` setine göre. */
export function isOeSupplyLabel(brandNorm: string): boolean {
return OE_SUPPLY_LABELS.has(brandNorm);
}
/** Yalnız orijinal (OE/dağıtıcı) etiketli teklifler. */
export function selectOeOffers<T extends { brandNorm: string }>(offers: T[]): T[] {
return offers.filter((o) => isOeSupplyLabel(o.brandNorm));
}
// İstenen marka bir ARAÇ markası mı? OEM çapraz-referans çipleri araç markası
// (RENAULT) geçer ama teklifler dağıtıcı (MAIS) etiketli olur; bu durumda
// uyumsuz-fallback OE-only tekliflere iner. Yan sanayi markaları (FEBI/SWAG)
// bu sette olmadığından onlar için OE'ye düşmez (yan sanayi çipi OE fiyatı
// göstermez). Salt gate amaçlı — teklif sınıflaması OE_SUPPLY_LABELS'tır.
const VEHICLE_BRANDS: ReadonlySet<string> = new Set([
"RENAULT",
"DACIA",
"FIAT",
"ALFAROMEO",
"LANCIA",
"ABARTH",
"FORD",
"OPEL",
"VAUXHALL",
"PEUGEOT",
"CITROEN",
"DS",
"VW",
"VOLKSWAGEN",
"AUDI",
"SEAT",
"SKODA",
"CUPRA",
"BMW",
"MINI",
"MERCEDES",
"MERCEDESBENZ",
"SMART",
"TOYOTA",
"LEXUS",
"HONDA",
"NISSAN",
"INFINITI",
"HYUNDAI",
"KIA",
"VOLVO",
"IVECO",
"GM",
"CHEVROLET",
"MAZDA",
"PORSCHE",
"JEEP",
"DODGE",
"CHRYSLER",
"SUZUKI",
"MITSUBISHI",
"SUBARU",
"DAEWOO",
"SSANGYONG",
]);
/** İstenen marka araç markası mı (OE çapraz-ref çipi fallback'i için). */
export function isVehicleBrand(brandNorm: string): boolean {
return VEHICLE_BRANDS.has(brandNorm);
}
// ─── Redis cache anahtarları ───────────────────────────────────────────────
// Service ve worker processor'ı AYNI üreticileri kullanır (literal kopya
// drift'i olmasın). v3 = markasız/araç-markalı görünümler OE-only süzgece
// geçti; v2 karma-havuz değerleri bayat.
export const PART_PRICE_CACHE_VERSION = "v3";
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;
@@ -61,10 +200,16 @@ export function allowBrandless(codeNorm: string): boolean {
/**
* 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ş.
* - marka istendi → etiketi uyumlular; hiçbiri uymuyorsa ve istenen marka bir
* ARAÇ markasıysa (OEM çapraz-ref çipi: RENAULT istenir, teklifler MAIS
* dağıtıcı etiketi taşır) orijinal teklifler. Yan sanayi markasına (FEBI)
* orijinal fallback'i YOKTUR — yan sanayi çipi altında OE fiyatı göstermek
* yanlış veri olur,
* - marka istenmedi (OEM detay ana kartı) → yalnız orijinal (OE/dağıtıcı)
* teklifler; yan sanayi karışımı hayalet medyan üretir (v2 davranışı
* "hepsi" idi — 2026-07-14'te bilinçli daraltıldı),
* - kısa/çakışmaya açık kodlarda (allowBrandless=false) markasız ve
* fallback yolları kapalıdır (yanlış veri göstermekten iyidir).
*/
export function filterOffersForBrand<T extends { brandNorm: string }>(
offers: T[],
@@ -74,9 +219,12 @@ export function filterOffersForBrand<T extends { brandNorm: string }>(
if (requestedBrandNorm) {
const compat = offers.filter((o) => brandCompatible(o.brandNorm, requestedBrandNorm));
if (compat.length > 0) return compat;
return allowBrandless(codeNorm) ? offers : [];
if (isVehicleBrand(requestedBrandNorm) && allowBrandless(codeNorm)) {
return selectOeOffers(offers);
}
return [];
}
return allowBrandless(codeNorm) ? offers : [];
return allowBrandless(codeNorm) ? selectOeOffers(offers) : [];
}
export interface SupplierOffer {

View File

@@ -17,6 +17,8 @@ import {
filterOffersForBrand,
istanbulToday,
normPartCode,
partPriceCurrentCacheKey,
partPriceSeriesCacheKey,
reconstructDailySeries,
} from "./part-prices.logic";
import { type SupplierPriceSource, createSupplierPriceSource } from "./supplier-price-source";
@@ -54,10 +56,8 @@ const BATCH_CACHE_TTL = 1800;
const MAX_BATCH_PARTS = 400;
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}`;
const seriesKey = partPriceSeriesCacheKey;
const currentKey = partPriceCurrentCacheKey;
/**
* Parça (kod + marka) bazlı tedarikçi fiyat görünümleri. P-servisi sözleşmesi:
@@ -67,7 +67,8 @@ const currentKey = (codeNorm: string, brandNorm: string) =>
* 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.
* sorgu (OEM detay ana kartı) yalnızca ORİJİNAL (OE/dağıtıcı) teklifleri
* kullanır — orijinal+yan-sanayi havuzu hayalet medyan üretir (2026-07-14).
*
* - 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