409 Commits

Author SHA1 Message Date
71568e2274 feat(rpartstore): Renault/Dacia için RPartStore VIN-decode fallback kaynağı
Some checks are pending
QA Gate (P0/P1) / Test affected app (pull_request) Waiting to run
rpartstore.renault.com (Renault Group bayi portalı) yeni decode kaynağı olarak
eklendi. Portal REST değil STOMP 1.2 over WebSocket konuşuyor; login Okta OIE
(IDX JSON API + PKCE), tarayıcı gerekmiyor. Sıralama Renault/Dacia için:
pcat + PL24 + emex yarışı → rpartstore → (bayrağı açıksa) Vinpin son çare.

- integrations/rpartstore: STOMP codec, Okta login, oturum (trace-id ile
  çok-mesajlı cevap eşleme), istemci (Redis'te 1 saatlik token), Renault/Dacia
  yönlendirme, RPartStore → PL24 catalog_vehicles model eşleyici (roman rakam,
  karoseri niteleyici, yanlış-pazar cezası).
- rpartstore_decodes tablosu (migration 0037) + rpartstore-decode BullMQ
  kuyruğu; worker concurrency 1 + 1 iş / 6 s limiter (portal 2 arama / 10 s).
- Günlük sert kota RPARTSTORE_DAILY_CAP (varsayılan 10, İstanbul günü):
  işlemci göndermeden önce Redis INCR ile rezervasyon yapar, aşan VIN'ler
  `capped` olur ve 24 saat sonra tekrar denenir; API kota doluysa kuyruğa
  hiç almaz. Kısa vadeli rate-limit cevabında retryAfter kadar bekleyip bir
  kez tekrar dener.
- VehiclesService.tryRpartstoreFallback: Vinpin fallback ile aynı sözleşme
  (decoding/catalogVehicle cevapları, tek başarısızlık log satırı).
- Env: RPARTSTORE_ENABLED/USER/PASS/DAILY_CAP/BROKER_URL/APP_VERSION
  (compose api+worker). Vinpin koda dokunulmadan pasif kalır (VINPIN_ENABLED).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 13:58:09 +03:00
41147f3f05 fix(pl24): kendi bütçe frenimiz "hesap banlandı" alarmı üretmesin
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Prod 2026-09-21 19:10 (İstanbul) itibarıyla Telegram "🔴 PL24 giriş
yapılamıyor — hesap banlanmış olabilir" dedi. Hesap sağlamdı:
kullanıcı tarayıcıdan giriş yapabildi, aynı gün 143 başarılı auth
çağrısı ve **0 × 401** vardı. Gerçek sebep alarmın kendi metninde:
"PL24 daily HTTP budget exhausted (user lane: 1205/1200)" — yani
upstream değil, BİZİM günlük tavanımız.

Zincir: `attemptLogin` içindeki `budget.consume()` fırlatıyor → dış
`catch` bunu `{ error }` olarak düzleştiriyor → `loginWithLock`
`!result.sessionToken` dalına giriyor → `breakerFail()` + kesinti
sayacı → bir saat sonra ban alarmı. Yani kendi frenimiz hem devre
kesiciyi tetikliyor hem de operatörü yanlış yere çağırıyor.

- `PL24BudgetExceededError` artık `attemptLogin` ve
  `authorizeServiceForAccount` içinde olduğu gibi yukarı çıkıyor;
  giriş/yetkilendirme hatasına çevrilmiyor. Devre kesici tetiklenmiyor,
  kesinti sayacı başlamıyor, Telegram susuyor. Çağıran taraf PL24'ü
  atlayıp pcat/emex'e düşüyor — bugün de öyle oluyordu, ama artık
  sessizce ve doğru gerekçeyle.
- Şerit bazlı sayaç eklendi: `pl24:http:<gün>:{user,worker}`. Paylaşılan
  toplam tavanın NE ZAMAN dolduğunu söylüyordu ama KİMİN harcadığını
  söylemiyordu; tavanı veriyle boyutlandırmak için bu şart. Bugün
  toplam 1200'e vurdu ve 16:10 UTC'den sonra her kullanıcı decode'u
  reddedildi, ama ne kadarının ısıtma prefetch'i ne kadarının ekran
  başında bekleyen biri olduğu `proxy_logs`'tan çıkarılamıyordu.

5 yeni test. api 650 test geçiyor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:52:52 +03:00
e03d721c37 Merge pull request 'feat(pl24): Faz 3 — backfill anahtarı + Mitsubishi parça-listesi düzeltmesi' (#270) from dev into main 2026-09-21 20:47:52 +03:00
142ec3a150 feat(pl24): Faz 3 — backfill anahtarı + Mitsubishi parça-listesi düzeltmesi
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Faz 3 backfill'i açmadan önce hacmi ölçtüm ve raporun işaret ettiğinden
çok daha büyük bir israf çıktı.

**Mitsubishi'nin parça listesi grup sanılıyordu.**
`/p5mitsubishi/extern/details/vinDetails` yanıtı `partno`/`qty` taşıyan
bir PARÇA listesi (canlı doğrulama: 16 kayıt), ama her kaydın kendi
linki `partInfoTable` ve ne wid ne yol sınıflandırıcıda karşılık
buluyordu. Sonuç (prod ölçümü): 2.193 parça listesi grup düğümüne
döndü, içlerindeki 19.576 tekil parça ("SCREW,LOCK CYLINDER",
"BOLT,STEERING COLUMN WASHER") kategori olarak kaydedildi. Bu 19.576
sahte düğümün TOPLAM 2 tanesinde parça var ve hepsi her prefetch
turunda yeniden çekiliyor. İkisi de %100 Mitsubishi.

- `detailsTable` artık yaprak, `isPl24PartDetailNode()` ile
  `partInfoTable` hiç kuyruklanmıyor.
- `isLeafLinkPath` artık `linkWid`'i de geçiriyor. Okuma yolu bu
  güvenilir sinyali hep kullanıyordu ama kuyruklama yolu düşürüyordu —
  sınıflandırıcı `detailsTable`'ı öğrense bile burada yine grup
  sayılacaktı.
- Migration 0036: 19.576 sahte kategori siliniyor. Okuma yolu bir
  düğümün ÖNCE çocuklarına baktığı için bu silme düzeltmenin parçası,
  ayrı temizlik değil. Kuru çalıştırma: 19.576 kategori, 9 araç, 2
  parça, Mitsubishi dışı 0.

**Backfill anahtarı.** `PL24_BACKFILL_ENABLED` eklendi, varsayılan
KAPALI. Eski `PL24_TR_DISABLED` adı "tr hesabı öldü" diyordu ama işi
"toplu yükü tek sağ kalan hesaptan uzak tut"tu. Eski değişken hâlâ
kapatabiliyor — yarım deploy musluğu sessizce açamasın. Kullanıcı
tetikli fast-lane bu anahtardan etkilenmiyor.

9 yeni test. api 647 test geçiyor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:51:42 +03:00
e8ffa6a5db Merge pull request 'fix(pl24): model listesi giriş noktasını catmeta'dan çöz (Volvo browse)' (#269) from dev into main 2026-09-20 10:18:50 +03:00
bd9f4bd3f8 style(pl24): normalizeLabel'daki yanıltıcı karakter sınıfını düzelt
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
`[̀-ͯ]` bir aralık sınıfı; biome bunu "bir taban karakter +
birleştirici karakter çiftini de eşleyebilir" diye işaretliyordu.
NFD zaten her aksanı kendi işaretine ayırdığı için doğrudan `\p{M}`
(tüm birleştirici işaretler) hem doğru hem daha geniş.

Gerçek Volvo/PSA/Subaru yakalamalarıyla çıktı birebir aynı kaldı.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:32:24 +03:00
d75c468341 fix(pl24): model listesi giriş noktasını catmeta'dan çöz
Prod'da browse onarması Peugeot'yu taşıdı (64 bayat satır → 39 güncel
model) ama Volvo'da "upstream returned no models" ile korumalı dala
düştü. Sebep: `BACKEND_MODEL_PATH`'te p5volvo yok ve yedek keşif yedi
bilinen yolu deniyor — Volvo'nunki `/extern/vehicles/models`, yani
ÇOĞUL "vehicles", listede yok. Volvo/Polestar browse bu yüzden sıfır
model tohumluyor ve emekli P4 listesini sunmaya devam ediyordu.

Otoritatif kaynak her P5 backend'inin kendi `/extern/catmeta`
yanıtındaki `data.catalogEntryPoint.path`. Canlı doğrulandı:
  p5volvo → /p5volvo/extern/vehicles/models  (60 model: XC90, V40…)
  p5psa   → /p5psa/extern/vehicle/catalogs

İki değişiklik:
- p5psa ve p5volvo `BACKEND_MODEL_PATH`'e sabitlendi (sıfır ek istek).
  p5psa zaten çalışıyordu ama her çağrıda beş boşa probe isteğiyle
  yeniden keşfediliyordu.
- `resolveModelPathFromCatmeta()`: haritada olmayan backend artık yedi
  kör probe yerine tek otoritatif catmeta çağrısı yapıyor. Sonuç
  backend başına 30 gün cache'leniyor (olumsuz sonuç dahil), yani
  backend ömrü boyunca tek istek; PL24 bir ucu taşırsa kendiliğinden
  düzeliyor. Hata durumunda null → eski yedeğe düşer.

8 yeni test. api 638 test geçiyor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:30:53 +03:00
a4e22e1bb8 Merge pull request 'fix(pl24): bütçe kilitlenmesi, sabitlenmiş browse satırları, Volvo alanları + WMI kapsamı' (#268) from dev into main 2026-09-20 09:15:26 +03:00
c2f4d8f421 feat(pl24): WMI haritasını canlı servis taramasıyla genişlet, Renault'yu aç
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Prod'daki 4.799 aracın 1.406'sının WMI'si haritada yoktu. Hangilerinin
PL24'te gerçekten karşılığı olduğu tahmin edilmedi — canlı
`/pl24-wmi/ext/api/2.0/decode` servisine gerçek prod VIN'leriyle tek tek
soruldu (2026-09-20).

**Renault yeniden açıldı (450 araç).** İki gerekçe de artık geçersiz,
ikisi de prod'a karşı doğrulandı:
- Askı kalkmış: `/p5renault` directAccess, gerçek müşteri aracı
  VF14SRCL458170337 için `VEHICLE_IDENTIFIED` + "SYMBOL II/LOGAN II"
  döndü; WMI servisi de `{service: renault_parts, error: false}` diyor.
- Devre kesici artık kesin olumsuz yanıtları saymıyor, yalnız geçici
  taşıma hatalarını (`vehicles.service` `isTransient`).

**Yeni eşlemeler (toplam 730 araç):** NLH/TMA/NLJ/KMF → hyundai_parts,
KNE/KNC → kia_parts, MMC/XMC → mmc_parts, JSA → suzuki_parts,
NMB → **mercestrucks**_parts (servis binek Mercedes'e değil kamyon
kataloğuna çözüyor; bu 30 araç "Mercedes-Benz" etiketliydi ama binek
kataloğunda yok).

**Kasıtlı olarak EKLENMEYENLER** — servis HTTP 410 "no brands found"
dedi, tıpkı NM4 ve VR7 gibi: JHM/SHH/SHS/MAK/NLA (Honda), KL1
(Chevrolet). Eklemek yalnız boşuna istek üretir ve pcat/emex/vinpin
fallback'ini geciktirir. JMZ (Mazda) 410 değil ama fordp/fordt
döndürüyor (Ford-Mazda platform ortaklığı); marka tutarsız olduğu için
eklenmedi.

6 yeni test; "olmayanlar" da kilitlendi. Ayrıca her eşlemenin katalog
tanımı olduğunu doğrulayan bütünlük testi. api 623 test geçiyor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 08:53:07 +03:00
72f8c2a3e4 fix(pl24): Volvo alanlarında iç kodu değil okunur değeri kullan
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Volvo vinfoBasic aynı özniteliği iki kez veriyor: "Şanzıman kodu" = "B"
ile "Şanzıman" = "6-PSHIFT 2WD / MPS6", "Satis tipi" = "42" ile "Türü"
= "S80". VAG için doğru olan kod-önce sırası (VAG'ın "Şanzıman kodu"
zaten anlamlı: "MQ200") bu yüzden Volvo'da şanzımanı tek harf, seriyi
çıplak sayı yapıyordu.

`lookupDescriptive()` eklendi: listedeki ilk *açıklayıcı* değeri döner
(2 karakterden uzun ve salt rakam değil), hiçbiri açıklayıcı değilse
ilk mevcut değere düşer — yani yalnız kod üreten backend'ler aynen
eskisi gibi davranır. Şanzıman ve seri bu yardımcıya geçti.

Ayrıca eksik İngilizce etiketler eklendi: `transmission` (İngilizce
yanıtta şanzıman yine tek harfe düşüyordu) ve `body_style`.

Ölçüm (gerçek keşif yakalamaları, YV1AS84ABD1168166 S80):
- şanzıman "B" → "6-PSHIFT 2WD / MPS6"
- seri "42" → "S80"
- kaporta İngilizce yanıtta null → "Sedan"
- PSA İngilizce yanıtta şanzıman null → "BVM5"
PSA Türkçe, Subaru ve diğer markalarda çıktı değişmedi.

6 yeni test + iki dilde gerçek Volvo fixture'ı. api 617 test geçiyor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 08:45:16 +03:00
0928559ea4 fix(catalog): browse onarmasında yalnız bayat satırları sil
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Bir servis karışık olabilir: bazı satırları yeni mimariyle yeniden
listelenmiş, bazıları hâlâ eski. Silme servis adına göre yapıldığında
zaten taşınmış satırlar da gidiyordu — üstteki insert onları
`onConflictDoNothing` ile atladığı için geri gelmiyorlar, yani temelli
kayıp. Silme artık yalnız gerçekten bayat olan satırlara uygulanıyor.

Hangi satırların silindiğini doğrulayan test eklendi (drizzle
`inArray` parametrelerini okuyarak).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 08:40:26 +03:00
eb18c9a122 fix(catalog): sabitlenmiş browse satırlarını görüntülendikçe yenile
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
`catalog_vehicles` kalıcı bir cache: `getModels` markanın tek bir satırı
varsa erken dönüyor, dolayısıyla `fetchVehicleList` o marka için bir
daha hiç çağrılmıyor. PSA ve Volvo hâlâ P4 iken listelenmiş 188 satır
(prod 2026-09-20: 127 LEGACY_PSA + 61 LEGACY_VOLVO) bu yüzden kalıcı
olarak çakılı kalmıştı:

- Peugeot/Citroën browse donmuş 2024-02-13 anlık görüntüsünü sunuyor,
- Volvo/Polestar browse HTTP 503 dönen bir uca gidiyor.

Kod düzeltmesi bu satırlara hiçbir zaman ulaşmıyordu.

`isStaleBrowseArchitecture()` + `CatalogService.healStaleBrowseRows()`:
satırın kayıtlı mimarisi servis tablosundakiyle uyuşmuyorsa marka bir
kez yeniden listeleniyor, yeni satırlar yazılıp eskiler aynı
transaction'da siliniyor. VIN tarafındaki onarma ile aynı temkinli
kurallar: marka başına günde bir deneme (Redis kilidi), başarısız veya
boş listede eski satırlar korunuyor, silme ancak yenisi elde edilince
ve servis bazında yapılıyor.

8 yeni test; api paketi 610 test geçiyor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 08:35:09 +03:00
ae8a8046bf refactor(prefetch): kapı sırasını en ucuzdan pahalıya diz
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Pencere kontrolü saf saat aritmetiği, hiç I/O yapmıyor ve pencere
dışındaki iş zaten hiçbir faydalı iş yapamıyor — dolayısıyla dakikalık
Redis sayacından da önce gelmeli. Yeni sıra: pencere → cooldown →
dakikalık tavan → günlük bütçe. Böylece pencere dışında uyanan iş
hiçbir sayacı kirletmiyor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 08:31:55 +03:00
7644d91407 fix(prefetch): PL24 bütçe/iş-saati kilitlenmesini kır
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Prod'da PL24 prefetch tamamen durmuştu: günlük bütçe 600/600 dolu
görünürken gün boyunca SIFIR katalog isteği ve SIFIR yeni kategori
üretiliyordu (ölçüm 2026-09-20).

İki hata birlikte kapalı bir döngü kuruyordu:

1. Günlük bütçe `process()` içinde düşülüyor, iş-saati (ve cooldown)
   kapısı ise her handler'ın başında duruyordu. Pencere dışında uyanan
   bir iş önce bütçeden bir birim yiyor, sonra `time-window` fırlatıp
   hiçbir iş yapmadan erteleniyordu.

2. Bütçesi biten kaynak "bir sonraki UTC gece yarısı"na erteleniyordu.
   PREFETCH_PL24_START=9 ile bu an 03:00 Europe/Istanbul'a denk gelir —
   pencere açılmadan altı saat önce. Uyanan iş yine pencereye takılıyor,
   yine erteleniyor; taze günlük bütçe daha pencere açılmadan bu boş
   uyanmalarla tükeniyordu.

Düzeltme:
- cooldown + iş-saati kapıları `process()` içinde, bütçe düşülmeden
  ÖNCE çalışıyor; handler'lardaki kopyaları kaldırıldı.
- `alignToWindow()` eklendi: bütçe ertelemesi pencerenin içine
  hizalanıyor. Pencere tanımlı değilse (varsayılan 0–24) no-op.
- `currentIstanbulHour` artık `istanbulHourAt`'e deleg ediyor ve h24
  döngüsünün gece yarısı için ürettiği "24" değeri `% 24` ile
  normalleniyor (aksi halde saat hiçbir pencereye düşmez).

8 yeni regresyon testi; api paketi 602 test geçiyor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 08:29:55 +03:00
40c4d33068 Merge pull request 'fix(pl24): VR7 WMI'sini haritadan çıkar — PL24'te böyle bir marka yok' (#267) from dev into main 2026-09-20 08:16:37 +03:00
semih
fe91b504de fix(pl24): VR7 WMI'sini haritadan çıkar — PL24'te böyle bir marka yok
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Faz 2 / açık soru kapatıldı (analiz: /home/s/ss/plv2.md, bulgu types_wmi-04).

Rapor "VR7 muhtemelen Citroën, peugeot_parts yanlış" diyordu ve doğrulama
istiyordu. Canlı portal WMI servisi (2026-09-19, /pl24-wmi/ext/api/2.0/decode,
pl24-wmidata kapsamlı token) kesin yanıtı verdi:

  HTTP 410 — "error resolving VIN: no brands found for WMI = VR7"

Yani VR7 ne Peugeot ne Citroën: PL24'ün WMI veritabanında HİÇ YOK, tıpkı Tofaş
NM4 gibi. Raporun önerisi (VR7 → citroen_parts) uygulansaydı hata aynen devam
edecekti.

Prod'daki 17 VR7 aracının tamamı model çözülmeden ("Peugeot Peugeot") ve 0
kategoriyle kaydedilmişti. Haritada tutmak yalnız her sorguda boşuna bir PL24
isteği üretiyor ve gerçek kaynağa (pcat/emex/vinpin) geçişi geciktiriyor —
canlı gözlem: P4→P5 onarma denemesi de bu VIN'lerde "kayıt bulunamadı" ile
düşüyor.

Test: pl24.types.spec'e kapsam-dışı WMI kilidi (VR7 ve NM4 haritada olmamalı) +
canlı doğrulanmış yönlendirmeler (JF1→subaru, ZAR→alfa, VXK→psa_opel).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:35:03 +03:00
3b63ae02c6 Merge pull request 'feat(pl24): bayat P4 mimarisindeki araçları görüntülendikçe P5'e taşı' (#265) from dev into main 2026-09-20 00:26:01 +03:00
58ba612707 Merge pull request 'feat(internal-admin): expired/cancelled kullanıcı için yeni abonelik başlatma ucu' (#266) from feat/admin-start-subscription into main 2026-09-19 01:37:19 +03:00
Semih
854ccbbe12 feat(internal-admin): expired/cancelled kullanıcı için yeni abonelik başlatma ucu
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
POST /internal/admin/users/:id/subscriptions — panelin "Yeni abonelik başlat"
butonu için. manuallyActivate expired/cancelled'ı "yeni subscription
başlatın" diye reddediyordu ama bunu yapacak uç yoktu; manuel tanımlar
DB'den elle yapılıyordu.

- BillingService.startSubscription: active/trial varsa 409, plan aktif değilse
  409, Full plan → tüm aktif markalar, marka-limitli plan → brandIds tam sayı +
  aktif marka doğrulaması. days verilirse bitiş = şimdi + days (1..3650), yoksa
  billingPeriod default'u (aylık/yıllık). Gelir eventi (PostHog/Meta) ve
  referral kredisi tüketimi YOK — para hareketi olmayan founder tanımı.
- UserBillingController (internal/admin/users) + module kaydı.
- billing.service.spec.ts: 7 test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ww3tfpuxBcettz81dDvyYt
2026-09-19 00:45:11 +03:00
semih
ddc223f745 feat(pl24): bayat P4 mimarisindeki araçları görüntülendikçe P5'e taşı
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Faz 2 / veri göçü (analiz: /home/s/ss/plv2.md, bulgu psa-07 / consumers_jobs-05).

SORUN: PSA ve Volvo upstream'de P5'e taşındı, ama daha önce decode edilmiş
araçlar hâlâ eski yollara işaret ediyor: 324 PSA aracı (302'si bir kullanıcıya
bağlı, 33.535 kategori) donmuş 2024-02-13 anlık görüntüsünden, 36 Volvo aracı
(34'ü kullanıcılı, 10.195 kategori) ölü P4 ucundan besleniyor. `decodeVin`'in
db_hit kısa devresi yüzünden kod düzeltmesi bu satırlara ASLA ulaşmıyor; ayrıca
`categories` tablosundaki (vehicle_id, name, source) tekil indeksi yüzünden yeni
ağaç eskisinin üzerine yazılamıyor.

YAKLAŞIM: toplu yeniden decode fırtınası yerine **görüntülendikçe kendi kendini
onarma**. Tek hayatta kalan hesaba 360 aracı arka arkaya sormak yerine, her araç
ilk açılışında bir kez taşınır.

- `isStalePl24Architecture()` (pl24-tree.ts): saklı catalogPath `/psa`|`/volvo`
  ama servis bugün P5 ise bayat. Hâlâ P4 olan markalar (Ford/Opel/Hyundai/Kia/
  Nissan) ve zaten P5 olan kayıtlar dokunulmaz.
- `CategoriesService.healStalePl24Architecture()`: cache'i atlayarak yeniden
  decode eder, eski ağacı SİLİP yenisini tek transaction'da yazar, raw_data'yı
  yeni catalogInfo ile günceller, `fully_fetched`'i düşürür ve
  `cat:tree` / `prefetch:complete` / `prefetch:noresult` anahtarlarını temizler.

Bilinçli olarak temkinli:
- Yeniden decode başarısız olur veya kategori dönmezse ESKİ ağaç korunur
  (bayat veri, veri yokluğundan iyidir).
- Deneme `pl24:heal:<vehicleId>` ile günde bir kez (setNx) — çözülemeyen bir VIN
  her sayfa görüntülemesinde PL24'e gidemez.
- Eski satırlar ancak yeni ağaç elde edildikten SONRA siliniyor (tekil indeks).
- PL24 bütçesi/hız sınırı zaten üstte: her onarma ~2 upstream isteği.

Test: `isStalePl24Architecture` 4 test (bayat tespiti, zaten-P5, hâlâ-P4 markalar,
eksik bilgi) + onarma akışı 4 test (ağaç değişir, P5 kayda dokunulmaz, boş decode
eski ağacı korur, günlük kilit). 221 test geçti; tsc + biome temiz.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:53:15 +03:00
d9bc61b99e Merge pull request 'fix(pl24): Opel/Hyundai/Kia decode'unda 0 kategori — tablo satırları okunmuyordu' (#264) from dev into main 2026-09-17 14:51:35 +03:00
semih
8d284f6442 fix(pl24): Opel/Hyundai/Kia decode'unda 0 kategori — tablo satırları okunmuyordu
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Faz 1 / adım 6 (analiz: /home/s/ss/plv2.md, bulgu p4legacy-03).

SORUN: Bu markalar ana gruplarını <a href> ile DEĞİL, satır attribute'uyla
veriyor:
  Opel     <tr class="tc-data-row" jsonurl="json-vin-main-group.action?catId=25
             &mainGroupId=394…" caption="BODY SHELL AND PANELS" ident="394">
  Hyundai  <tr class="tc-data-row" url="vin-group.action?…&mainGroup=BO…">
             <td>BO</td><td>BODY</td>
decodeVin yolu yalnız anchor tarayan parseP4NavigationCategories'i kullanıyordu
→ sayfa listeyi taşıdığı hâlde 0 kategori. Prod ölçümü (raw_data.categories):
Opel 177/181 (%97,8), Hyundai 12/12, Kia 6/6, Nissan 1/1 araç decode anında BOŞ.
(Kategori tablosu sonradan lazy-seed ile doluyor, ama decode kategorisiz
döndüğü için kullanıcı ilk açılışta boş görüyor ve prefetch self-seed'e zorlanıyor.)

Üç ayrı kusur vardı:
1. `parseFordGroupsFromHtml` bu satırları zaten anlıyor ama decode'dan hiç
   çağrılmıyordu → kategori zincirine fallback olarak eklendi
   (JSON ucu → tc-data-row tablosu → anchor taraması).
2. Volvo için konmuş `vin-group.action && !group1=` guard'ı Hyundai/Kia'nın
   AYNI ucu `mainGroup=` anahtarıyla kullanan gerçek ana gruplarını da eliyordu
   → artık `group1=` veya `mainGroup=` taşıyan satırlar korunuyor.
3. Satır attribute'ları HTML-escaped (`&amp;`), bu yüzden `[?&]mainGroup=`
   kontrolü hiç tutmuyordu → URL artık ayrıştırma anında entity-decode ediliyor
   (saklanan linkPath de böylece temiz).

Test: yeni `pl24-p4-groups.spec.ts` + 2026-09-16 canlı yakalamalarından kırpılmış
fixture'lar (`__fixtures__/p4_{opel,hyundai,ford,nissan}.html`). Opel 31 ana grup
("BODY SHELL AND PANELS"…), Hyundai 6 ("BODY"…), anchor-only parser'ın bu
sayfalarda hâlâ 0 döndüğü (fallback'in gerekçesi), Ford sharedCatCode scope
satırlarının bozulmadığı ve Nissan model-seçim satırlarının elendiği kilitlendi.
213 test geçti; tsc + biome temiz.

NOT: db_hit kısa devresi yüzünden mevcut araçlar kendiliğinden düzelmez; kazanç
yeni decode'larda. Toplu yeniden decode Faz 2'de.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:47:53 +03:00
4cae337241 Merge pull request 'feat(pl24): PSA+Volvo → P5, reaktif drill freni, 1 saatlik kesinti için Telegram uyarısı' (#263) from dev into main 2026-09-17 14:26:26 +03:00
semih
dde6f6a15c feat(pl24): PSA + Volvo'yu P5'e taşı, eksik katalogları ekle, ağaç sınıflandırmasını birleştir
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Faz 1 / adım 3-4 (analiz: /home/s/ss/plv2.md §3.3/§3.5, bulgular types_wmi-01/02/03/04/05,
p5core-02/03/04/05, psa-04/05/06, consumers_jobs-01/02).

CANLI GERÇEK (2026-09-16 keşfi): PL24 manufacturers API'si peugeot/citroen/
citroenDs/psa_opel/psa_vauxhall'ı `/p5psa`, volvo/polestar'ı `/p5volvo`,
subaru'yu `/p5subaru` olarak veriyor. Eski Volvo P4 ucu **503** döndürüyor
(ölü); eski PSA P4 ucu 200 döndürüyor ama verisi **2024-02-13'te donmuş**
(P5 upds 2026-09-07). Kodumuz her ikisini de P4'e yönlendiriyordu.

SERVİS TABLOSU:
- peugeot/citroen/citroenDs + YENİ psa_opel_parts, psa_vauxhall_parts → /p5psa
- volvo/polestar → /p5volvo · YENİ subaru_parts → /p5subaru
- YENİ abarth/alfa/jeep/lancia → /p5fiat
- WMI düzeltmeleri: ZAR→alfa, ZLA→lancia (fiatp'den), VXK→psa_opel (PSA-platform
  Corsa F/Mokka B), YENİ JF1/JF2→subaru, 1C4/1J4→jeep. SERVICE_TO_BRAND genişledi.
- P4 kodu ve eski yollar DOKUNULMADI: mevcut 322 PSA + 36 Volvo aracı eski
  ağaçlarıyla çalışmaya devam eder (veri göçü ayrı iş).

PARSER (canlı yanıt şekillerine göre):
- `normalizeLabel()`: JS toLowerCase Türkçe "İ"yi "i̇" (i + birleşen nokta)
  yapıyordu → PSA'nın "AKTARMA SİSTEMLERİ"/"GÖVDE TİPİ" etiketleri HİÇBİR ZAMAN
  eşleşmiyordu, şanzıman/gövde sessizce null kalıyordu. Artık tr-locale +
  diakritik temizliği + ı→i.
- `parsePsaModelYear()`: "AM 2005" → 2005; "MAJÖR ENDEKS" bir yıl DEĞİL (bu
  yüzden Citroën'ler 2001 görünüyordu). `damToModelYear()`: PSA DAM alanı
  (1976-01-01'den gün) → üretim yılı, model yılı Temmuz'da döner.
  PSA VIN'lerinde 10. karakter model yılı olmadığı için VIN fallback'i en sona.
- Alan listeleri: motor/engine (PSA "MOTOR", Volvo "Motor"), aktarma_sistemleri,
  mission, govde_tipi, kaporta_stili, türü→series.
- Alt grup kodu: PSA'da `record.id` = illusPath ve birden çok illüstrasyon
  paylaşıyor (canlı: 36 kayıt / 11 id) → benzersiz kod `values.illustration`.
- Parça filtresi: Volvo BOM'unun başlık satırı (id "null_null", link yok,
  unavailable) hayalet parça olarak geçiyordu → elendi.
- Adet: qty (VAG/Subaru) · coef (PSA) · unit (Volvo). Uygulanabilirlik:
  modelDescription · restriction.

AĞAÇ SINIFLANDIRMASI (yeni `pl24-tree.ts`, tek kaynak):
Dört ayrı sezgisel vardı ve birbiriyle çelişiyordu:
- `linkWid.includes("group")` → p5psa `illusTable`, p5volvo/p5subaru
  `illustrationsTable` yakalanmıyordu → o seviye yaprak sanılıp parça olarak
  çekiliyor, partno'suz kayıtlar 0 parçaya dönüşüyor, panel sessizce boş
  kalıyordu (2026-06 isPsaParent olayının aynısı).
- case-sensitive `includes("/bomdetails")` → p5psa/p5volvo'nun camelCase
  `/details/vin/bomDetails` yaprakları grup sanılıyor, parçaları hiç prefetch
  edilmiyordu (bugün Mitsubishi %99,9 / Fiat %80 / Renault %70 bomDetails
  yaprağı 0 parça — aynı kök neden).
Artık `isPl24LeafNode`/`isPl24GroupNode` tek kaynak; categories.service (2 yer),
catalog.service (2 yer) ve prefetch-worker aynı fonksiyonu kullanıyor.

Test: yeni `pl24-tree.spec.ts` (7 test, canlı VW/PSA/Volvo/Subaru wid+path
çiftleriyle regresyon kilidi). Etkilenen paketler: 208 test geçti. tsc + biome temiz.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:00:30 +03:00
semih
3cfa3d588a feat(pl24): PL24 girişi 1 saat başarısızsa Telegram uyarısı
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
İki hesap banı da günler sonra, DB satır sayılarından fark edildi (tr 07-24 →
08-18'de, de 09-04 → 09-16'da). Üçüncüsünde bir saat içinde haber olsun.

- Yeni global `TelegramService` (aynı bot: TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID;
  yapılandırılmamışsa sessizce devre dışı, istek yolunu asla etkilemez).
- `PL24AuthService` kesinti saatini Redis'te tutuyor (`pl24:auth:fail-since:<hesap>`),
  böylece api+worker ve yeniden başlatmalar aynı başlangıcı görür — crash-loop
  saati sıfırlayamaz. Başarılı login saati siler.
- Kesinti 60 dakikayı geçince tek sefer uyarı (`pl24:auth:alerted:<hesap>` NX,
  6 saat) — hesap kodu, süre, son hata ve "banlanmış olabilir" notu ile.
  Düzelince sessiz "🟢 düzeldi" mesajı.
- Devre kesici açıkken de saat işler: kesici login denemesini bastırdığı için
  aksi halde hesap en ölü olduğu anda saat duruyordu.
- compose'a eksik env referansları: TELEGRAM_*, PL24_HTTP_DAILY_MAX,
  PL24_HTTP_USER_RESERVE, PREFETCH_PL24_FAST_DEPTH, PREFETCH_PL24_DELAY_MS
  (Coolify env'i ancak compose'da referanslıysa container'a ulaşıyor).

Test: 3 yeni test (1 saat dolmadan susar, dolunca tek sefer uyarır, düzelince
kurtarma mesajı + saat sıfırlanır). Redis stub'ı gerçek SET NX semantiğine
çekildi (alarm tekrarının bastırılması buna dayanıyor). 80 PL24 testi geçti.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 13:53:15 +03:00
semih
02ec3856d5 perf(pl24): reaktif drill derinliğini sınırla, backfill'i jitter'lı aralıkla yavaşlat
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Faz 1 / adım 2b (analiz: /home/s/ss/plv2.md, bulgu consumers_jobs-03).

SORUN: Ban'ı süren hacim backfill değil, her yeni decode'da çalışan fast-lane
TAM AĞAÇ drill'iydi. 2026-08-18→09-04 arasında (PL24_TR_DISABLED main lane'i
park etmişken) yeni kategorilerin %100'ü aynı gün decode edilen araçlardan
geldi: günde 3-17 decode → 1.4k-6.8k kategori (bir Passat 1.251, bir L200
2.323). Araç başına drill derinliği sınırsızdı ve PL24 istekleri arasında hiç
bekleme yoktu.

DEĞİŞİKLİK:
- `PREFETCH_PL24_FAST_DEPTH` (varsayılan 1): PL24 reaktif/fast lane yalnız üst
  gruplar + doğrudan çocuklarını gezer. Daha derini ya kullanıcı o düğümü
  açtığında (lazy) ya da bütçeli backfill lane'inde çekilir. Diğer kaynaklar ve
  PL24 main lane MAX_DEPTH kullanmaya devam eder. Fast lane'in derinlik tavanına
  ulaşması normal durum olduğu için log seviyesi warn değil log.
- `PREFETCH_PL24_DELAY_MS` (varsayılan 8000) + ±%50 jitter: PL24 BACKFILL
  işlerine per-job bekleme. Kullanıcının fast lane'i asla yavaşlatılmaz.
  Metronom gibi düzenli akış otomasyon imzasıdır; jitter onu kırar.
- Telemetri düzeltmesi: `proxy_logs`'a ham hesap etiketi ("tr") yerine gerçekte
  kullanılan hesap yazılıyor (PL24_TR_DISABLED altında "de"). Yeni
  `PL24AuthService.activeAccount()`.

Test: `__testables` ile maxDepthFor/jitter dışa açıldı + derinlik tavanı testi.
Etkilenen paketler: 198 test geçti. tsc + biome temiz.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 13:44:59 +03:00
2add2b253a Merge pull request 'feat(pl24): günlük HTTP bütçesi, kullanıcı rezervi ve proxy_logs telemetrisi' (#262) from dev into main 2026-09-17 13:37:50 +03:00
semih
56a8ea09be feat(pl24): günlük HTTP bütçesi, kullanıcı rezervi ve proxy_logs telemetrisi
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Faz 1 / adım 2 (analiz: /home/s/ss/plv2.md, bulgular consumers_jobs-03/06/07,
deploy_ops-13).

SORUN: İki PL24 hesap banı da günde 5-7 bin kategori yazan (≈10 bin+ istek)
otomatik drill dalgalarının ardından geldi; gerçek kullanıcı decode'u ≤14/gün.
Mevcut korumaların hepsi iş-seviyesinde (iş/dk, iş/gün) — HTTP isteği sayan
hiçbir tavan yoktu. Üstelik PL24 çağrıları `proxy_logs`'a hiç yazılmıyordu
(yalnız pcat/emex), bu yüzden hacim ancak ban'dan SONRA, DB satır sayılarından
geriye dönük çıkarılabildi. Kill switch de yalnız decodeVin'i kapatıyordu; drill,
backfill ve katalog gezinmesi "kapalı" kaynağa istek atmaya devam ediyordu.

DEĞİŞİKLİK:
- Yeni `PL24BudgetService`: tüm PL24 upstream trafiği için tek geçit.
  - Günlük sayaç `pl24:http:<gün>` (Redis, 8 gün TTL); tavan `PL24_HTTP_DAILY_MAX`
    (varsayılan 1200).
  - Kullanıcı rezervi `PL24_HTTP_USER_RESERVE` (varsayılan %40): backfill tavanın
    %60'ında durur, kullanıcı decode'u sonuna kadar akar — kaçak bir backfill
    müşteriyi asla aç bırakamaz. Lane ayrımı mevcut `isBackfillContext()` ile.
  - Reddedilen çağrı ağa hiç çıkmaz ve telemetriye yazılmaz.
  - Redis düşerse fail-open (auth/katalog telemetri yüzünden bloklanmaz).
- Telemetri: `ProxyServiceLeg` += `pl24_http`, `pl24_auth`. Login, authorize ve
  tüm katalog fetch'leri `proxy_logs`'a yazıyor (provider dataimpulse/none,
  sessionKey=hesap, status, süre, 403/429 → banned, taşıma hatası sınıflandırma).
  Artık "hangi gün kaç istek attık, ilk 401/403 ne zaman başladı" sorusu
  ban'dan ÖNCE yanıtlanabilir.
- Kill switch yayıldı: `kill-source-pl24` artık `budgetedFetch` içinde, yani
  decode + drill + backfill + browse + görsel indirme dahil TÜM katalog trafiğini
  kapatıyor (eskiden yalnız decodeVin).
- `fetchWithRetry` 401 çaresi netleşti: yalnız servis token'ı tazelenir; oturumun
  kendisi gerekirse auth katmanında düşürülür.

Test: yeni `pl24-budget.service.spec.ts` (9 test: tavan, kullanıcı rezervi,
reddedilen çağrının ağa çıkmaması, Redis fail-open, telemetri biçimi/leg ayrımı,
ban sinyali, taşıma hatası sınıflandırma). Etkilenen paketler: 197 test geçti.
tsc + biome temiz.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 13:13:05 +03:00
62ad9ac318 Merge pull request 'refactor(pl24): oturum modeli — tek login, çerez tabanlı authorize yenileme' (#261) from dev into main 2026-09-17 13:01:27 +03:00
semih
e50a907931 refactor(pl24): oturum modeli — tek login, çerez tabanlı authorize yenileme
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Faz 1 / adım 1 (analiz: /home/s/ss/plv2.md §4.1, bulgular auth-02/03/05/06/09/11/15,
p4legacy-07, consumers-04).

SORUN: PL24'ün base JWT'si 600 saniye yaşıyor ve kod bunu "oturum" sayıyordu
(isTokenValid → login). Talep varsa her ~9 dakikada bir squeezeOut:true ile
YENİDEN LOGIN atılıyordu (günde ~144 login/hesap). PL24 hesap başına tek oturum
veriyor; bu desen hem kendi oturumumuzu sürekli düşürüyor hem de iki hesap
banından önceki otomasyon imzasını oluşturuyordu. Portalın kendisi bir kez login
olup PL24TOKEN çerezini saklıyor ve yalnız authorize'ı yeniliyor.

DEĞİŞİKLİK:
- Oturum = PL24TOKEN çerezi (süresiz). `ensureSession()` sahibi; bellekte tutulur,
  Redis'te `pl24:auth:session:<hesap>` ile 24 saat paylaşılır (api/worker aynı
  oturumu devralır). Süre kontrolü YOK — oturum yalnız sunucu reddederse düşer.
- Servis token'ı = authorize ile mint edilen 600 s'lik JWT; yalnız `Cookie:
  PL24TOKEN` ile istenir (Bearer gerekmiyor — canlı doğrulandı).
- `session_status:"gone"` ve authorize 401/403 artık okunuyor: oturum düşürülür,
  tek sefer yeniden login + retry.
- Single-flight: süreç içi in-flight promise + Redis kilidi
  (`pl24:auth:login-lock:<hesap>`), kilidi alamayan paylaşılan oturumu bekler.
  Eski prewarm iki paralel login atıyordu ve biri her boot'ta HTTP 500 alıyordu.
- Login sözleşmesi: portal ucu `/auth/ext/api/1.1/login`
  ({account,user,password,squeezeOut} → {loginStatus,sessionToken} + Set-Cookie);
  RFC7807 problem gövdesi tiplendi. Önce squeezeOut:false denenir, yalnız
  "session-limit-exceeded"/USER_ALREADY_LOGGED_IN'de squeezeOut:true ile tekrar.
  `PL24_LOGIN_API=legacy` eski uca döner (bir sürüm geri çekilme bayrağı).
- Hata sınıflandırma: account-not-active / user-not-active / authentication /
  2FA-required → ilk hatada 6 saatlik devre kesici (eskiden ölü hesap 5 dakikada
  bir sonsuza dek deneniyordu: prod'da 376 ardışık 401). Ağ/timeout → 5 dakika.
- Saatlik login tavanı (hesap başına 6) — sağlıklı günde 0-1 login beklenir.
- P4 (.action) sayfaları için authorize kaldırıldı: yalnız çerez gönderiliyor
  (canlı doğrulandı). Ford/PSA'daki 18 "ısıtma" authorize çağrısı ensureSession'a
  indirildi; demo-sayfa/401 çaresi artık servis token temizleme değil oturum
  yenileme.
- Gerçek Chrome User-Agent sabiti (eski değer hiçbir tarayıcıda yok).
- authorize'a `pl24-wmidata` eklendi (portal WMI decode'u için ön hazırlık).
- Ölü kod silindi: getAccessToken*/getSessionCookie* base-JWT yolu, hasService,
  getAvailableServices, AUTH_TOKEN_TTL.

Test: pl24-auth.service.spec.ts yeniden yazıldı (19 test: tek login, single-flight,
Redis devralma, cookie-only authorize, gone/401 yeniden login, 412 squeezeOut,
P4 cookie-only header, kalıcı hata kesicisi, saatlik tavan). Tüm PL24 + tüketici
paketleri: 188 test geçti. tsc + biome temiz.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:59:36 +03:00
ca18749ec5 Merge pull request 'chore(prefetch): PL24 günlük backfill tavanını env ile ayarlanabilir yap' (#260) from dev into main 2026-09-17 00:44:47 +03:00
semih
330015fbb6 chore(prefetch): PL24 günlük backfill tavanını env'den ayarlanabilir yap
SOURCE_DAILY_MAX.pl24 zaten PREFETCH_DAILY_PL24'ü okuyor
(prefetch-worker.service.ts:156) ama compose'da hiç ${...} referansı
olmadığı için Coolify env'i container'a ulaşmıyordu; tavan 15.000'de
kilitliydi.

PL24 hesap banları (tr 07-24, de 09-04) günde 5-7 bin kategori üreten
otomatik drill dalgalarının ardından geldi. Yeni hesaba geçerken
(de-025446) backfill'in güvenli bir günlük tavanla çalışması gerekiyor.

Coolify env: PREFETCH_DAILY_PL24=600 (main lane payı %80 = 480 iş/gün),
PREFETCH_PL24_START=9 / PREFETCH_PL24_END=18 (Europe/Istanbul insan
saatleri), PREFETCH_RATE_PL24=6 (main 4/dk, fast 2/dk).

Analiz: /home/s/ss/plv2.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:55:56 +03:00
24f7e7aa5b Merge pull request 'fix(pl24): tr hesabı kapalıyken de köprüsü (PL24_TR_DISABLED) + login devre kesici' (#259) from dev into main 2026-08-18 18:02:43 +03:00
5d9da95830 fix(pl24): tr hesabı kapalıyken de köprüsü (PL24_TR_DISABLED) + login devre kesici
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
- tr-903645 PL24 tarafında pasif (07-24, "The account is not active"): flag
  açıkken tüm tr auth/proxy/egress şeffaf biçimde de-708171'e maplenir
  (PSA dahil — legacy authorizeService yolu tr'ye hardcode'du)
- PSA fetch'lerine dispatcher eklendi: de oturumu DE proxy'den çıkar
- resolveAccount flag altında herkese de döner (fetch/cache/etiket tutarlı)
- login devre kesici: 3 ardışık hata → 5 dk soğuma (haftalardır süren
  ~6 boş login/dk fırtınasını bitirir)
- prefetch: flag açıkken pl24 backfill üretimi durur + kuyruktaki pl24
  main-lane işleri 15 dk defer'la park edilir (fast/user lane akmaya devam
  eder) — hayatta kalan tek hesabı arka plan yükü yakmasın
- compose: PL24_TR_DISABLED env injection (api+worker)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 14:47:53 +03:00
1e3f37f71e Merge pull request 'fix(billing): dunning kurtarma zinciri (migration 0035 + webhook + banner)' (#258) from dev into main 2026-08-18 13:07:15 +03:00
181bdbe971 fix(billing): dunning kurtarma zinciri (migration 0035 + webhook + banner)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Stripe gerçeği: 3 abonelik past_due (3×₺999 = brüt MRR'ın ~%27'si), 1 abonelik
dün dunning'den iptal (tugem, ₺999 kayıp); kurtarma tarihsel %0. Kök nedenler:
(1) dunning e-postasının CTA'sı ödeme imkânı olmayan /dashboard/subscription'a
gidiyordu, (2) kart güncelleme yüzeyi hiç yok + yeniden checkout 'Zaten aktif
aboneliğiniz var' ile bloklu, (3) app past_due'yu hiç bilmiyordu (DB state yok,
banner yok), (4) subscription.deleted no-op'tu (churn görünmez + status active
kaldığı için re-subscribe kalıcı bloklu).

- migration 0035: user_subscriptions.dunning_since + dunning_invoice_url
- handleInvoiceFailed: dunning state persist + e-posta CTA'sı Stripe hosted
  invoice sayfasına (öde/yeni kart/3DS) + milestone e-posta (1./3./final —
  deneme başına aynı mail spam'i bitti) + attempt_count PostHog'a
- handleInvoicePaid: her iki dalda dunning temizliği
- handleSubscriptionDeleted: status→cancelled (re-subscribe deblke),
  subscription_churned PostHog event'i (reason: payment_failure/cancelled)
- web: DunningBanner (kapatılamaz, kırmızı; CTA hosted invoice) dashboard'da
- spec: yeni davranışa güncellendi + milestone/final testleri (14/14)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 11:10:44 +03:00
c12b2992f9 Merge pull request 'fix(analytics): CSP Google Ads tag + conversion domainleri (ASIL conversion bugu)' (#256) from dev into main 2026-08-04 16:54:36 +03:00
57d16b9aea fix(analytics): CSP'ye kalan enhanced-conversion endpoint'lerini ekle
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Dev testinde ana conversion ping'i (googleadservices/pagead/conversion) geçti
ama ad.doubleclick.net (ccm/collect enhanced) + google.com.tr (TR yerelleştirilmiş
1p-conversion) hâlâ bloklanıyordu. img-src/connect-src'e eklendi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 16:53:48 +03:00
eb277cd55a fix(analytics): CSP'ye Google Ads tag + conversion domain'lerini ekle
CSP script-src googletagmanager.com İÇERMİYORDU → gtag.js hiç yüklenmiyordu →
gtag config/conversion event'i çalışmıyor, _gcl_aw linker cookie set olmuyor,
conversion ping'i Google'a hiç gitmiyor → haftalardır panelde 0 dönüşüm (doğru
AW id + label + kanonik gtag'e rağmen). ASIL bloker buydu — gtag shim (6c936d5)
+ OAuth (685c6af) fix'leri gerekliydi ama tek başına yetmezdi.
- script-src += www.googletagmanager.com
- img-src/connect-src += google.com, googleadservices.com, googleads.g.doubleclick.net
Meta Pixel worker-src ve Sentry ingest ile aynı sınıf CSP-blocking bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 16:47:22 +03:00
8346a1db8e Merge pull request 'perf(prefetch): pcat gunluk butce 30k->45k' (#255) from dev into main 2026-08-01 14:32:44 +03:00
2df73e6a48 perf(prefetch): pcat günlük bütçe 30k→45k (main lane 36k, fast lane 9k rezerv)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Gate fix (PR#254) prod'da doğrulandı: pressure=56 (eskiden backlog=11495),
bütçesi dolan kaynak eligible'dan düşüyor — runaway guard'ları canlı. Bütçenin
kalan tek riski bant genişliği maliyeti.

45k gerekçesi: guard'ı doğuran 2026-07-09 olayı ~74k çağrı/~10 GB idi; 45k onun
%61'i (~6 GB/gün). DAILY_FAST_RESERVE ile backfill main lane 36k alır = eski
efektif 30k'ya göre +%20, kullanıcıya 9k rezerv kalır. Burst hızı DEĞİŞMİYOR
(pcat 90/dk) → per-IP ban baskısı aynı, sadece tempo günün daha uzun bölümünde
sürüyor. Rollback: bir saatte >2 pcat HTTP 402 → 30_000'e dön.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 14:32:42 +03:00
c21472d930 Merge pull request 'fix(prefetch): Phase-2 kilidini ac (pressure/total gate) + tamamlanma muhasebesi' (#254) from dev into main 2026-08-01 14:22:23 +03:00
3597b03c4f fix(prefetch): Phase-2 kilidini aç (pressure vs total gate) + tamamlanma muhasebesi
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Sorun (2026-08-01): wait=0/active=0 iken scan backlog=11495 hesaplıyordu — hepsi
günlük-bütçe ile ertesi gün 00:00'a park edilmiş pcat job'ı. backlog>maxBacklog
olduğu için Phase-2 günün çoğunda askıdaydı; 2143 kısmi araç ilerlemiyordu.

Adversarial analiz kritik uyarı verdi: delayed'ı gate'ten çıkarmak TEK BAŞINA
470k runaway'i tekrarlar (üretim ~288k job/gün vs bütçe 65k/gün; bütçe-defer'leri
skipAttempt ile attempts tüketmediğinden asla düşmez). Bu yüzden üretim kaynağında
kesiliyor + ikinci tavan ekleniyor:

- Gate ikiye ayrıldı: pressure (waiting+active+10dk içinde vadesi gelen delayed,
  HER İKİ kuyruk) vs maxBacklog; total (tüm bekleyen) vs HARD_MAX_TOTAL_JOBS=50k.
  zcount BullMQ delayed ZSET score'u (dueMs*4096+seq) ile; hata halinde fail-CLOSED.
- ÜRETİMİ KES: scan, günlük main-lane bütçesi dolmuş kaynağı eligible'dan düşürür.
- Admission control JOB cinsinden (EST_JOBS_PER_VEHICLE=400), araç cinsinden değil.
- Günlük bütçe lane-aware (fast lane'e %20 rezerv) + READ-then-INCR (sayaç artık
  defer'lerle şişmiyor; 48531 vs 30000 gözlemi) + 45dk jitter (thundering herd).
- KÖK NEDEN: addJob artık boolean, queueCategoryJob sayı döndürüyor; progress.total
  yalnız GERÇEKTEN kuyruğa giren job'ı sayıyor. Şişmiş total zinciri asla
  "finished"a ulaştırmıyordu → araç fullyFetched işaretlenmiyor → scan sonsuza dek
  yeniden seçiyordu (~25/gün platosu). processInit queued===0 ise finalizeVehicle.
- prefetch:scheduled:<id> ÇAKIŞMASI: backfill artık :backfill: namespace'i yazıyor;
  eski 6h TTL kullanıcının fresh-decode fast-lane init'ini sessizce atlatıyordu.
  TTL 6h→36h (bütçe-park edilmiş zincir ~24h yaşıyor).
- Phase-2 artık kalıcı vehicles.fullyFetched'i hedefliyor (efemeral marker değil).
- CATEGORY_CAP DB'den ölçülüyor (her processInit'te sıfırlanan sayaçtan değil).

537 test geçti. Bütçe artışı (PREFETCH_DAILY_PCAT) BU PR'da DEĞİL — ayrı adım.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 14:21:44 +03:00
2cda50f9af Merge pull request 'fix(analytics): gtag shim kanonik arguments push' (#253) from dev into main 2026-08-01 10:09:43 +03:00
6c936d546b fix(analytics): gtag shim kanonik arguments push — tüm conversion takibi kırıktı
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
initGoogleAds gtag shim'i array push ediyordu ((...args) => dataLayer.push(args));
gtag.js komut olarak sadece `arguments` nesnesini işler, array'i inert dataLayer
verisi sayar → config/event HİÇ çalışmıyordu → _gcl_aw linker cookie'si set
olmuyor, conversion ping'i Google'a gitmiyor, panelde 14 günde 0 dönüşüm (email
kayıtları dahil — sadece OAuth değil). Google'ın kanonik snippet formuna
(function gtag(){dataLayer.push(arguments)}) döndürüldü. OAuth post-auth fix'iyle
(685c6af) birlikte email+OAuth tüm kayıtlar artık ateşler + doğru atfeder.
Doğrulama: headless test gtag'i teyit edemez (Google bot-baskılaması); kesin
kanıt gerçek tarayıcıda kaydın panele düşmesi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 10:09:28 +03:00
bf0cc4e96c Merge pull request 'fix(analytics): OAuth Google Ads sign-up conversion' (#252) from dev into main 2026-08-01 09:16:41 +03:00
685c6af3ed fix(analytics): OAuth kayıtlarında Google Ads sign-up conversion'ı ateşle
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Conversion sadece register.tsx email path'inde ateşleniyordu; Google OAuth
signup'lar (register + login Google butonu) hiç ateşlemiyordu → ölçüm: paid-
kaynaklı kayıtların ~%79'u OAuth, Google Ads'e görünmüyordu, Max Conversions
sinyalsiz optimize ediyordu. Evrensel post-auth bileşeni (google-ads-signup-
conversion.tsx, __root'a mount) yeni kullanıcıda (createdAt < 30dk) conversion'ı
bir kez ateşler; transaction_id signup_<userId> Google tarafında dedup eder,
register.tsx guard'ı email path'in çift-ateşini önler. _gcl_aw cookie OAuth
roundtrip'inde korunduğu için atıf doğru. Playwright 6/6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 09:16:20 +03:00
95818b2501 Merge pull request 'feat(growth): B2B segment kapısı + funnel-bucket A/B (Kova B)' (#251) from dev into main 2026-07-29 11:03:48 +03:00
55b4cd38e9 feat(growth): B2B segment kapısı + funnel-bucket A/B deneyi (Kova B)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Fix (herkes, flag'siz): evrensel post-auth segment kapısı (segment-gate.tsx).
Kayıtların ~%41'i segmentsizdi çünkü login.tsx Google OAuth segment adımını
atlıyordu. Yeni kullanıcıya zorunlu (açığı kapatır), mevcut segmentsize
7g-cooldown'lu yumuşak prompt (~538 backfill). Segment localStorage +
PostHog person prop (b2b_segment); backend persist faz 2.

Kova B (funnel-bucket flag, b2b_qualified): trial-value-upsell'e segmente-özel
Meta-kanıtlı kopya (iade / yanlış-parça / sınırsız-şase). Flag SADECE banner
görünürken okunur → deney maruziyeti = gerçekten gören aktif trial'lar.
vehicle_owner / bilinmeyen segment → nötr control kopya (ürün kararı).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 17:26:19 +03:00
c0045fa372 Merge pull request 'feat(prefetch): kalici vehicles.fully_fetched kolonu (migration 0034)' (#250) from dev into main 2026-07-25 10:44:19 +03:00
6610bdf720 feat(prefetch): kalıcı vehicles.fully_fetched kolonu (güvenilir tamlık ölçümü)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Redis prefetch:complete:* 21g TTL'li → "% tam çekilmiş"i güvenilir ölçemiyorduk.
Migration 0034: vehicles'a fully_fetched (bool, default false) + fully_fetched_at
(ts) + index. incrementCompleted chain parça ile bitince kalıcı flag'i set eder
(Redis marker'a EK — operasyonel skip mantığı değişmedi). fully_fetched_at her
re-drill tamamlanışında güncellenir (son-doğrulanma). Idempotent SQL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 10:40:40 +03:00
8c1ee15fac Merge pull request 'perf(prefetch): backfill günlük bütçe (storm guard)' (#249) from dev into main 2026-07-16 13:32:47 +03:00
ed8a6ce6e9 perf(prefetch): backfill'e per-source günlük bütçe (storm guard)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Per-dakika rate cap'i (pcat 90/dk) burst'ü sınırlıyor ama günlük TOPLAM'ı değil —
bir kaynak saatlerce tavanda çalışıp proxy bütçesini boşaltabiliyor (2026-07-09:
backlog drain pcat'i ~74k çağrı / ~10 GB'a çıkardı). SOURCE_DAILY_MAX eklendi:
UTC-gün fixed-window Redis sayacı; kaynak günlük bütçeyi aşınca backfill job'ları
pencere dönene kadar ertelenir. Kullanıcı decode'ları etkilenmez (worker'dan
geçmiyor). Dakikalık gate'ten SONRA sayılır → rate-limitli retry'lar şişirmez.

Default (env-tunable): pcat 30k, emex 20k, pl24 15k/gün. Backlog drain'i
hızlandırmak için yükselt, bütçeyi daha çok korumak için düşür.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:32:34 +03:00
8a4dd6e10e Merge pull request 'chore(proxy): DataImpulse birincil, Floxy default kaldır' (#248) from dev into main 2026-07-16 13:04:30 +03:00
bf834b8f41 chore(proxy): DataImpulse'i birincil yap, Floxy default'unu kaldır
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Floxy kalıcı olarak devre dışı (bakiye/tünel ölü — pcat+emex loglarında sürekli
"Floxy unhealthy → DataImpulse failover"). Floxy-primary her çağrıda önce bir
başarısız deneme yakıp sonra failover ediyordu. DataImpulse zaten kanıtlanmış
çalışıyor (call-leg %97,6).

PCAT_PROXY_PROVIDER ve EMEX_PROXY_PROVIDER default'ları floxy→dataimpulse
(pcat auth + emex browser + emex HTTP ayağı). Floxy hâlâ env ile seçilebilir
(PCAT_PROXY_PROVIDER=floxy / EMEX_PROXY_PROVIDER=floxy); failover makinesi
dormant kalıyor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:04:15 +03:00
bd134bba06 Merge pull request 'perf(pcat): capture widget asset cache — ~%90 capture bant genişliği' (#247) from dev into main 2026-07-16 12:52:59 +03:00
7fd486855f perf(pcat): capture'da widget asset'lerini cache'le — ~%90 capture bant genişliği
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Her JWT capture yeni context (boş cache) açıp aynı widget JS/CSS'ini residential
proxy'den yeniden indiriyordu — ölçüldü ~2,2 MB/capture, %93'ü static (en büyüğü
tüm sitelerde ORTAK `.../v3/bundle_<hash>.js`, 603 KB). ~3-4k capture/gün ile bu
en büyük kaçınılabilir proxy maliyeti (~20 GB/15g), sıfır ban riski.

URL-anahtarlı, disk-destekli (hot in-memory + best-effort disk) cache eklendi.
Sadece versiyonlu static JS/CSS cache'lenir (hash/`?_=` → invalidation otomatik);
token XHR (/v3/api/proxy/*) ve HTML document HER ZAMAN canlı geçer. HIT'te sıfır
proxy byte'ı; MISS'te bir kez route.fetch (context proxy'sinden) + sakla + servis.
PCAT_ASSET_CACHE=false ile redeploysuz kapatılabilir (kill switch).

Doğrulama (autotrade.md + e-trak.ru, gerçek yükleme, 2 ardışık capture):
warm capture canlı trafiği %89-91 düştü VE token XHR ikisinde de atıldı — yani
cache'ten JS servisi widget'ı bozmuyor (geçen capture-blocking olayının tersi).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:47:10 +03:00
8d07d9a31a Merge pull request 'fix(vinpin): blank Dialogys header retries instead of definitive not_found' (#246) from dev into main 2026-07-16 07:59:39 +03:00
fdbaaf375f fix(vinpin): blank Dialogys header → ambiguous (retry), not definitive not_found
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
A blank header region means the Dialogys vehicle result never rendered (transient/
slow), NOT a genuine miss — a decodable Renault (Megane) read "" here and was wrongly
returned not_found (made definitive by the Rpartstore-down gate). Return ambiguous so
the caller's cheap in-session reset retries; a real empty-form miss just exhausts the
bounded retries. A NON-empty unparseable header (old R19's garbled parts screen) stays
a fast definitive not_found.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 07:59:32 +03:00
ebbdb22e33 Merge pull request 'chore(compose): VINPIN_RPARTSTORE_ENABLED env ref' (#245) from dev into main 2026-07-16 07:41:23 +03:00
8e4581f1b5 chore(compose): add VINPIN_RPARTSTORE_ENABLED env ref (api+worker)
So the flag reaches the container (Coolify only injects compose-referenced ${VAR}).
Default true = unchanged; set false during a Rpartstore outage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 07:41:16 +03:00
679664d7b0 Merge pull request 'feat(vinpin): VINPIN_RPARTSTORE_ENABLED flag — skip Rpartstore during outage (+pcat capture fix)' (#244) from dev into main 2026-07-16 07:39:43 +03:00
c3ea03db03 feat(vinpin): VINPIN_RPARTSTORE_ENABLED flag skips Rpartstore during known outage
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Rpartstore is DOWN upstream. Every Renault decode still tried to OPEN it
first → launch-error, stuck "Loading application..." app (dirty-resume for
the next decode), ~30-50s burned, and the in-memory down-cooldown resets on
every worker restart so the first decode after each restart repaid the full
cost. That overhead pushed decodes over VINPIN_DECODE_BUDGET_MS → budget
abort → sessionPoisoned → cascade.

Add a persistent kill-switch: VINPIN_RPARTSTORE_ENABLED=false makes BOTH
Renault paths (warm warmRenaultDecode + cold runRenaultFlow) skip opening
Rpartstore entirely and route straight to Dialogys — mirroring the existing
rpartstoreInCooldown() skip but surviving worker restarts. Flag-disabled is
treated as "Rpartstore unavailable" exactly like cooldown, so primaryRan
stays false and a clean Dialogys not_found is definitive (no retry thrash).
Also gated the _warmUp Rpartstore-open so a future warm session with the
flag off pays no launch cost / leaves no stray app.

DEFAULT true (only the exact string "false" disables) → behaviour with the
flag unset is completely unchanged. tsc clean; vinpin unit tests green
(+3 flag tests: warm/cold skip + Dialogys-definitive, and default-true still
attempts Rpartstore).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 07:36:20 +03:00
7ec4fb9297 Merge pull request 'fix(pcat): capture route'unu bilinen-iyi'ye döndür — prod token capture kurtar' (#243) from dev into main
Reviewed-on: #243
2026-07-16 07:00:24 +03:00
6df9c18efb fix(pcat): capture route'unu bilinen-iyi'ye döndür — prod token capture kurtar
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
0f2126d'deki agresif capture blocking (stylesheet + yastatic/google-font/ad
domain'leri) prod'da token capture'ı öldürdü: dataimpulse ile 425→0 capture/
saat, tam deploy anında (03:40 UTC), hepsi capture_timeout. RU katalog widget'ı
init olup /v3/api/proxy XHR'ını atmak için CSS'ine ve Yandex-hosted runtime'ına
ihtiyaç duyuyor. Aynı deploy'da emex (HTML-scrape, widget yok) aynı dataimpulse
üzerinden sağlıklı kaldı → sorun pcat-capture'a özgü.

pcat capture blocking'i orijinaline döndürüldü (image/font/media + tracker'lar)
— resim engellemesi zaten çalışıyordu, korunur. Emex blocking (kanıtlanmış
güvenli) ve ipify gate (PCAT_EXIT_IP_PROBE, kapalı) korunur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 06:56:09 +03:00
f42f1888ae Merge pull request 'perf(proxy): capture/scrape browser'larında bant genişliği israfını kes' (#242) from dev into main
Reviewed-on: #242
2026-07-16 06:39:49 +03:00
0f2126d694 perf(proxy): capture/scrape browser'larında bant genişliği israfını kes
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
DataImpulse residential kotası hızlı tükeniyordu (15 günde 58.5 GB). CSV
analizi capture/scrape tarayıcılarının tam sayfa (resim/font/CSS/reklam/
CDN) yüklediğini gösterdi.

- pcat + emex capture: resource-blocking'e stylesheet + üçüncü-parti çöp
  (yastatic, google fonts/autofill, adsco.re, displayvertising, tidio,
  ipify) eklendi. Widget runtime CDN'leri (jsdelivr/unpkg) bilinçli hariç.
- emex: engelleme context seviyesine alındı → tüm tab'ları kapsıyor.
- pcat: ipify exit-IP probe artık PCAT_EXIT_IP_PROBE ile default-off
  (15 günde ~54k faturalı istek + 352 MB, sadece telemetri içindi).

Not: emex scraping resmi tarayıcıda yüklemiyor; URL'yi HTML'den parse edip
boyutu ayrı Range GET ile alıyor → resim engellemek scraping'i etkilemez.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:25:51 +03:00
3248ab1c3a Merge pull request 'test(shared): fix normalizeName hyphen test to correct tr-TR output' (#241) from dev into main 2026-07-15 16:55:43 +03:00
a6437dcd67 test(shared): correct normalizeName hyphen test expectation to tr-TR output
The failing test expected normalizeName("ANNA-MARIA")=="Anna-Maria" (dotted i),
but that is internally inconsistent with the suite's own Turkish tests that REQUIRE
the deliberate tr-TR locale: "ALİ YILMAZ"→"Ali Yılmaz" (dotless ı) and "ÇAĞRI"→
"Çağrı". Uppercase Latin "I" (U+0049) is the SAME codepoint as Turkish dotless-I,
so tr-TR lowercases it to "ı" — correct for a TR product (invariant casing would
break every Turkish name: Yilmaz/Çağri, and mangle İ→i̇ with a combining dot). Not
a code bug; expectation corrected to "Anna-Marıa" with an explanatory comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:55:35 +03:00
544b3f25d1 Merge pull request 'fix(vinpin): Rpartstore-down definitive not_found (no budget-burn+poison) + old-Renault tokens + year-cycle' (#240) from dev into main 2026-07-15 15:50:02 +03:00
cc543ace21 fix(vinpin): poll Dialogys header before a definitive not_found (fixA follow-up)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
FIX A made a CLEAN Dialogys not_found definitive on its own while Rpartstore is
down (no retry). But runDialogysSearch inferred not_found from a SINGLE OCR
sample taken after a blind fixed wait — a slow render or a transient OCR glitch
on a DECODABLE Renault read empty at t=afterDialogysSubmit and was mislabelled
not_found, and under FIX A that miss is now terminal (decodeRenaultLocked
returns null, no cheap retry).

Replace the blind wait + single sample with a poll (pollForState) over the
header region for a decodable render, capped at afterDialogysSubmit — mirrors
runRpartstore's poll-then-decide shape. Multi-samples across the same window and
returns early on a hit, so a slow render/OCR glitch no longer produces a false
not_found. Cap unchanged, so a genuine miss consumes no more time than before:
FIX A's no-budget-burn / no-seat-poison guarantee and the definitive-not_found
semantics both hold, and the full-frame fallback is preserved. Working Renault
decodes only get faster (early return). tsc clean; vinpin (119) + extractModelYear
(12) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:48:44 +03:00
c03e7f4079 fix(vinpin): stop budget-burn + seat-poison on undecodable Renault when Rpartstore down
FIX A (runRenaultFlow): when Rpartstore is UNAVAILABLE (down-cooldown or it
never loaded, so `primary` is only a placeholder ambiguous), a CLEAN Dialogys
not_found is now DEFINITIVE. The old gate required BOTH catalogs to say
not_found, so every undecodable Renault while Rpartstore was down got
downgraded to ambiguous → 3x retry → 180s budget → sessionPoisoned, which
then degraded later decodes. A genuinely-ambiguous Dialogys (unreachable)
still retries. When Rpartstore actually ran, both-must-agree is preserved.
runDialogysSearch now logs its outcome + truncated OCR header so this class
is diagnosable from prod logs.

FIX B (vinpin.constants): add old R-number + TR-badge Renault model tokens
(R5/R9/R11/R12/R19/R21/R25, Europa/Broadway/Toros/Flash). R-prefixed form
only — no bare numerics that could false-match year/engine digits.

FIX C (vin-validator extractModelYear): the position-10 year code repeats every
30 years ("T" = 1996 or 2026) with no clean VIN-only rule. New optional
{modelResolved:false} signal: for a brand-only decode of an old-shaped Renault
VIN (Renault WMI + numeric-led VDS type code) whose code pins to the current
cycle's leading edge, roll back one 30-year cycle so a ~1996 R19 isn't labelled
2026. Narrow: model-resolved or modern-shaped VINs are unchanged. Corgi's
WMI-only decoder wired to pass modelResolved:false.

Keeps never-throw / VINPIN_DECODE_BUDGET_MS / sessionPoisoned semantics and the
Fiat + working Renault paths intact. tsc clean; vinpin + corgi + extractModelYear
tests green (new tests cover A and C).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:39:26 +03:00
41f2f1777f Merge pull request 'fix(vinpin): warm Fiat window-fault cold fallback' (#239) from dev into main 2026-07-15 13:56:47 +03:00
d7d78a77a6 fix(vinpin): warm Fiat window-fault falls back to cold (not throw→null)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The last gap: when ensureWarmWindow('fiat') can't bring the Fiat ePER to its VIN
panel (partial Rpartstore-down session leaves it off-panel + re-nav can't recover),
warmDecode threw SessionDropped → re-warm → null, turning a DECODABLE Fiat VIN into
a not_found. Fall back to the proven cold Fiat path instead, so a warm-window fault
never loses a real decode. Completes the FIX2 cold-fallback (previously only the
unusable-parse branch had it; now the can't-reach-panel branch does too).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:56:40 +03:00
5a79e94df3 Merge pull request 'fix(vinpin): warm on Fiat+Dialogys anchor (no starvation)' (#238) from dev into main 2026-07-15 13:42:39 +03:00
dac0708cda fix(vinpin): claim warm on Fiat+Dialogys anchor (not full 3-window)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The full 3-window gate never warmed during a prolonged Rpartstore outage, so the
daemon re-attempted warm-up every backoff cycle — each ~2min attempt holds the
single-seat mutex and STARVES real decodes. Anchor warm on Fiat ePER + Dialogys
(the two windows that serve both brands; the Rpartstore-down cooldown routes
Renault to Dialogys anyway, so Rpartstore is an optional bonus). Warm is then
claimed once and HELD (no re-warm loop → no starvation); the Поиск-token foreground
fix makes warm Fiat raise the correct window. Rpartstore rejoins on recovery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:42:31 +03:00
5628cbceaf Merge pull request 'fix(vinpin): claim warm only on full 3-window session' (#237) from dev into main 2026-07-15 13:25:46 +03:00
aa798ec03d fix(vinpin): claim warm ONLY on a full 3-window session (Fiat+Rpartstore+Dialogys)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
A partial warm session (e.g. Rpartstore DOWN → only Fiat+Dialogys) is proven
unstable: re-warm cycles + the missing catalog's launch-error keep knocking the
Fiat window off its VIN panel, so warm Fiat decodes thrash to the 180s budget →
not_found. Require all three windows before this.warm=true; otherwise stay on the
reliable cold path (which decodes Fiat/Renault + cross-brand cleanly). The daemon
backs off + retries, so warm auto-resumes once Rpartstore recovers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:25:39 +03:00
0011c80ec3 Merge pull request 'fix(vinpin): warm-Fiat decode (Поиск false-match + silent-null cold-fallback + strict not-found)' (#236) from dev into main 2026-07-15 13:03:30 +03:00
46f0210ba6 fix(vinpin): strict not-found for warm-Fiat full-frame garble check
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Review finding: warmDecode's genuine-not-found decision used VINPIN_OCR.notFound
(/…|Catalogue/i) against the FULL frame, where the 'Spare Parts Catalogue' header
always matches → every on-panel garble was flagged a genuine miss and null'd out
instead of falling back to the cold retry. Add notFoundStrict (no Catalogue token)
for the full-frame check so a transient on-panel garble (VIN exists) recovers via
the cold path; keep notFound for the runVinFlow modal-region settle poll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:03:23 +03:00
31aabfc28f fix(vinpin): warm-path Fiat silent not_found (wrong-window raise + silent null)
When the warm daemon is up and Rpartstore is DOWN, a partial-warm session
(Fiat opened, Rpartstore launch-error, Dialogys opened last → foreground)
made a Fiat warm decode fail silently: the Fiat foreground regex shared the
`Поиск` token with the Dialogys "ПОИСК" button, so raiseWarmWindow reported
success without raising Fiat and the VIN was typed into Dialogys → garbage;
warmDecode's Fiat branch then did a bare `return null` (no log, no fallback).

- FIX 1: drop the ambiguous `Поиск` from VINPIN_WINDOW_FOREGROUND.fiat; keep
  Fiat-only chrome (Dealer/ePER) + VIN-panel model tokens.
- FIX 2: warmDecode Fiat unusable-parse no longer returns a silent null —
  OCR the frame; on-panel + genuine not-found → null (real miss), otherwise
  warn (cold-path parity) and fall back to the proven cold decodeFiatLocked.
- FIX 3: ensureWarmWindow panel-verifies a raised Fiat window (catalogueReady);
  if up but off the VIN panel, re-navigate via establishSession (bounded/never-throw).
- FIX 4: _warmUp records per-window availability (warmWindows) so a Fiat VIN
  routes straight to cold when no Fiat window opened; and dismisses a leftover
  Rpartstore launch-error modal before opening Dialogys so it can't dirty the
  desktop / drive the wrong-window state.

Adds ocrFrame() test seam + 4 unit tests. tsc clean; 114 vinpin tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 12:55:10 +03:00
0de05e5515 Merge pull request 'fix(vinpin): cheap in-session retry on ambiguous Renault decode' (#235) from dev into main 2026-07-15 10:28:57 +03:00
9cb58c583c fix(vinpin): cheap in-session grid reset on ambiguous Renault retry (no relaunch)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
An AMBIGUOUS Renault outcome is transient/state-dependent, but decodeRenaultLocked
retried it with a full browser teardown (this.close()). The next attempt then
re-launched chromium + re-did the web login + re-established from scratch
(~60-90s each), and each re-establish re-hit the seat's dirty-resume ("catalog
window resumed open" -> closeStrayRunningApps), so 3 attempts blew the 180s budget
-> not_found. Proven live: VF1RFE00653633190 decoded cleanly to KADJAR earlier
when the desktop state was favorable, then thrashed to not_found on relaunch.

Fix: on the ambiguous path, reset to a clean VinPower brand grid on the SAME live
session via ensureBrandGrid (closeStrayRunningApps DOM recovery first, canvas
tab-X fallback) instead of tearing the browser down. Keep the browser + authed so
the next iteration's ensureAuthenticated is a no-op (no relaunch, no web login),
and re-run runRenaultFlow from the clean grid (~30-40s). Graduated safety: if the
cheap reset can't confirm a clean grid or the session is broken (page
closed/disconnected), fall back to the old close() + cold re-establish. The reset
runs under the wall-clock deadline so an overrun still routes to the existing
VinpinBudgetError teardown+poison path. never-throw + budget/poison paths
unchanged; maxAttempts semantics unchanged.

Tests: +3 (ambiguous -> in-session ensureBrandGrid reset re-runs runRenaultFlow
with NO close(); graduated fallback close()s when the reset can't reach a grid;
broken session skips straight to close()). 110 vinpin tests green; tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:27:17 +03:00
d55a2b570f Merge pull request 'fix(vinpin): DOM Running-panel recovery for dirty-resume + fail-safe stray close' (#234) from dev into main 2026-07-15 09:49:20 +03:00
cf36da07af fix(vinpin): fail-safe stray-app close — never terminate a row on an unreadable name
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Review finding: closeStrayRunningApps classified an unreadable/empty VinPower
running-app row as a stray and closed it (`/VinPower/i.test('')` is false),
needlessly killing+relaunching a healthy VinPower on a transient name-read miss.
Only close rows POSITIVELY identified as non-VinPower (non-empty name that fails
the VinPower match); treat unreadable names as keep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:48:48 +03:00
814d11d03e fix(vinpin): DOM-based dirty-resume recovery via Horizon Running panel
On login the RDS seat resumes DIRTY (e.g. a Renault Rpartstore launch-error
modal + its taskbar window over the brand grid). The old recovery failed: the
canvas tab-✕ (93,45) only hits a TABBED catalog window's ✕, which a resumed
stray doesn't have, so 6 tries did nothing and escalated to logout+relogin —
counterproductive, since the seat publishes apps-only and the RDS session ends
only on server-side idle timeout, so a Connection-Server logout+relogin PROVABLY
resumes the same dirty window.

New state-agnostic recovery `closeStrayRunningApps`: reveal the Horizon sidebar
(#sidebar-toggler), enumerate ul.running-app rows, and terminate every app whose
name != VinPower via its per-app ✕ (li.icon-close-app-image) — real DOM outside
the Blast canvas, so it closes a window regardless of its modal/spinner/loading
state. Collapse the sidebar, OCR-confirm the brand grid; relaunch VinPower via
#available-VINPIN (or the vinpinApp canvas coord) if the app itself was gone.

Wired as the PRIMARY recovery in ensureBrandGrid — both the resumed-catalog
branch (before the canvas tab-✕ fallback) and the end-of-loop escalation, which
NO LONGER calls logout+relogin (method retired). Every DOM op is guarded
(try/catch + presence check) so a missing selector / canvas-only render degrades
gracefully to the existing dismissBlockingModal + tab-✕ / OCR path instead of
throwing. Bounded loop; the launch-error modal dismissal (OK 868,530 / Escape)
is kept as a fast pre-step and fallback.

Preserves the never-throw contract, 180s budget/poison, spinner-guard, acquire
cap, launch-error cooldown, ensureRpartstore fast-bail, and the Fiat ePER path.
tsc clean; 107 vinpin tests green (adds closeStrayRunningApps close/degrade tests
and the ensureBrandGrid-uses-closeStrayRunningApps escalation tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:43:53 +03:00
ee56dec2d5 Merge pull request 'fix(vinpin): ffmpeg OCR root-cause + robust DOWN-Rpartstore Renault decode (defense-in-depth + hardening)' (#233) from dev into main 2026-07-15 08:47:46 +03:00
8137845198 fix(vinpin): bail a DOWN Rpartstore on iteration 1, don't re-click the flyout 6×
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
When Rpartstore is DOWN, its hard launch-error modal ("Ошибка запуска каталога",
title "Renault Rpartstore") renders OVER the brand grid. The grid tiles stay
OCR-visible behind the small centered modal, so the full-frame read matches both
`brandGrid` and `renaultSubmenu` — making ensureRpartstore's flyout branch fire on
EVERY iteration, re-clicking renaultRpartstore(584,779) + langOk + a 12s full-frame
poll for all ~6 iterations (~72s) before finally returning false. That wastes ~60s on
the first cold DOWN decode AND repeatedly actuates coordinates on a wedged desktop.

Detect the launch-error modal (upscaled crop via the existing
`rpartstoreLaunchErrorPresent`) at the top of the per-iteration loop, BEFORE the
flyout branch: if present, return false on iteration 1 so the acquire loop's
`!present` path dismisses it, sets the down-cooldown, and routes straight to Dialogys.
Depends on the crop OCR being legible (ffmpeg upscale, added in 281c54a); when
illegible it's false and behaviour is exactly as before.

Adds two robustness tests: (1) modal-over-grid → false on the first iteration with no
flyout re-clicks; (2) illegible crop → flyout path still runs (unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:46:40 +03:00
c8d9e45207 fix(vinpin): don't cool down Rpartstore on transient born-stuck streak; don't relaunch on clean-logout launcher
Two review follow-ups to 281c54a:

1. born-stuck cooldown regression: acquireLoadedRpartstoreInner set the 10m
   down-cooldown when maxOpens was exhausted by a born-stuck-spinner streak — a
   TRANSIENT, reopen-recoverable blip, not a server outage. Because the cooldown
   can only self-clear from INSIDE the acquire loop (skipped while cooling down),
   one spinner streak suppressed the richer Rpartstore catalog for every Renault
   decode for 10m. Reserve the cooldown for the confirmed launch-error DOWN signal
   (unchanged at the two launch-error sites); the born-stuck give-up now just falls
   back to Dialogys for that one VIN and retries Rpartstore fresh next VIN.

2. clean-logout false disconnect-recovery: cleanTeardown's disconnect recovery
   gated on VINPIN_OCR.sessionDropped, whose broad "HTML Access" token also matches
   the clean-logout Horizon HTML-Access launcher. A clean log-off could then click
   disconnectedClose + RELAUNCH VinPower right before close(), leaving the exact
   dirty resumed session the teardown prevents (+~17s wasted). Veto the recovery
   with !VINPIN_OCR.launcher so it fires only on a real Disconnected drop.

Keeps never-throw, budget, spinner-guard, Fiat/Dialogys fallbacks intact. Adds a
born-stuck-no-cooldown test and a clean-logout-launcher-no-relaunch test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:41:35 +03:00
281c54a423 fix(vinpin): Renault-decode robustness for a DOWN Rpartstore (ffmpeg + flyout/close/logout/cooldown)
Root cause: ffmpeg is absent in the prod worker, so vinpin.ocr cropScale silently
returns the full 1600x900 frame — the tiny centered Rpartstore launch-error modal
is illegible, so a DOWN Rpartstore is misread as a spinner and thrashes the seat.

- Dockerfile: install ffmpeg so cropScale actually crops+3x-upscales (restores all
  clipped OCR: modal-crop error detect, Fiat modal, Renault header).
- ensureRpartstore: gate the "already open" short-circuit on a CONTENT token
  (rpartstoreLoaded), not rpartstoreOpen which false-matches the flyout/title word
  "Rpartstore"; click the flyout entry when the submenu is up over the grid; keep a
  late open-window branch so a spinner/vehicle-page window still counts as present.
- closeRpartstoreTab: never click windowClose(1298,14) (it hits the language
  selector and wedges the grid); gate the retry on a content token; Escape after.
- cleanTeardown: dismiss any blocking modal BEFORE "Çıkış yap" so logout is a clean
  RDS log-off (not a dirty channel disconnect); recover a Disconnected dialog via
  disconnectedClose(953,505) + relaunch VINPIN app for a fresh grid.
- Rpartstore-down cooldown (10m): a launch-error / repeated load-failure routes
  Renault decodes straight to Dialogys (skip reopening a down catalog); a confirmed
  load clears it.
- ensureBrandGrid: dismiss a wedging launch-error modal + short-retry instead of
  burning the 84s dead-wait.
- parseRenaultHeader: ignore the status-bar license-expiry date when reading the
  model year; sessionAlive matches RDST01/RDST02 (seat load-balances).
- Keeps never-throw, VINPIN_DECODE_BUDGET_MS, sessionPoisoned, the acquire cap,
  spinner-guard, relogin-cap and the Fiat/Dialogys fallbacks intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:32:39 +03:00
5b93864dd5 Merge pull request 'fix(vinpin): dismiss leftover launch-error modal + cap relogin per decode (browser-disconnect loop)' (#232) from dev into main 2026-07-15 07:01:23 +03:00
f54511d393 fix(vinpin): clear leftover launch-error modal on acquire give-up + cap relogin
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The Renault decode fell to not_found when Rpartstore is DOWN (hard
launch-error modal) even though Dialogys should take over. Root cause:

- acquireLoadedRpartstore's budget-exhausted / never-loaded return-false
  paths left the centered launch-error modal on screen (only the flaky
  OCR error-detect branch dismissed it). The modal then blocked the
  Dialogys grid-return.
- returnToBrandGrid's tab-✕ can't close a centered dialog, so every
  "not on brand grid (try N/4)" wedged and re-entered ensureBrandGrid,
  which escalated to logoutAndRelogin → close()+launch() ("browser
  disconnected — will relaunch") on EVERY iteration, thrashing on a stale
  page ref until the 180s budget → not_found.

Fixes (conservative, all safety nets intact):
1. Wrap acquireLoadedRpartstore so EVERY false return runs a best-effort
   defensive dismiss (Escape → click launch-error OK 868,530 → Escape),
   unconditional of the OCR read. Harmless when no modal is up.
2. returnToBrandGrid + ensureBrandGrid dismiss a possible centered modal
   before the tab-✕ close so a leftover dialog can't wedge the loop.
3. Cap the logout+relogin escalation to ONE attempt per decode/warm-up
   (reloginUsedThisDecode) — a capped exhaustion poisons the seat for a
   clean cold restart instead of looping close()+launch() until budget.

Keeps the OCR fast-path branch, maxOpens/acquireBudgetMs=14s, fcc0298,
61b5769, Fiat path, never-throw/budget/poison all intact.

Tests: +3 (budget-exhausted defensive dismiss; grid-return modal-clear
before tab-✕; relogin capped to one attempt) — 88 vinpin tests green,
tsc + biome clean. Needs prod validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 06:47:28 +03:00
9f6693e833 Merge pull request 'fix(vinpin): cap Rpartstore acquire at ~1 open via 14s sub-budget' (#231) from dev into main
Reviewed-on: #231
2026-07-15 06:25:45 +03:00
e0b3de8dc7 fix(vinpin): cap Rpartstore acquire at ~1 open via 14s sub-budget
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Rpartstore's launch-error modal OCR-detection is unreliable across renderings,
so a DOWN Rpartstore was grinding all 3 reopens (~54s) + dirtying the seat →
Dialogys fallback couldn't finish inside the decode budget → not_found.
Lower acquireBudgetMs 90s→14s so the loop bails after the first open+poll (~22s)
straight to Dialogys on a still-clean seat. Healthy Rpartstore loads on the first
open and is used as before; maxOpens kept so tests still exercise the reopen path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 06:18:55 +03:00
6c5f8401f8 Merge pull request 'fix(vinpin): detect Rpartstore launch-error via upscaled modal crop, not full 1x frame' (#230) from dev into main
Reviewed-on: #230
2026-07-15 06:01:39 +03:00
542ab0cb2b fix(vinpin): detect Rpartstore launch-error via upscaled modal crop, not full 1x frame
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The prod Rpartstore→Dialogys bail regressed: a DOWN Rpartstore was still
misclassified as a "born-stuck spinner", burning 3 reopens (~54s) and pushing
the decode past the 180s budget → not_found. The rpartstoreLaunchError regex
never matched because acquireLoadedRpartstore's load-poll OCR'd the FULL
1600x900 frame at 1x — at which the small centered Cyrillic modal ("Ошибка
запуска каталога") is illegible to tesseract (it returns the surrounding
brand-grid tiles and drops the modal text). The regex text was actually fine;
the modal was never fed to it.

Root cause (verified live 2026-07-15, seat trvinpin47828): wrong OCR
resolution/region, not wrong regex.

Fix:
- New VINPIN_RPARTSTORE_ERROR_REGION (centered modal crop); OCR it UPSCALED (3x)
  so "Ошибка запуска каталога" reads as "Owwu6ka 3anycka KaTanora" and matches.
- pollRpartstoreState(): each poll reads LOADED off the full frame (large Latin
  text, unchanged) AND the launch-error off the upscaled modal crop → bail on the
  FIRST open, no wasted reopens. Genuine-spinner reopen path preserved.
- Also catch the launch error when ensureRpartstore/raiseWarmWindow can't confirm
  a window (modal is over the grid, no catalog chrome) → dismiss + bail.
- rpartstoreLaunchError regex: add the verbatim live transliterations
  (Owwu6ka/OwwbKa); 3anycka+KaTanora remain the stable anchors.

Live verification (seat trvinpin47828, exclusive night access):
- Rpartstore is DOWN server-side (hard launch error, NOT a spinner).
- New detection returns launchError on open 1 → bail, no reopens.
- Dialogys fallback decoded VF1RFE00653633190 → RENAULT Kadjar (HFE) in 52.2s
  (well under the 180s budget).

Budget/poison/livelock/Fiat paths untouched. 85 vinpin unit tests green;
typecheck + biome clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 05:53:22 +03:00
74c718fb18 Merge pull request 'fix(vinpin): detect Rpartstore hard launch-error → bail straight to Dialogys' (#229) from dev into main
Reviewed-on: #229
2026-07-15 05:16:15 +03:00
0ad3f114f3 fix(vinpin): detect Rpartstore hard launch-error → bail straight to Dialogys
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Rpartstore currently throws an immediate hard launch-error modal ("Ошибка
запуска каталога" / title "Renault Rpartstore") within ~8-10s of every open —
a server-side/entitlement failure that reopening never fixes. The old code
misclassified it: the error dialog's title matched rpartstoreOpen so
ensureRpartstore returned true, but it lacked the rpartstoreLoaded content
markers, so acquireLoadedRpartstore judged it a born-stuck spinner and burned
all 3 reopens (~54s) before falling back to Dialogys — which then ran out of
the 150s decode budget → not_found. Dialogys itself decodes correctly (~25s).

Fix (detect-and-bail on the FIRST open, no wasted reopens):
- constants: add VINPIN_COORDS.rpartstoreLaunchErrorOk (868,530) + the
  VINPIN_OCR.rpartstoreLaunchError pattern (matches the real Cyrillic AND its
  stable eng-OCR transliteration "Owwnbka 3anycka KaTanora").
- acquireLoadedRpartstore step 2: poll now stops on loaded OR launch-error and
  classifies via the returned OCR text; a launch error dismisses the modal and
  returns false immediately → straight to Dialogys (reopen loop untouched for
  genuine spinners).
- ensureRpartstore: submenu-open poll also stops fast on the launch error
  instead of dead-waiting afterRenaultCatalogOpen.
- bump VINPIN_DECODE_BUDGET_MS 150s→180s (cheap safety margin).
- tests: launch-error → false after ONE open (no reopens, dismiss clicked);
  genuine spinner still reopens; OCR-pattern matches Cyrillic + transliteration.

Never-throw contract, sessionPoisoned, budget teardown, cold/Dialogys
fallbacks, the fcc0298 spinner-guard, the 61b5769 establish/teardown fix and
the Fiat ePER path are all intact. Skipped the warm-path Dialogys field-clear
tweak — runDialogysSearch is shared with the cold path and switching its clear
step would change cold behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 04:24:02 +03:00
68e3eb4a61 Merge pull request 'dev' (#228) from dev into main
Reviewed-on: #228
2026-07-15 03:12:22 +03:00
61b5769e48 fix(vinpin): reliable warm-daemon establish (clean teardown + tab-close + backoff)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Root cause of warm-establish failures was resume-into-dirty-session +
broken window-close + no backoff, not OCR/detection. Four composing fixes:

A. Clean teardown (cleanTeardown): before the browser close(), close each
   open catalog window via the corrected tab-✕ then click "Çıkış yap" logout
   to END the RDS session, so the next warm-up starts from a fresh login/grid
   instead of resuming into the last-open 3-window desktop. Wired into
   teardownWarm() and both failed-warmUp exits. Bounded + never-throw.

B. Fix close coords + tab-✕ primary. catalogTabClose {135,45}→{93,45}
   (validated live). In ensureBrandGrid/returnToBrandGrid the tab-✕ is now the
   PRIMARY close; the windowClose {1298,14} click (which opens the HTML-Access
   language dropdown) is no longer used there. Escape pressed after each close
   to dismiss an accidental dropdown before re-OCR. After N failed closes,
   ensureBrandGrid escalates to logout+relogin (one-shot, no recursion) instead
   of limping into the ePER-open loop.

C. Realistic grid wait. afterLogin 20_000→45_000 (real grid render ~32-46s).

D. Backoff between re-warm attempts. A failed warmUp sets a cooldown (60s,
   exponential to 5min) that BOTH reconcile() and decode()'s warm-on-demand
   honour; a successful warm resets it — so a failing seat is no longer
   hammered every ~60s leaving fresh dirty windows.

Safety nets preserved: never-throw contract, VINPIN_DECODE_BUDGET_MS,
sessionPoisoned, cold/Dialogys fallbacks; fcc0298 Rpartstore spinner-guard,
Fiat ePER path, and Russian-dialog dismissal (746,454) untouched. Cannot be
exercised in dev (single seat on prod) — needs prod validation on a rested seat.

Tests: +8 unit tests (clean-teardown ordering, tab-✕ primary + relogin
escalation, warm-up backoff respected by reconcile + warm-on-demand + reset).
81 vinpin tests green; tsc + biome clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:11:21 +03:00
8e10ebc883 feat(web): redirect path-style /dashboard/settings/<tab> to ?tab= search param
Unsubscribe confirmations (and any stale links) used the path form which
had no route and fell to the SPA not-found screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:47:24 +03:00
29e10b432f feat(notifications): visible unsubscribe footer link in lifecycle mails
- inject signed unsubscribeUrl into every optional-workflow Novu payload
  (templates render it via {{#if unsubscribeUrl}} footer)
- add 'conversion' campaign workflow to OPTIONAL_WORKFLOWS + email_marketing
  category so its one-click tokens validate and opt-outs suppress it
- declare UNSUBSCRIBE_SECRET/URL_BASE/EMAIL in env schema (""→undefined
  preprocess against the url().optional() boot-crash trap), .env.example and
  both compose env blocks
- fix confirmation-page settings link (?tab=notifications)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:35:47 +03:00
55dd34a450 Merge pull request 'dev' (#227) from dev into main
Reviewed-on: #227
2026-07-14 15:50:40 +03:00
66b39862c8 fix(part-prices): araç-marka etiketlerini de orijinal say (FORD/GM/BMW/Mercedes/VW)
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>
2026-07-14 15:34:10 +03:00
fcc02984dd fix(vinpin): Rpartstore acquire-with-spinner-guard for Renault decode
A freshly-opened Rpartstore instance sometimes gets "born stuck" on an
infinite spinner (survives raise/maximize). The only reliable fix, proven
in a live spike, is to CLOSE the stuck instance and reopen a FRESH one.

Replace the old "raise Rpartstore → if not focusable reopen → else fall to
Dialogys" with a bounded load-verify-else-reopen loop applied to BOTH the
warm-daemon Renault path (warmRenaultDecode) and the cold per-decode path
(runRenaultFlow):

- acquireLoadedRpartstore(): bring a Rpartstore window forward, OCR-verify it
  actually rendered its search-home landing markers (new rpartstoreLoaded set —
  content tokens the spinner lacks), and if still spinning close the tab and
  reopen a fresh instance. Retries up to VINPIN_RPARTSTORE.maxOpens (3) times.
- Bounded inside the decode wall-clock budget AND a tighter acquireBudgetMs
  (90s) sub-cap, so a permanently-stuck Rpartstore still leaves headroom to
  fall back to Dialogys — never a livelock.
- Rpartstore stays PRIMARY (richer Turkish catalog); Dialogys only as a
  last resort once reopen attempts are exhausted or it genuinely misses.
- Fiat ePER path unchanged; sessionPoisoned / never-throw contract preserved.

Adds unit tests for reuse / spinner→reopen→loaded / exhausted→false /
budget-bail / Rpartstore-primary-on-hit / exhausted→Dialogys-fallback.

Could not live-test (single Vinpin seat is held on prod) — needs prod
validation after promote.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:29:12 +03:00
edde2dc3b4 fix(part-prices): OEM fiyatını kodun araç markasına göre filtrele (PSA vs OPAR)
Aynı OEM koduna farklı OE dağıtıcıları düşebiliyor (Stellantis: PSA ₺5.531 vs
OPAR ₺3.459) → OE-only havuz bile iki dağıtıcıyı medyanlayıp hayalet fiyat
veriyordu. Artık kart, kodun kendi araç markasına göre tek OE ailesine süzer.

- part-prices.logic: marka→OE-dağıtıcı-ailesi haritası (OE_FAMILIES: PSA=
  Peugeot/Citroen/DS, FIAT=Fiat/OPAR/Tofaş, RENAULT=Renault/Dacia/MAIS, ...).
  filterOffersForBrand: araç markası → yalnız o ailenin orijinal teklifleri
  (jenerik ORJINAL aile-belirsiz olduğu için elenir); yan sanayi markası çipi
  → yalnız etiket-uyumlular; markasız → tüm OE aileleri (taban).
- Web: OEM kartı kodun araç markasını oem-vehicles'tan (en çok geçen brandName)
  türetip geçirir; başlık "Orijinal (OE) fiyat analizi · <Marka>". Kart marka
  çözümü için oem-vehicles'ı bekler.
- vehiclesByOem NORMALIZE eşleşmeye geçti + parts_oem_code_norm_idx functional
  index (migration 0033): PSA kodları boşluklu saklandığından ("9827 622 780")
  exact match onları kaçırıyordu — marka bu yüzden çözülemiyordu. Reverse
  "kataloğunuzda" bölümü de artık reformatlı kodları buluyor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:07:55 +03:00
f52cafd622 Merge pull request 'dev' (#226) from dev into main
Reviewed-on: #226
2026-07-14 13:51:22 +03:00
d0caf57e89 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>
2026-07-14 13:28:27 +03:00
32655ead7b chore(vinpin): add VINPIN_WARM_DAEMON compose env ref (api+worker) so Coolify can inject the warm-daemon flag
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:21:35 +03:00
98376747cd feat(vinpin): persistent warm-session daemon (taskbar-raise decode) with cold fallback
Replace the per-decode "launch browser + login + open catalog" model with a
persistent warm Vinpin seat that eliminates cold-start, brand-switch cost and the
seat livelock. Additive + fail-safe: every warm operation degrades to the proven
cold per-decode path, so behaviour never regresses.

Warm daemon (VinpinDaemonService, worker-process singleton):
- Scheduler warms the seat at 08:00 and tears it down at 21:00 Europe/Istanbul
  (proper TZ via Intl, no hardcoded offset); warms on worker start if inside hours;
  reconcile() every 60s with a reentrancy guard.
- Keepalive nudges the RDS session (mouse.move) every ~75s while warm+idle; it
  SKIPS during any active seat op (busy flag) and never takes a lock that blocks a
  decode.
- decode(vin): inside hours ensure warm (warm-on-demand once) then delegate to the
  driver; off-hours delegate straight to the cold path. Never throws.

Driver warm path (VinpinDriverService):
- warmUp() launches+logs in ONCE and opens Fiat ePER + Renault Rpartstore + Renault
  Dialogys windows without closing each other, then OCR-binds each taskbar button
  (order read via OCR, not hardcoded; raise self-heals by probing slots + OCR
  verify). isWarm()/teardownWarm()/keepalivePing() added.
- warmDecode(): taskbar-raise the brand window (Fiat→ePER, Renault→Rpartstore w/
  Dialogys fallback), run the EXISTING in-catalog decode on the warm window, OCR
  the modal, parse, then Escape to ready the field for the next VIN. Runs under the
  wall-clock budget; a hang still aborts.
- Health-recovery: a dropped seat (Disconnected/no-free-sessions OCR marker) →
  teardown + re-warm ONCE, then retry the decode once.
- Refactored runDialogys into openDialogysSubmenu + runDialogysSearch so the warm
  path searches without a window-closing reopen; cold Dialogys flow unchanged.

Safety nets retained: VINPIN_DECODE_BUDGET_MS + sessionPoisoned breaker (warm
budget abort → teardown+poison+null), single-seat serialization (runExclusive),
decode() never throws. Gated by VINPIN_ENABLED; VINPIN_WARM_DAEMON=false forces the
legacy cold path (kill-switch). Widened VINPIN_MODAL_REGION to ~900px.

Wiring: processor calls getVinpinDaemon().decode(); worker starts the daemon on
boot and stops it (releasing the seat) on shutdown. BullMQ concurrency 1 +
attempts:1 unchanged.

Tests: business-hours warm/teardown scheduling (injected clock/TZ), brand→taskbar
routing, taskbar OCR-order binding, keepalive-skips-during-decode, and
session-drop→re-warm→retry recovery. All existing vinpin/queue tests stay green.

NOTE: un-dev-testable (prod holds the single seat) — pixel/taskbar coords are
OCR-verified + marked TUNE and need live prod validation; warm falls back to cold
until confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:19:20 +03:00
ccf2417cc8 Merge pull request 'fix(vinpin): break the single-seat decode livelock (attempts:1 + budget + poison)' (#225) from dev into main
Reviewed-on: #225
2026-07-14 11:32:46 +03:00
d253a43ad2 fix(vinpin): break the single-seat decode livelock (attempts:1 + budget + poison)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
A bad/unresolvable VIN could leave a catalog window open on the shared Vinpin
seat; the next decode's ensureBrandGrid found it "resumed open" and looped
(close→brand-grid-not-confirmed→ePER-open→browser-disconnect→relaunch) forever.
BullMQ attempts:2 + 30s backoff auto-re-fed every failure straight back into the
stuck seat, starving all real decodes for minutes.

- queue: attempts:1, drop the 30s exponential backoff (extract VINPIN_DECODE_JOB_
  OPTIONS). The driver already runs its own bounded internal retries; a BullMQ
  retry on top is what compounded the livelock. Null decode still persists as
  not_found; a hard infra throw stays user-retriable (failed).
- driver: hard per-decode wall-clock budget (VINPIN_DECODE_BUDGET_MS, 150s) via
  withDeadline() racing each attempt; on abort → close() + poison seat + return
  null (no retry into the stuck state).
- driver: sessionPoisoned flag — set at nav-loop exhaustion (ensureBrandGrid /
  establishSession), budget abort, and failed post-decode cleanup; the NEXT
  decode forces a full cold re-establish instead of reconnecting to the resumed
  desktop. Cleared on any confirmed-clean grid/catalogue.
- driver: finally-cleanup after every decode — on failure/not-found return the
  seat to a clean brand grid; if that can't reach the grid, poison + tear down.
- driver: basic VIN sanity (17 alphanumerics) before touching the seat.

Healthy Fiat/Renault happy paths are byte-identical when nothing is stuck.
Cannot be live-tested (seat is on prod) — needs prod validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:29:43 +03:00
c2bb1e7382 Merge pull request 'fix(vinpin): prefer exact model over wrong-market catalog in matcher' (#224) from dev into main
Reviewed-on: #224
2026-07-14 10:55:06 +03:00
1fade89df4 fix(vinpin): prefer exact model over wrong-market catalog in matcher
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The Vinpin catalog matcher could route a European Renault VIN
(VF1RFE00653633190, decoded "KADJAR") onto the China-market catalog
"KADJAR ÇİN" (source XZH, 22 categories) instead of the correct European
"KADJAR" (XFE, 44 categories), serving wrong parts. Root cause: the
Turkish "ÇİN" lost its Ç/İ to the ASCII token strip and collapsed to a
dropped 1-char "N", so "KADJAR ÇİN" tokenized identically to "KADJAR" —
the market qualifier was invisible to the scorer.

Fixes:
- modelTokens now folds diacritics (NFD + combining-mark strip) so
  "ÇİN" survives as the ASCII token "CIN".
- Scorer prefers an EXACT normalized model match (no extra tokens) over
  a superset, via a bonus kept smaller than the year-range swing so
  multi-generation routing (2022 TIPO-EGEA → MCA) is unaffected.
- New MARKET_QUALIFIERS set (ÇİN/CIN, CHINA, CHINE, RUSYA, ... grounded
  in the real Renault/Dacia catalog rows) heavily penalizes candidates
  whose EXTRA tokens are region qualifiers; generation tokens are not
  penalized, so generations stay matchable.
- Richer-catalog (categoryCount) tiebreak among equal-score candidates;
  match() query selects the category count via a correlated subquery.

Adds unit tests incl. the KADJAR case, CLIO/DUSTER generation cases, and
diacritic-folding. Fiat behavior unchanged; all 39 vinpin tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:47:55 +03:00
7387c02da0 Merge pull request 'fix(web): cover cold Vinpin decode + drop hardcoded Fiat no-catalog copy' (#223) from dev into main
Reviewed-on: #223
2026-07-14 10:22:48 +03:00
7069ceaeae fix(web): cover cold Vinpin decode + drop hardcoded Fiat no-catalog copy
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Two Vinpin decode-flow UX bugs in the search page:

1. Poll window (VINPIN_MAX_POLLS 6→30, still 3s interval) now covers a
   cold Vinpin VDI decode (~60-100s) instead of bailing at 18s. On
   poll-exhaust we no longer fall through to the no-catalog dead-end;
   instead a reassuring brand-neutral "still working, ready shortly"
   card (decodePendingHint) — the decode caches server-side so a later
   re-submit returns instantly.

2. The poll-exhaust fallback previously forced brandName:"Fiat" onto the
   no-catalog prompt, so a Renault VIN read "Fiat". Removed. The
   no-catalog copy is brand-aware via the API's noCatalog.brandName, with
   brand-neutral fallback keys (noCatalogHintNeutral / browseCatalogCtaNeutral)
   when no brand is available. A Renault VIN never says "Fiat".

Web-only, no flag. New i18n keys added to tr.json + en.json. No API change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:21:43 +03:00
cf9454482f Merge pull request 'perf(vinpin): in-session Renault→Fiat swap + OCR-poll waits + input micro-trims' (#222) from dev into main
Reviewed-on: #222
2026-07-14 10:09:38 +03:00
eaa500ddd5 perf(vinpin): in-session Renault→Fiat swap + OCR-poll waits + input micro-trims
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Latency optimizations in the flag-gated (VINPIN_ENABLED) Vinpin decode driver.
Every change keeps the existing fixed-wait value as a fallback cap, so
worst-case behaviour and robustness are unchanged — only the common case is
faster. No live seat session was run (prod holds the single Horizon seat).

#2 Renault→Fiat symmetric swap: ensureReady no longer tears the whole browser
down on a Renault→Fiat flow switch (was a ~60-80s cold restart). It now keeps
the authenticated Horizon/VinPower session, returns to the brand grid via the
existing ensureBrandGrid (which closes a resumed foreign catalog window) and
reopens Fiat via the normal tile flow — symmetric with the Fiat→Renault
direction. Guarded fallback: if the in-session swap can't reach the grid /
throws, it falls back to close()+cold re-establish.

#3 OCR-poll-until-ready: replaced big fixed post-action sleeps whose completion
is OCR-detectable with a capped poll (screenshot→ocrRegion→regex every ~700ms,
return on match, cap == old fixed wait). Converted: afterFiatOpen(12s),
afterVinSubmit(7s, Fiat modal), afterRpartstoreSubmit(8s),
afterRenaultCatalogOpen(12s, ×2), plus cold-path afterLogin(20s) and
afterVinPowerLogin(18s). Left afterVinpinLaunch(14s) fixed — the VinPower login
dialog has no reliable OCR marker (detected via DOM), and it doubles as a
generic connecting-screen settle. Left afterDialogysSubmit fixed (out of scope).

#4 Micro-trims: field-clear Backspace burst 40→5 (VINPIN_FIELD_CLEAR_BACKSPACES),
key-type delay 45-50ms→20ms (VINPIN_TYPE_DELAY_MS, 17-char VIN ~850→~340ms),
afterAlertDismiss 700→250ms.

New pure/injectable pollForText helper in vinpin.ocr.ts (unit-tested:
early-return, cap-never-exceeded, first-read match). typecheck + biome clean;
31 vinpin unit tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:57:40 +03:00
e47e795a09 Merge pull request 'dev' (#221) from dev into main
Reviewed-on: #221
2026-07-14 09:13:52 +03:00
77c93af9a0 feat(vinpin): Renault/Dacia decode akışı (Rpartstore + Dialogys)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
VINPIN köprüsüne Fiat'ın yanına Renault/Dacia desteği eklendi.

- Marka yönlendirme: selectVinpinBrandFlow(vin) WMI'den akışı seçer
  (Renault/Dacia → Rpartstore/Dialogys, geri kalan → mevcut Fiat ePER,
  Fiat davranışı byte-identical). isVinpinBrandAllowed allowlist'i
  fiat + renault + dacia'ya genişletildi.
- Renault akışı durum-toleranslı: koltuk son katalogu (Rpartstore)
  sunucu-taraflı hatırlayıp açık resume ediyor; ensureRpartstore grid /
  submenu / açık-pencere / yükleniyor durumlarını tanıyıp kendini
  toparlıyor. Rpartstore ana akış, Dialogys best-effort fallback.
- Ortak ensureBrandGrid artık resume olmuş yabancı katalog penceresini
  (Rpartstore/Dialogys/ePER) kapatıp grid'e dönüyor → Renault↔Fiat
  ardışık decode'ları (tek koltuk) artık kırılmıyor.
- parseRenaultHeader/isUsableRenaultParse: OCR başlığından model+marka
  (RENAULT/DACIA) + yıl; bilinen-token allowlist ile contiguous model
  koşusu. Matcher + processor kararlaştırılan markaya göre PL24
  catalog_vehicle eşliyor (Fiat varsayılan korunur).
- Testler: Renault parser + marka-yönlendirme/allowlist birim testleri.

Flag-gated (VINPIN_ENABLED). Canlı doğrulama: 5/5 Renault/Dacia VIN
(Kadjar, Clio II, Clio IV, Dacia Duster, Latitude) doğru decode; Fiat
Egea spot-check korunuyor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:44:09 +03:00
24716d03a9 Merge pull request 'revert(build): drop ineffective cache-bust (#219)' (#220) from revert/build-cache-web into dev 2026-07-14 08:13:46 +03:00
6de7839107 revert(build): drop ineffective per-commit cache-bust (#219)
SOURCE_COMMIT isn't substituted into docker-compose build args by Coolify
(resolved to a constant "unknown"), so the cache-bust never fired. Removing the
dead ARG/RUN + compose arg. Web-only deploys use a Coolify force-rebuild instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:13:44 +03:00
edcd168d48 Merge pull request 'fix(build): per-commit cache-bust for web-only deploys' (#219) from fix/build-cache-web into dev 2026-07-14 07:43:07 +03:00
c20fc5462a fix(build): per-commit cache-bust so web-only changes actually deploy
Auto-deploys (docker build with cache) were reusing a stale build layer, so
web-only commits shipped an old bundle — the switcher removal (#216) and the
canonical fix (#217) only appeared after a manual --no-cache/force rebuild.
Reference SOURCE_COMMIT (substituted by Coolify) right before `COPY . .`, so the
build layer's cache key changes every commit and COPY + `pnpm build` re-run. The
deps stage stays cached on the lockfile, so installs aren't repeated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 07:43:05 +03:00
4bba42fc13 Merge pull request 'promote: Fiat eper-default katalog (Orijinal→ePER, Birleşik→kanonik)' (#218) from dev into main 2026-07-14 07:35:32 +03:00
2bf7525fe2 Merge pull request 'fix(catalog): Fiat Birleşik = canonical view (not raw pcat)' (#217) from feat/fiat-birlesik-canonical into dev
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
2026-07-14 07:11:53 +03:00
bf49900b57 fix(catalog): Fiat "Birleşik" stays the canonical view, not the raw pcat tree
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Correction to #216: "Birleşik" must fold the catalog into OUR canonical 20-bucket
structure (pcat + eper), not show parts-catalogs' raw tree. Only "Orijinal
Katalog" swaps to the native ePER tree for Fiat.

- treeSource → eper-pekidi for Fiat (Orijinal); canonical tab renders CanonicalView
- reverts #216's "canonical tab → pcat source tree"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 07:11:50 +03:00
ff96dcac1c Merge pull request 'feat(catalog): Fiat tabs → eper/pcat, drop extra switcher' (#216) from feat/fiat-eper-simplify into dev 2026-07-14 06:56:14 +03:00
5a3e1a2299 feat(catalog): map the two Fiat catalog tabs onto eper/pcat, drop the extra switcher
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Simplify the previous change: instead of a third source-switcher pill, reuse the
two tabs that are already there. For an eper (Fiat) vehicle:
  "Orijinal Katalog" → the native ePER tree (default)
  "Birleşik"         → parts-catalogs
Non-eper vehicles keep the original source/canonical tabs untouched.

- web: derive treeSource from browseMode when the vehicle has eper; canonical
  tab renders the pcat source tree (not CanonicalView) for eper vehicles
- removes the ePER/Diğer switcher, SOURCE_LABELS, and the activeSource state
Net −27 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 06:56:12 +03:00
24495d8970 Merge pull request 'feat(catalog): eper as default Fiat catalog source (pcat switchable)' (#215) from feat/fiat-eper-default into dev 2026-07-14 06:42:06 +03:00
de960a0dd4 feat(catalog): eper as the default Fiat catalog source, pcat one tap away
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Fiat dealers know the native ePER tree; parts-catalogs' broader-but-lazy folders
are where they hit gaps (a motor OEM missing under "Motor" — the reason we
crawled pekidi ePER). So for a multi-source decoded vehicle, default the
"Orijinal Katalog" view to eper-pekidi — fully populated with parts + diagrams —
instead of showing every source's roots mixed together.

- categories.service.getCategoryTree(vehicleId, source?): prefer eper-pekidi when
  present; ?source= overrides; cache keyed per source
- categories: getCatalogSources(vehicleId) → sources present, default (eper) first
- controller: tree/:id/sources endpoint + ?source query
- web: source switcher pill (ePER ⇄ Diğer) in source mode when >1 source;
  defaults to the first (eper). pcat never hidden, just one tap away.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 06:42:04 +03:00
cee11b5769 Merge pull request 'fix(vinpin): reject stale-breadcrumb decodes + harden matcher' (#214) from fix/vinpin-false-positive into dev 2026-07-13 12:09:49 +03:00
5697447988 Merge pull request 'fix(vinpin): reject stale-breadcrumb decodes (require SINCOM) + harden matcher' (#213) from fix/vinpin-false-positive into main 2026-07-13 12:09:15 +03:00
70d901f9f5 fix(vinpin): reject stale-breadcrumb decodes (require SINCOM) + harden matcher
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
A failed ePER VIN lookup ("vehicle not found") whose Cyrillic "не найден" alert
OCRs to garbage ("He HaaeHs") slipped the not-found check; the parser then
grabbed the operator's last-browsed catalog from the breadcrumb
("FIAT » TIPO-EGEA") and false-mapped it to a catalog — NM4131 landed on the
ancient "Tipo 1100-1370-1580 (1987-1993)", plus 2× PANDA. Every genuine decode
carries a SINCOM; the breadcrumb false positives never do.

- parser: isUsableParse now requires a SINCOM (a model alone is not a decode)
- matcher: modelTokens keeps 4-digit engine displacements (1100/1370/1580) —
  only 1950-2039 count as years — so ancient catalogs no longer collapse to
  ["TIPO"] and win the fewest-extra-tokens tiebreak
- matcher: recency tiebreak on equal score — never fall back to an ancient
  generation for a bare / year-less model

15 unit tests (parser + matcher) pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:08:40 +03:00
731ac93a55 Merge pull request 'public-api: katalog gezinme (VIN'siz araç ağacı) endpoint'leri /v1/catalog/* — marka→model→motor→kategori, API-key auth' (#212) from dev into main
Reviewed-on: #212
2026-07-10 12:44:09 +03:00
oyp
0e8b2ab294 public-api: katalog gezinme (VIN'siz araç ağacı) endpoint'leri /v1/catalog/* — marka→model→motor→kategori, API-key auth
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:27:36 +03:00
814b798b3f Merge pull request 'feat(internal-admin): extend limiti 90 → 3650 gün' (#211) from fix/extend-days-cap into main
Reviewed-on: #211
2026-07-09 11:27:23 +03:00
c51b7b4e78 feat(internal-admin): extend limiti 90 → 3650 gün
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Panelin 'Bonus süre ekle (goodwill)' aksiyonu 360 gün gibi süreler
gerektiriyor; 90 gün sınırı iş ihtiyacını karşılamıyordu. Üst sınır
3650 (10 yıl) parmak-kayması koruması olarak kalıyor. Panel tarafı
(sp) aynı limite güncellendi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:35:28 +03:00
ff81b244b7 Merge pull request 'fix: şerit-bazlı rate bütçesi' (#209) from dev into main 2026-07-09 10:01:55 +03:00
8dd2d223e9 fix(prefetch): şerit-bazlı kaynak bütçesi — fast lane açlığını bitir
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Fast worker canlıydı ama pcat'in 90/dk penceresini ana worker yiyordu → fast
işler sürekli 18s source-rate defer (BMW init'i slot bulamadı). checkSourceRate
artık şerit-bazlı: fast = total/3 (pcat 30), main = kalan (60); toplam
değişmedi → upstream yükü aynı. Cooldown pause'u da doğru worker'ı duraklatıyor.
Spec fast-queue davranışına güncellendi (17/17).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 10:01:47 +03:00
36ed37d5c0 Merge pull request 'feat: prefetch fast kuyruk' (#208) from dev into main 2026-07-09 09:13:34 +03:00
c8803ada35 feat(prefetch): ayrı FAST kuyruk — taze araçlar backlog'la yarışmasın
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Kök neden: ana kuyruk saatlerce üretim>tüketim rejiminde (children patlaması);
lifo'da sonradan eklenen HER iş bir öncekini gömer → taze araç init'leri bile
açlığa düşer (BMW 320i 58dk 0 parça). Fix: catalog-prefetch-fast kuyruğu +
ikinci worker (conc=4, 120/dk) — decode-tetiği ve fast:true işler (children
zinciri dahil) buraya akar; per-source redis rate gate iki worker'ı da ortak
sınırlar (upstream korunur). vehicles.service fast kuyruğa bağlandı.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 09:13:26 +03:00
ccc2cd75a5 Merge pull request 'feat: birleşikte görünüm değiştiriciler' (#207) from dev into main 2026-07-09 08:11:24 +03:00
f2a37b78cb feat(web): Birleşik görünümde grid/ağaç/kolon değiştiricileri çalışsın
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
CanonicalView viewMode prop'u: tree/columns için kanonik veriden sentetik
hiyerarşi (kova → alt-yaprak → gerçek kategoriler; sentetik düğümler children'lı
geldiğinden lazy-fetch tetiklenmez, gerçek kategoriler children'sız geçip normal
drill yapar; tpl-* placeholder'lar bu görünümlere alınmaz — grid'de kalır).
vehicles sayfasında CategoryViewToggle artık canonical'da da görünür; katalog
sayfası viewMode'u geçirir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 08:11:16 +03:00
0e2871c964 Merge pull request 'feat: katalog sayfası Birleşik toggle' (#206) from dev into main 2026-07-09 08:07:21 +03:00
116def2c79 feat(web): pl24 katalog sayfasına Birleşik/Orijinal toggle
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
/dashboard/catalog/:brand/:modelId sayfasında birleşik görünüm yoktu — backend
endpoint'i (/catalog/vehicles/:id/canonical) hazırdı ama web'e hiç bağlanmamıştı.
CanonicalView'a endpoint/catalogMode/brandName/variantSearch prop'ları eklendi
(varyant qs dahil); sayfaya vehicles/$id ile aynı toggle. Varsayılan Orijinal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 08:07:13 +03:00
bcf8bb38cd Merge pull request 'fix: kanonik sayaç + entry cap' (#205) from dev into main 2026-07-09 07:45:09 +03:00
d38bcaa36f fix(canonical): kova başlık sayacı subgroup'ları da saysın + entry cap 40/kova
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
(1) Placeholder'lar subgroup içinde olduğundan başlık "0 kategori" diyordu —
sayaç artık flat+subgroup birleşik unique. (2) Kalabalık markalar (pl24 BMW
elektrik 537 entry) görünümü boğuyordu — kova başına en-paylaşılan 40 entry
(vehicle_count desc).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 07:45:02 +03:00
25965717c3 Merge pull request 'fix: entries ambiguity' (#204) from dev into main 2026-07-09 07:28:15 +03:00
671e9af15b fix(canonical-backfill): entries GROUP BY'da name'i c.name ile nitele (parent join ambiguity)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 07:28:07 +03:00
da95b0484d Merge pull request 'canonical: şablon entry-seviyesi' (#203) from dev into main 2026-07-09 07:23:51 +03:00
5ed3959a12 feat(canonical): şablon ENTRY-seviyesi — decode anında tam gezinilebilir yapı
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Kullanıcının orijinal tasarımı: aynı (kaynak, marka)da kategori yapısı birebir
aynı — değişen yalnız parça kodları/şemalar. Önceki şablon sadece kova/alt-yaprak
BAŞLIKLARINI overlay ediyordu → taze araçta "Aydınlatma — 0 kategori". Yeni:
canonical_template_entries (migration 0031) — markanın paylaşılan kaynak-kategori
ADLARI (≥2 araç); backfill'de rebuild. overlayTemplate eksik adları disabled
placeholder olarak basar (unavailable=true, CategoryGrid zaten soluk+tıklanamaz
render ediyor) → decode anında her kova tam adlarıyla dolu; drill tamamlanınca
placeholder'lar gerçek satıra dönüşür.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 07:23:43 +03:00
eb92c5c5f8 Merge pull request 'fix: orijinal default' (#202) from dev into main 2026-07-09 07:12:00 +03:00
8a079d84f8 fix(web): Orijinal Katalog tekrar varsayılan — taze araçta boş kanonik kovalar çözülene dek
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 07:11:52 +03:00
1c4c896d64 Merge pull request 'feature/vin-ocr' (#201) from feature/vin-ocr into main
Reviewed-on: #201
2026-07-09 07:05:10 +03:00
3c6c27cb00 fix(vin-ocr): sunucu hata mesajını toast'ta göster
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
"Fotoğraf okunamadı" genel metni gerçek nedeni (örn. 503 "görselden şase
okuma şu anda kullanılamıyor", 400 "desteklenmeyen tür") yutuyordu — dev'de
OPENROUTER_API_KEY boş kaldığında teşhisi zorlaştırdı. ApiError mesajı artık
toast açıklamasında; 429 için Türkçe "çok fazla deneme" metni; PostHog
reason artık http_<status> taşıyor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 06:52:14 +03:00
8d11ae7400 feat: fotoğraftan şase okuma (VIN foto-OCR)
Dashboard şase kutusunun sağına kamera butonu: kullanıcı fotoğraf yükler
(mobilde kamera), görsel istemcide ~1800px'e küçültülüp JPEG'e çevrilir,
POST /vehicles/decode/ocr OpenRouter üzerinden vision modeline gönderir
(varsayılan google/gemini-2.5-flash; bulamazsa openai/gpt-5.4-nano yedeği),
dönen VIN kutuya yazılır. Decode HER ZAMAN kullanıcının "Şase Çöz"
tıklamasıyla başlar — OCR yalnızca yazma zahmetini alır.

Model seçimi 2026-07-09 canlı bake-off'una dayanır (Commons'tan gerçek VIN
fotoğrafları): flash plaka/etiketleri doğru okudu; flash-lite rakam bozdu,
Haiku 4.5 olmayan VIN uydurdu, OCR.space damgalı şasede 8→S hatası verdi.
Sunucu tarafı katı doğrulama (17 karakter, I→1 O→0 Q→9, aksan ayrıştırma)
"regex'ten geçen ama yanlış" VIN'leri keser; okunaksızsa vin:null döner.

- throttle: kullanıcı başına 10 istek/dk; görsel sınırı 5MB (mevcut multipart
  middleware'iyle uyumlu), sadece JPEG/PNG/WebP
- compose: OPENROUTER_API_KEY api VE worker bloklarına eklendi (worker çeviri
  işçisi bu anahtarı bekliyordu ama compose'a hiç enjekte edilmemişti),
  VIN_OCR_MODEL / VIN_OCR_FALLBACK_MODEL api bloğuna eklendi
- analitik: vin_ocr_used eventi (success/reason/confidence/duration_ms),
  query_source="ocr"
- i18n: search.ocr.* (tr+en)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 06:41:33 +03:00
cafe206001 Merge pull request 'Merge branch 'feature/widget-canonical' into dev' (#199) from dev into main 2026-07-08 06:48:39 +03:00
a803d9da10 Merge pull request 'fix(vinpin): query_logs muhasebesi — pending poll'lar failure satırı yazmasın' (#200) from fix/vinpin-query-log-accounting into main 2026-07-08 06:47:50 +03:00
Semih
8054e2faf3 fix(vinpin): query_logs muhasebesi — poll'lar failure satırı yazmasın
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Vinpin fallback'i failure logQuery'sinden ÖNCE denenir oldu:

- Pending decode'un her 3sn'lik frontend poll'u 'No catalog — identified
  as Fiat' failure satırı yazıyordu: vinpin'in 10sn'de ÇÖZDÜĞÜ tek Egea
  VIN'i 17 failure satırı üretti, ham 24h success -20.7pp okundu
  (2026-07-07, 4 kullanıcı/4 VIN, gerçek infra hatası 0).
- Resolved VIN'lerde her decode kalıcı olarak fail+success çifti
  yazıyordu (failure log vinpin lookup'tan önceydi ve negatif cache
  vinpin çözümünden haberdar değil).

Yeni muhasebe: VIN başına TEK coverage-gap failure satırı (ilk görüş,
enqueue anı — coverage backlog sinyali korunur); pending poll'lar hiç
loglanmaz; resolved decode tek success satırı loglar; not_found/failed
eskisi gibi caller'ın failure log'una düşer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 06:40:23 +03:00
7956fc9e9b feat: kategori sağlık paketi — dedup+index, prefetch throughput, v1 taxonomy, Birleşik default
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
(1) migration 0030: 57.780 duplike kategori merge (prod'da uygulandı, idempotent)
+ categories unique index NULLS NOT DISTINCT → decode retry duplike üretimi
KALICI olarak durdu (onConflictDoNothing artık NULL sınıflarında da çalışıyor).
(2) prefetch throughput: backlog %89 pcat + net-büyüyordu (82k→94k). pcat
20→90/dk (DataImpulse rotating gateway), pace 5s→1.5s, conc 3→8, global 180.
pl24/emex dokunulmadı (ban riski).
(3) GET /v1/taxonomy: araçtan bağımsız kanonik yapı kontratı (B2C navigasyon).
(4) Birleşik tekrar varsayılan görünüm (edge-case'ler çözüldü).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 06:26:54 +03:00
006cf1fd63 Merge pull request 'feat(widget): kanonik (birleşik) kategori görünümü' (#198) from feature/widget-canonical into main
Reviewed-on: #198
2026-07-06 11:47:33 +03:00
6fb27d9509 Merge branch 'feature/widget-canonical' into dev 2026-07-06 11:32:55 +03:00
394e71e269 feat(widget): kanonik (birleşik) kategori görünümü
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Widget artık /categories varsayılan kanonik cevabını render eder (view=raw
kaldırıldı): kaynak-bağımsız kova → alt-grup → yaprak kategori → parça gezinme.
Frame-tabanlı breadcrumb (her seviye kendi render'ını taşır), ortak gridEl/grid
çizici, leafItem drill'i /categories/:id ile parça+şema. Dashboard'daki
'Birleşik' varsayılanıyla aynı deneyim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:32:55 +03:00
454eab6b0e Merge pull request 'dev' (#197) from dev into main
Reviewed-on: #197
2026-07-06 11:10:03 +03:00
6585412932 Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
2026-07-06 11:02:55 +03:00
df04784ddb fix(widget): kategori ağacını ?view=raw ile çağır (API default kanonik oldu)
/v1/vehicles/:id/categories artık varsayılan kanonik kova görünümü döndüğü
için, ham ağaç şeklini render eden widget shell'i açıkça view=raw ister —
yoksa {canonical:{name},categories[]} şeklini {name,partCount,id} sanıp
kırılırdı. Kanonik-kova görünümünü widget'a getirmek ayrı iş.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:02:08 +03:00
239a46451d Merge pull request 'main' (#196) from main into dev
Reviewed-on: #196
2026-07-06 11:01:30 +03:00
d167c15c5e Merge pull request 'feature/public-canonical-categories' (#195) from feature/public-canonical-categories into main
Reviewed-on: #195
2026-07-06 11:01:04 +03:00
ae31a7b61a feat(jobs): kanonik backfill gecelik cron + artımlı (keep-fresh) mod
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Faz 2 — decode edilen yeni araçların kategorilerini materyalize tutar:
- Processor'a incremental mod: step-2 yalnız HENÜZ map satırı olmayan yeni
  isimleri sınıflandırır (tüm ~113k değil). Materialize (step-5) differential +
  inheritance NULL-only olduğundan, adı zaten map'li yeni kategori de id'sini
  alır — hiçbir şey atlanmaz. is_manual korunur. reset varsa yok sayılır
  (reset tam yeniden-eşleme demek).
- Gecelik cron: 05:00 İstanbul, { incremental: true }. Dev+prod'da güvenli
  (harici yan-etki yok, kendi DB'sine yazar). Lexicon iyileştirmesi sonrası
  tam yeniden-eşleme hâlâ ELLE { reset:true } global run.
- Dokümante edilen 'backfill trigger yok' boşluğunu kapatır; serving zaten
  anlık lexicon fallback yapıyordu, bu cron materyalizasyonu güncel tutar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:59:19 +03:00
101ee755f1 Merge branch 'feature/public-canonical-categories' into dev (Faz 1) 2026-07-06 10:52:12 +03:00
dc65c61440 feat(api): /v1 kategori ağacı varsayılan birleşik (kanonik) + source-strip
Faz 1 — public API kategori ucunu kanonik taksonomiye bağlar:
- GET /v1/vehicles/:id/categories artık VARSAYILAN olarak birleşik (kanonik)
  görünüm döner (kaynak-bağımsız 20 kova + alt-kategoriler,
  getCanonicalTree; materyalize %96,9 L1 + anlık lexicon fallback).
- ?view=raw ile ham kaynak ağacı (kaçış kapısı — plaka-kodu yapraklı yeni
  PSA/Renault kataloglarında birleşik zayıfsa).
- GÜVENLİK: iki görünüm de public-api.sanitize ile temizlenir — 'source'
  (upstream sağlayıcı: parts-catalogs/emex/pl24) ve 'linkWid' (upstream widget
  id'si) ARTIK SIZMAZ. Önceki ham-ağaç ucu bunları sızdırıyordu (Faz 1 widget
  işinde açılmış mevcut sızıntı, burada kapatıldı).
- Swagger: view param + iki görünümün şeması dokümante.
- 6 birim test (sanitizer beyaz-liste + source-drop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:52:03 +03:00
380ab60572 Merge pull request 'fix: kova doğal alt-kategori seviyesi' (#194) from dev into main 2026-07-06 10:15:59 +03:00
28720c7d7d fix(canonical): kova → doğal alt-kategori seviyesi (entry-children, subtree count)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Önceki hibrit sadece derin parça-yapraklarını + kök/dal girişlerini gösteriyordu;
kısmen drill edilmiş kovada ("Fren sistemi" subtree'de 39 parça) sistem-kökü
skip ediliyor + 0-parçalı kardeşleri düşüyordu → Fren'de 14 yerine 2. Yeni model
(buildCanonicalInput): her kovanın "entry container" köklerinin AYNI-KOVA
çocuklarını SUBTREE parça sayısıyla göster (drilled olsun olmasın). Audi Fren →
11 alt-kategori (Disk frenler/Fren Kaliyeri/El fren…), orijinal katalog gibi.
On-the-fly bucketing (materialize edilmemiş taze kategoriler için).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:15:52 +03:00
8b7bd56c7b Merge pull request 'fix: kova→alt doğrudan + subgroup render' (#193) from dev into main 2026-07-06 09:59:26 +03:00
7fb3608dea fix(canonical): kova→alt-kategori doğrudan (redundant "sistem" hop'unu kaldır)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
(1) Serving: "Fren sistemi" gibi sistem-container dalını çocuklarıyla değiştir
(Fren Kaliyeri/Disk frenler/Balata…) → kova doğrudan alt-parçaları içerir.
(2) Frontend: CanonicalView artık subgroups'u (2. seviye: Balata/Disk/Kaliper)
render ediyor — önce sadece flat categories gösteriyordu, alt-seviye işi hiç
görünmüyordu. Böylece Fren'e tıkla → alt-kategoriler doğrudan gelir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 09:59:19 +03:00
8ce387c0c5 Merge pull request 'fix: dal-giriş yaprak dahil' (#192) from dev into main 2026-07-06 09:42:33 +03:00
5a933fec9d fix(canonical-tree): dal-girişi filtresini yaprakları da kapsayacak şekilde düzelt
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Debriyaj sistemi / İç Aydınlatma Sistemi drill-edilmemiş YAPRAK (0 çocuk) —
önceki "çocuğu olmalı" şartı bunları dışlıyordu → hâlâ 0. Şart: mapped + kök-
değil + 0-parça + entry-point (parent farklı kova). Yaprak/dal fark etmez.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 09:42:25 +03:00
682f189e6e Merge pull request 'fix: dal-girişleri kanonik' (#191) from dev into main 2026-07-06 09:40:40 +03:00
f5d7a27ea3 fix(canonical-tree): drill-edilmemiş dal-girişlerini de göster (Debriyaj/Aydınlatma)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Sorun: aracın "Debriyaj sistemi"/"Aydınlatma Sistemi" kategorisi var + doğru
kovaya map olmuş ama parçası drill edilmemiş → hibrit sadece parça-yaprak +
drill-edilmemiş KÖK gösterdiğinden bu DALLAR düşüyordu → "0 kategori". Fix:
bucket'a map olan, çocuğu olan ama subtree'de parçası olmayan dal-girişlerini de
input'a ekle (parent farklı kova = entry point). Böylece kova boş görünmez,
tıklanınca on-demand drill olur (prefetch beklemez).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 09:40:33 +03:00
d997abc3d3 Merge pull request 'fix: prefetch decode fast-lane' (#190) from dev into main 2026-07-06 09:19:38 +03:00
e5fd44820a fix(prefetch): decode-tetiğini fast-lane (lifo) yap — backlog'u atla
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Sorun: yeni decode edilen araç prefetch'i normal öncelikle (5dk delay, fast yok)
enqueue ediliyordu → 82k'lık deep-drill backlog'unun ARKASINA düşüp hiç
işlenmiyordu → taze araçlarda kanonik view boş kalıyordu. Fix: fast:true + lifo
→ taze araç saniyeler içinde drill olur, eski children backlog'unu atlar.
Per-source rate gate upstream'i hâlâ korur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 09:19:30 +03:00
b5b846f3f2 Merge pull request 'feat: kanonik yapı şablonu' (#189) from dev into main 2026-07-06 08:47:06 +03:00
f25ef2d54f feat(canonical): marka/platform yapı şablonu — decode anında tam kanonik yapı
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
canonical_structure_template(source, brand, bucket, sub, vehicle_count): drill
edilmiş araçlardan her (kaynak, marka) için görülen kova/alt-yaprak kümesi.
Backfill sonunda yeniden üretilir (global run). Serving overlayTemplate ile
aracın ağacına markanın bilinen ama bu araçta henüz drill edilmemiş kova/alt-
yapraklarını BOŞ ekler → VIN decode olur olmaz FULL kanonik yapı görünür;
parçalar backfill ile dolar. migration 0029. vehicle + catalog akışı.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 08:46:59 +03:00
6eb7dc11a4 Merge pull request 'feat: hibrit canonical tree — tüm kovalar anında' (#188) from dev into main 2026-07-06 08:32:35 +03:00
7b1daffe97 feat(canonical-tree): hibrit — tüm üst kovalar decode anında görünür
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Sorun: bir araçta sadece TEK bir kategori (drill edilmiş olan) görünüyordu —
"herhangi parça varsa leaf-mode" mantığı drill-edilmemiş kökleri gizliyordu.
Fix: parça-tutan yapraklar + drill-edilmemiş kökler (subtree'sinde parça yok)
birlikte gösterilir. Kök yapı decode'da oluştuğundan tüm 20 kova ANINDA görünür;
parçalar backfill deep-drill ile dolar. vehicle + catalog akışı.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 08:32:28 +03:00
c5c569b941 Merge pull request 'fix: orijinal default + dedupe' (#187) from dev into main 2026-07-06 08:06:15 +03:00
013cd43e7e fix: Orijinal Katalog varsayılan + canonical duplike-kök dedupe
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
(1) Araç sayfası varsayılanı "Orijinal Katalog" (source) — canonical view'ın
edge-case'leri (duplike/drill-edilmemiş kökler) çözülene kadar.
(2) canonical grouping ad-bazlı dedupe: bazı decode'lar kökü iki kez üretiyor
("Motor, Motor"; Seat Ateca'da her kök 2×) → aynı adı en zengin kopyayla
(parça, sonra çocuk) birleştir. Önceki boş-kök filtresi GERİ ALINDI (drill-
edilmemiş kökleri de siliyordu = "2 kategori" regresyonu).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 08:06:08 +03:00
c6e5d06172 Merge pull request 'fix: boş kök gösterme' (#186) from dev into main 2026-07-06 07:54:47 +03:00
34d97b47ac fix(canonical-tree): boş kökleri (çocuksuz+parçasız) root-fallback'ta gösterme
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
emex bazen aynı kökü iki kez decode ediyor (biri boş) → "Motor → Motor, Motor"
gibi redundant başlık. Root-fallback artık sadece çocuğu VEYA parçası olan
kökleri gösteriyor. vehicle + catalog akışlarının ikisinde de.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 07:54:39 +03:00
dc5cf4039c Merge pull request 'canonical: 3 yanlış-kova override daha' (#185) from dev into main 2026-07-06 07:39:40 +03:00
6f8e9ff618 feat(canonical-lexicon): 3 yanlış-kova override daha (IC Motor prefix + türbo şarj)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
"motor soğutma"→Soğutma (2516, engine cooling — "motor" subsystem qualifier),
"türbo şarj"→Motor (684, "şarj"=charge Elektrik'e çekmiş), "tahrik kayışı"→
Triger (767, drive belt). pcat "IC Motor <sistem>" prefix örüntüsü. 32 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 07:39:32 +03:00
2581908cc4 Merge pull request 'canonical: phrase override yanlış-kova' (#184) from dev into main 2026-07-06 06:48:47 +03:00
6e556e198a feat(canonical-lexicon): phrase-override katmanı — adjective+head yanlış-kovalar
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Skordan önce yüksek-güven phrase kuralları: "<sistem> yağı$" → Yağlar (end-
anchor, "motor yağı filtresi/pompası" hariç), "(cam|silecek|kalorifer) motoru"
→ Elektrik, "zamanlama (dişli|zincir|kayış)" → Triger. ~1400 motor/şanzıman
yanlış-kovası + ~230 silecek-motoru + ~790 zamanlama düzelir. 29/29 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 06:48:40 +03:00
cec68a8cd9 Merge pull request 'canonical: SP kuyruk küçültme' (#183) from dev into main 2026-07-06 06:28:13 +03:00
75dbadcb47 feat(canonical-sublexicon): SP kuyruğunu küçültmek için yüksek-frekans alt-terimler
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Kuyruğun tepesindeki net-eşlenebilir adları otomatikleştir: gösterge/anten→
Modül, korna→Korna(yeni), silecek→Silecek(yeni), güneşlik→Tavan, havalandırma/
ısıtma/eşanjör→Kalorifer, soğutucu→Radyatör, absorber→Amortisör, dingil→Aks,
pim→Burç, kontrol kol→Salıncak, yağ tava/keçe/basınç→Yağlama, motor takımı→
Komple, iç kontrol→Vites. +2 yaprak (silecek/korna), migration 0028. 6/6 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 06:28:06 +03:00
046b8a8f4c Merge pull request 'canonical: emex düşük-marka deep-dive' (#182) from dev into main 2026-07-05 17:15:58 +03:00
1aaf2d8d8b feat(canonical): emex düşük-marka deep-dive terimleri
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
emex Dacia/Toyota/BMW/Kia eşleşmeyenlerinden tek-token distinctive terimler
(üst-lexicon token-bazlı): buharlastirici/kurutucu→sogutma, aktuator/selenoid/
anten/ses→elektrik, lambda/rolanti→yakit, kanat/kiris→panjur, burkma→susp. +
ilgili sub keyword'ler. 2-kelime adlar (su pompası) token-matcher'da eşleşmez,
atlandı → SP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:15:51 +03:00
33f58e14c6 Merge pull request 'canonical: zayıf kova sub tuning' (#181) from dev into main 2026-07-05 16:48:49 +03:00
db646d10f4 feat(canonical-sublexicon): zayıf kova tuning (yaglar/panjur/direksiyon/debriyaj)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
+7 yaprak (migration 0027): yag-sistemi (yağlama/pompa/çubuk), panjur kaput/
tavan/şasi/cam/kanat, direksiyon simit/dişli/hidrolik/elektronik + debriyaj
serbest-bırakma. En düşük kovaları (Yağlar %10, Panjur %17, Direksiyon %38)
yükseltir. 150 yaprak toplam.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:48:42 +03:00
a9a40e4587 Merge pull request 'canonical: 2-level serving' (#180) from dev into main 2026-07-05 16:40:46 +03:00
2d5e2d04a9 feat(canonical): 2-level serving — taksonomi endpoint + araç sub-gruplama
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
GET /categories/canonical/taxonomy: kova→alt-yaprak + populasyon sayısı (B2C API
kontratı). getCanonicalTree (vehicle + catalog) artık subLeaves geçip her kovayı
alt-gruplara böler (materialize + on-the-fly sub fallback). jant-tire slug fix
(jant-lastik üst-kova ile çakışıyordu). Kategori sub-kapsama: Filtre %88, Fren/
Aydınlatma %74, Motor/Süsp/Elektrik %60-66. Eşleşmeyen → Genel/SP manuel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:40:39 +03:00
cf11379b49 Merge pull request 'canonical: 2. seviye alt-kategori' (#179) from dev into main 2026-07-05 16:32:32 +03:00
9dd3b2c858 feat(canonical): 2. seviye alt-kategori mapping (otoparcasan-türevi)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Kendi kanonik yapımıza tam geçiş — B2C API için alt-kategori. migration 0026:
canonical_categories'e 143 alt-yaprak (parent_id=üst kova) + categories/
category_canonical_map'e canonical_subcategory_id. canonical-sublexicon.ts:
bucket başına satılabilir parça yaprakları + Türkçe varyant (kaliyer/hidroliği)
+ Bağlantı&Donanım catch-all. Backfill artık isim→kova→alt-yaprak deterministik
materialize ediyor (inheritance dahil). Eşleşmeyen = null → SP manuel. Testler +.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:32:25 +03:00
5ac92556e3 Merge pull request 'canonical: Opel inheritance yanlış-pozitif fix' (#178) from dev into main 2026-07-05 15:18:26 +03:00
458603fb98 fix(canonical-backfill): model/yıl seçici düğümlere parent-inheritance verme
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Opel model-öncelikli: "X 2004-2014" model düğümü parent'ından (MOTOR VE DEBRIYAJ)
Debriyaj miras alıyor, sonra jenerik çocukları (BOYALAR/MÜTEFERRİK/SIZDIRMAZ) de
ondan Debriyaj miras alıyordu → yanlış eşleme zinciri. Inheritance artık yıl-
aralıklı + ^[a-z]{1,3}\d{2} model düğümlerine miras VERMEZ (null kalır, cascade
kırılır). Kod/plaka yaprakları (X1318_/20-201A) bu desenlere uymaz → Renault/
Hyundai etkilenmez. Sadece Opel 58 + Nissan 1 düğüm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:18:18 +03:00
f63ccf0aef Merge pull request 'canonical: MAN kalan platform prefix' (#177) from dev into main 2026-07-05 14:46:16 +03:00
5900b8d399 feat(canonical-lexicon): MAN kalan otobüs platform prefixleri (SG/SL/SR/SUE/UEL/RH/NM…) + model adları
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:46:09 +03:00
57d9ff2e30 Merge pull request 'canonical: MAN EL/GS/LE platform kod' (#176) from dev into main 2026-07-05 14:31:02 +03:00
a10654f210 feat(canonical-lexicon): MAN EL/GS/LE/MAZ/MFTPL platform kodlarını da yapısal yap
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:30:54 +03:00
d04d27d221 Merge pull request 'canonical: MAN deep-tune' (#175) from dev into main 2026-07-05 14:16:46 +03:00
86a08dbe12 feat(canonical-lexicon): MAN deep-tune — otobüs/koçbuilder model adları yapısal
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
MAN neredeyse tamamen model-öncelikli (LION'S CITY, SKYLINER/CITYLINER/TOURLINER,
NL/ND/NG/MAN platform kodları, AUFBAU koçbuilder). classifyNode MAN_MODEL_CODE:
^(nd|ng|nl|ngt|man) \d + distinctive model kelimeleri → structural. Truck-kod
^\d{4,5} KULLANILMADI (2800 emex mapped bozardı). Ayrıca LPG-model yanlış-pozitif
(NL 205 M11 LPG→Yakıt) düzelir. elektr→elektrik, aggregate→motor. 27/27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:16:38 +03:00
c4608a8421 Merge pull request 'canonical: Kollar/Ön Takım + conta strong' (#174) from dev into main 2026-07-05 13:49:06 +03:00
c88b6b45c5 feat(canonical-lexicon): Kollar/Ön Takım→süspansiyon; conta→motor strong
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
emex "Kollar"(salıncak kolları)/"Ön Takım"(aks) → süspansiyon (kollar/takim
weak). "conta" motor strong'a alındı ki "conta takımı"→motor kalsın (takim ile
tie'ı kazansın). 26/26 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:49:01 +03:00
0517a57021 Merge pull request 'canonical: emex servis sayfa junk' (#173) from dev into main 2026-07-05 13:45:33 +03:00
e42345f35b feat(canonical-lexicon): emex servis sayfalarını (Servis Aralıkları/Ek Onarımlar) junk yap
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:45:28 +03:00
dce3a4f332 Merge pull request 'canonical Faz 3: emex terimleri' (#172) from dev into main 2026-07-05 13:31:56 +03:00
ee7925c960 feat(canonical-lexicon): Faz 3 emex yüksek-frekans terimleri
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
emex'in en sık eşleşmeyen adları (100-250x, tüm araçlarda paylaşılan):
stabilizer/absorber→suspansiyon; esanjor/kondansator/ventilator/kompresor/
sogutucu/uflec→sogutma; hali/halilar→ic-doseme; kilit/yagmurluk→dis-kaporta;
stepne→jant-lastik. Jenerik (BOYALAR/İZOLATÖRLER/Montaj) bırakıldı. 25/25.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:22:06 +03:00
527f69dc1b Merge pull request 'canonical: son cila edge-case' (#171) from dev into main 2026-07-05 12:37:00 +03:00
50a92c78cc feat(canonical-lexicon): son cila — koltuğu/takoz/stabilizatör edge-case'leri
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
koltugu (koltuk k→ğ eki)→ic-doseme, stabilizator→suspansiyon strong, takoz/
takozu→suspansiyon weak. Küçük markaların (Smart/Mini) kalan gerçek boşluklarını
kapatır. Kalan eşleşmeyenler artık junk/generic/veri-kalitesi (pratik maksimum).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:36:57 +03:00
b6a6893f6f Merge pull request 'canonical: küçük markalar maksimum kapsama' (#170) from dev into main 2026-07-05 12:29:59 +03:00
db4b85da8e feat(canonical-lexicon): küçük/düşük markalar için maksimum kapsama terimleri
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Tüm sub-90 markaların global eşleşmeyen adlarından net olanlar (çoğu İngilizce,
markalar arası): diagnostic/entertainment/bilgisayar/elektrigi→elektrik,
heater/ventilation→sogutma, braking→fren, sunroof/exterior/locking→dis-kaporta,
restraint/occupant/konfor→ic-doseme, mekanik→motor, yapistirici→yaglar. 24/24.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:29:57 +03:00
e428a82bd5 Merge pull request 'canonical: Ford + BMW fix' (#169) from dev into main 2026-07-05 12:13:30 +03:00
44987e7e7e feat(canonical-lexicon): Ford + BMW fix'leri
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
BMW (~%81): elektronik terimleri → elektrik (audio/navigasyon/elektronik/
iletisim/multimedya/cruise), "ışık"→aydınlatma; servis/doküman sayfaları →
junk (teknik doküman/servis ve onarım/value parts). Ford (~%74): trans
varyantları speed/spd→sanziman, motor varyantları diesel/dohc→motor, gövde→
panjur. 23/23 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:07:09 +03:00
675fab0c79 Merge pull request 'canonical: MAN kamyon model kodları' (#168) from dev into main 2026-07-05 12:00:05 +03:00
1fcbfdf5d1 feat(canonical-lexicon): MAN kamyon model kodları yapısal + parça terimleri
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
MAN %32 = Opel gibi metrik yanılsaması (kamyon model tanımlayıcıları
denominatörde). classifyNode: ^\d{1,2}[./]\d ("12.155","10/11.150") + ^\d{2}t
("18T HOCL-SD") → structural (^\d{4,} KULLANILMADI — 362 mapped satırı bozardı).
Parça terimleri: salter/verici→elektrik, tahrik→sanziman (ticari araç + genel).
21/21 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:00:03 +03:00
afd92e90b6 Merge pull request 'canonical: Mercedes uzun-kuyruk parça adları' (#167) from dev into main 2026-07-05 11:31:50 +03:00
347d9f2ddb feat(canonical-lexicon): Mercedes uzun-kuyruk parça adları
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Mercedes fragmented long-tail'inin eşlenebilir kısmı (çoğu İngilizce, diğer
markalara da yarar): propeller→sanziman, radiator→sogutma, roof/wall/underpan→
panjur, door/window/kapak→dis-kaporta, timing→triger, carburetor→yakit,
pedal→fren, ortu→ic-doseme. Doküman: preliminary remarks/genel bakış tablosu→
junk. Genuinely jenerik (AYAR/GENEL PARÇALAR/DONANIM/CONTROL) Diğer'de kalır. 20/20.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:31:48 +03:00
51eec60c6c Merge pull request 'fix: MB katalog-kodu regex çıplak-koda sabit' (#166) from dev into main 2026-07-05 11:08:50 +03:00
f1f36d8cf1 fix(canonical-lexicon): MB katalog-kodu regex'ini çıplak-koda sabitle
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
^[cd]\d{3,} fazla genişti — "D765300(22H), LS 7 F, MB SERVO DİREKSİYON" gibi
parça bilgisi taşıyan düğümleri yapısal yapıp Direksiyon eşleşmesini bozuyordu
(Mercedes ham kapsama %79→%49). $ ile sabitle → sadece çıplak kod (C442,
C105011). Açıklamalı düğümler keyword'le eşleşmeye devam eder. 19/19 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:08:47 +03:00
bf259f3d79 Merge pull request 'canonical: Mercedes C/D katalog kodları yapısal' (#165) from dev into main 2026-07-05 10:52:44 +03:00
25cf0fa670 feat(canonical-lexicon): Mercedes C/D katalog kodları yapısal + doküman sayfaları çöp
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Mercedes'in düşük kapsaması büyük ölçüde metrik yanılsaması: 175 dağınık kök,
C/D katalog-kodu düğümleri (C371211, C442, D308802(811),308,COWL) yapısal ama
classifyNode kaçırıyordu → denominatörde. Ekle: ^[cd]\d{3,} → structural;
"SA LİSTESİ"/"…resim açıklama sayfası" → junk. 19/19 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 10:52:42 +03:00
c9f96f8666 Merge pull request 'canonical: Opel yazım varyantları + serving yapısal-atla' (#164) from dev into main 2026-07-05 10:30:31 +03:00
82793a8257 feat(canonical): Opel model-öncelikli — yazım varyantları + serving yapısal-atla
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Opel gerçek parça-kat kapsaması zaten %85; %38 model-kodu köklerin (yapısal)
denominatörde olmasından. Lexicon: sanzuman/disli→sanziman, suspensiyon/dingil→
suspansiyon (Opel yazımları). Serving: groupCategoriesByCanonical yapısal/çöp
düğümleri "Diğer"e sokmadan atlar (Opel model kökleri Birleşik'te görünmez).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 10:25:46 +03:00
51032342b8 Merge pull request 'canonical: parent-inheritance (kod-yaprak → parent kovası)' (#163) from dev into main 2026-07-05 10:16:21 +03:00
aba1bfc126 feat(canonical-backfill): parent-inheritance (kod-yaprak → parent kovası)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Kendi adı eşleşmeyen kategori (Renault P5 X1318_ plaka kodları, figür
illustration'ları) parent'ının kanonik kovasını miras alır ("Alternatör"→
Elektrik). Materyalizasyondan sonra birkaç seviye kaskad. Re-scrape gerektirmez.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 10:11:22 +03:00
3fb7fa1aa3 Merge pull request 'fix(pl24): Hyundai/Kia illustration açıklaması' (#162) from dev into main 2026-07-05 10:05:10 +03:00
c70b9b61b9 fix(pl24): Hyundai/Kia illustration açıklamasını kod-yaprağa ekle
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Upstream doğrulandı: illustration satırında td[0]=figür kodu ("20-201A .1"),
td[1]=açıklama ("SUB ENGINE ASSY") — parser ilk td'yi (kod) alıp açıklamayı
düşürüyordu. Artık ilk td figür-koduysa (\d\d-\d\d\d[A-Z]?) açıklamalı sonraki
td eklenir → "20-201A .1 SUB ENGINE ASSY", canonical eşleşir (engine→motor).
Diğer markalar etkilenmez (yalnız kod-only ilk td'de tetiklenir). 10/10 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 10:02:01 +03:00
6bbc32d9c6 debug(pl24): TEMP Hyundai illustration rows + td dump 2026-07-05 09:55:30 +03:00
05951f9853 debug(pl24): TEMP Hyundai/Kia raw HTML row log (katalogJob Faz 2 upstream doğrulama) 2026-07-05 09:51:46 +03:00
50c7d91707 Merge pull request 'canonical Faz 2: İngilizce kategori adları' (#161) from dev into main 2026-07-05 09:30:36 +03:00
4b6642a78a feat(canonical-lexicon): Faz 2 — İngilizce kategori adları
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
pl24 Asyalı markaları (Hyundai/Kia/Nissan/Toyota) ve çevrilmemiş emex/pcat
yaprakları İngilizce kategori adı taşıyor (ENGINE/BODY/BRAKE/TRANSMISSION…) →
%0 kapsama. Sistem kelimeleri strong (engine→motor, brake→fren, transmission→
sanziman, electrical→elektrik…), konum kelimeleri weak (body/chassis/floor/axle)
ki "BODY ELECTRICAL"→elektrik olsun. 17/17 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 09:30:34 +03:00
ef8240a012 Merge pull request 'canonical: Jant/Lastik + Aksesuar kovaları + pcat kararları' (#160) from dev into main 2026-07-05 09:24:49 +03:00
b547140eb1 feat(canonical): Jant ve Lastik + Aksesuar kovaları + pcat kararları
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
migration 0025: 2 yeni kanonik kova (jant-lastik #19, aksesuar #20).
lexicon: jant/lastik/tekerlek/göbek→jant-lastik, aksesuar→aksesuar (catch-all,
en düşük öncelik). Kullanıcı kararları: Hava Sistemi→Soğutma (hava weak),
Bakım parçaları→Filtreler (bakim weak). "hava yastığı"→direksiyon için yastigi
strong'a alındı. 16/16 test. Yorumlar 18→20 kova güncellendi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 09:24:29 +03:00
5abdc6f628 feat(canonical-lexicon): Faz 1 pcat kök eşlemeleri
pcat paylaşımlı taksonomisinin eşleşmeyen köklerinden net olanları ekledim:
jenerator→elektrik, yaglama→yaglar, karo/cerceve→panjur-panel, aktarim→
sanziman, aks→suspansiyon(weak). sasi weak (süspansiyon+şasi net kalsın).
15/15 test. Kova-kararı gerektirenler (jant-lastik, aksesuar) katalogJob.md'de.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 09:18:58 +03:00
efa395b7b5 Merge pull request 'web: birleşik görünüm default + Orijinal Katalog seçeneği' (#159) from dev into main 2026-07-05 09:09:39 +03:00
b96ca78942 feat(web): birleşik görünüm varsayılan + "Orijinal Katalog" seçeneği
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Araç sayfasında browse varsayılanı "canonical" (Birleşik) oldu; toggle
"Birleşik | Orijinal Katalog" — Orijinal Katalog seçilince ham OEM ağacı
gösterilir. Kullanıcı isteği: birleşik katalog default, orijinal opsiyonel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 09:09:37 +03:00
5a74a0e552 Merge pull request 'canonical: kova-adı lexicon fix (süspansiyon/elektrik/aydınlatma)' (#158) from dev into main 2026-07-05 09:05:50 +03:00
643e1d923c fix(canonical-lexicon): kova adının kendisini anahtar kelime yap
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Kök tam da kanonik başlıkla anılınca ("Süspansiyon, şasi", "Elektrik",
"Aydınlatma") lexicon'da o kelime yoktu → "Diğer"e düşüyordu. suspansiyon/
elektrik/aydinlatma kendi kovalarına strong olarak eklendi. Serving on-the-fly
fallback aynı lexicon'u kullandığı için taze araçlarda backfill'siz düzelir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 09:00:33 +03:00
dedcf71419 Merge pull request 'dev' (#157) from dev into main
Reviewed-on: #157
2026-07-05 08:43:44 +03:00
07d4bf7e69 feat(canonical-lexicon): 3-harfli kök stem çekimlerini ekle
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
"yag/mil/far/aku" gibi 3-harfli kökler ≥4 prefix kuralı yüzünden çekimli
hallerine (yagi/yaglar, mili, farlar/fari, akusu) eşleşmiyordu — "HPS yağı",
"Kardan Mili", "araba aküsü" "Diğer"e düşüyordu. Çekimleri açıkça ekledim +
sanziman'a "kardan". Global düşürmek riskli (sis→sistemi). 13/13 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:42:06 +03:00
cc1d22a7d4 feat(catalog): kök-seviye canonical fallback + araç sayfası "Birleşik" toggle
Backend: groupCategoriesByCanonical'a requireParts opsiyonu; her iki serving
metodu artık parça-içeren yaprak yoksa (taze/drill-edilmemiş araç) kök
kategorileri kovalayarak boş dönmüyor.

Frontend: araç sayfasına "Katalog | Birleşik" toggle'ı; Birleşik seçilince
CanonicalView aracın kataloğunu 18 kanonik başlık altında gösterir, her kova
CategoryGrid ile normal drill'e bağlanır. canonical_view_opened event'i.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:28:07 +03:00
af2b35e6e2 feat(catalog): pl24 catalog-vehicle canonical gezinme variantı
GET /api/catalog/vehicles/:id/canonical → catalog_vehicle_id akışının canonical
karşılığı; pl24 markaları (VW/Opel/Renault) emex/pcat ile aynı 18 başlıktan
gezilir. Parça sayısı category_id ile join'lenir (pl24 parçaları çoğu zaman
vehicle_id taşır, catalog_vehicle_id değil). Gruplama mantığı iki servisin
paylaştığı canonical-grouping.ts helper'ına çıkarıldı (categories.service da
bunu kullanacak şekilde refactor edildi).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:08:59 +03:00
3c26f00a19 feat(categories): canonical taksonomiyle birleşik gezinme endpoint'i
GET /categories/canonical → 18 kanonik üst kova (browse landing).
GET /categories/canonical/:vehicleId → aracın parça-içeren leaf kategorilerini
18 kova altında gruplar; VW ve Opel gibi kökten farklı OEM ağaçları tek/aynı
başlık setinden gezilir. Kovalama materyalize canonical_category_id'yi
kullanır, son backfill'den sonra decode edilmiş kategoriler için ortak
canonical-lexicon ile on-the-fly fallback yapar; kovasız olanlar "Diğer"e düşer.
foldName() NORMALIZE_SQL'in JS karşılığı olarak eklendi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:58:17 +03:00
cd66947303 feat(canonical-backfill): reset/prune modu (global re-run için)
reset:true → non-manual keyword katmanını sıfırdan kurar: kapsamdaki
materyalize id'leri NULL'lar + stale non-manual map satırlarını siler,
sonra yeniden eşler. Manuel satırlar korunur. Sadece GLOBAL run'da (marka
scope'suz) geçerli — map isim-düzeyi/marka-bağımsız olduğu için marka-scope'lu
silme başka markaların paylaşılan eşlemelerini bozardı. Böylece lexicon/
sınıflandırıcı değişince eski eşlemeler dangling kalmaz.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:36:35 +03:00
c3d0dbaa0b fix(canonical-backfill): ">>" marker'ını yapısal sayma
Gerçek VW parça adları geçerlilik-tarihi marker'ı taşıyor ("pencere camlari
d >> - 14.07.2019"); bare ">>" onları yanlışlıkla yapısal işaretliyordu (VW
kapsamı düşüyordu). Gerçek VIN düğümlerini zaten VIN_LIKE yakalıyor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:29:29 +03:00
8b88ccd238 feat(canonical-backfill): ağırlıklı skorlama + genişletilmiş lexicon
- Belirsizlik skorlaması: strong head-noun ×3 / weak ×1; kazanan skor
  marjıyla belirlenir, ≤1 marj = ambiguous (eski eşit-sayı tie-break'i
  fazla ambiguous işaretliyordu). confidence marja göre (0.5–0.95).
- Lexicon genişletildi: egzoz/egz/konvertor/katalitik/susturucu (yakıt),
  karoser (panjur-panel), bcm/ecu (elektrik) + strong/weak ayrımı.
- classifyNode sıkılaştırıldı: VIN markerları (wvwzzz, >>), bare motor/
  sipariş kodları (z16dt[lfu]) artık yapısal.
- map.confidence artık per-eşleşme marjdan yazılıyor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:24:42 +03:00
754f1af61c fix(canonical-backfill): brand/source filtresini inArray ile kur
`= ANY($1)` postgres-js'te parametreyi Postgres array olarak bağlamıyor
("op ANY/ALL (array) requires array on right side") → job'lar fail'liyordu.
inArray() IN-listesine açar, sürücü-güvenli.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:12:49 +03:00
0ae7c3ceb6 fix(worker): canonical-backfill Worker'ını worker.ts'e bağla
Önceki commit'te (7b5e678) src/worker.ts git add kapsamı dışında kaldı
(apps/api/src/jobs/ eklendi ama worker.ts src/ altında). Queue/processor
deploy oldu ama tüketici Worker yoktu → enqueue edilen job'lar 'waiting'de
takıldı. Bu commit Worker instance'ını ekler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:02:37 +03:00
7b5e678c55 feat(catalog): kanonik taksonomi şeması + backfill job'ı
Kaynak/marka-bağımsız tek parça taksonomisi. Ham OEM ağaçları (categories.parent_id
+ pl24/emex/pcat aynaları) dokunulmadan kalır; kanonik omurga onlara dik.

- migration 0024: canonical_categories (18 üst kova seed) + category_canonical_map
  (isim-düzeyi eşleme) + categories.canonical_category_id kolonu
- canonical-lexicon.ts: iki-aşamalı eşleme (classifyNode: parça/yapısal/çöp;
  mapToCanonical: 18-kova keyword). VW+Opel pilotunda kanıtlandı — Opel ağacının
  %87'si model-seçici düğüm, bunlar kovaya girmez.
- canonical-backfill BullMQ job'ı (on-demand): distinct foldlanmış adları
  sınıflandırır, category_canonical_map'e upsert eder (is_manual korumalı),
  categories.canonical_category_id'yi join ile materyalize eder. brands/sources/
  dryRun parametreli.
- 8 unit test (classifyNode/mapToCanonical)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:54:24 +03:00
d2f42af935 Merge pull request 'dev' (#156) from dev into main
Reviewed-on: #156
2026-07-04 06:42:02 +03:00
ec95ab025d Merge branch 'feature/api-key-admin' into dev
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
2026-07-04 06:26:44 +03:00
8f6894eecb feat(api): api-key admin uçları — PATCH güncelleme, Redis kota usage, global liste
Süper Panel'den tam anahtar yönetimi için:
- GET /internal/admin/api-keys artık userId/email'siz TÜM anahtarları
  kullanıcı e-postasıyla birlikte döner
- GET /internal/admin/api-keys/usage — günlük decode kotasının anlık
  Redis durumu (limit/used/remaining), ids ile filtrelenebilir
- PATCH /internal/admin/api-keys/:id — name, dailyDecodeLimit(null=varsayılan),
  domains (yalnız publishable), rateLimitMax(null=plugin varsayılanı),
  expiresInDays(null=süresiz)
- PublicApiQuotaService internal-admin'e provider olarak eklendi

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 06:20:05 +03:00
123b701ed7 Merge branch 'feature/pcat-jwt-resilience' into dev 2026-07-03 18:33:48 +03:00
cd30278ad9 feat(pcat): JWT capture proxy-outage resilience + pool cap
JWT sitelerinin (parts-catalogs v3 widget'ını gömen partner mağazalar) sağlık
incelemesinden çıkan iki iyileştirme:

1) Direct-capture fallback (PCAT_CAPTURE_ALLOW_DIRECT, default açık): proxied
   capture tünel/connect hatasıyla ölürse (Floxy bakiye 402 / tünel down,
   DataImpulse portları ölü — 2026-06-11 ve 2026-07-03'te yaşandı) aynı siteyi
   sunucu IP'sinden DIRECT yeniden dener. Güvenli: TWS- token IP-bound DEĞİL +
   capture düşük frekans (≤MAX_POOL_SIZE refresh/~8.5dk). Doğrulandı: Floxy down
   iken direct 1.4s'de gerçek TWS- token yakaladı → warm pool artık tam proxy
   kesintisinde bile açlığa girmez.

2) Sert havuz üst-sınırı (enforcePoolCap): refresh yolundaki slot-muhasebe
   yarışı havuzu MAX_POOL_SIZE=5'in üstüne şişiriyordu (prod'da 32-39 gözlendi
   → gereksiz capture yükü + partner-site ban riski). Her push'tan sonra en eski
   slot(lar) çıkarılıp cap uygulanıyor, timer'ları temizleniyor.

6 JWT sitesinin hepsi canlı (direct 1.3-2s) — liste budanmadı. Birim testler +
canlı Floxy-down doğal deneyiyle doğrulandı.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:33:43 +03:00
f586b2f9c2 fix(api): widget şema görseli schemaPics[0].url okusun
schemaPics öğeleri {id,url,label} nesneleri; src'ye nesnenin kendisi
verilince kırık görsel ikonu çıkıyordu. + yüklenemeyen görsel gizlenir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:09:38 +03:00
da2eaecd47 fix(api): public API parça cevabını beyaz-listeye indir + widget pozisyon alanı
- Kategori-detay parçalarında iç alanlar (source=upstream sağlayıcı adı,
  presel, ham price/currency, catalogVehicleId...) public API'den sızıyordu —
  decode kontratı disipliniyle beyaz-liste: id/name/description/oemCode/
  position/quantity/remark/unavailable/categoryId
- Widget parça tablosu positionNumber yerine gerçek alanı (position ??
  hotspotIndex) okuyor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:05:21 +03:00
d46a463076 fix(api): widget Sorgula butonu MouseEvent'i candidate sanıyordu
onclick handler'ına doğrudan decode verilince click event'i candidate
parametresine düşüyor, sunucu da onu aday anahtarı sanıp 'araç seçimi
zaman aşımı' dönüyordu. Sarmalayıcı + typeof string guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:00:56 +03:00
e952687710 fix(api): widget loader.js global response-interceptor'a takılmasın
String dönüş {success,data} JSON'una sarılıyordu; Content-Type JS ama gövde
JSON olunca tarayıcı ERR_BLOCKED_BY_ORB ile script'i blokladı. @Res ile ham
send'e geçildi (shell zaten öyleydi).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:55:52 +03:00
ae61ac055e feat(api): gömülebilir katalog widget'ı (Faz 2 POC)
- GET /widget/loader.js: müşterinin yapıştırdığı tek script — iframe kurar,
  postMessage köprüsü (yükseklik + sase:partClick/sase:decoded olayları),
  parça tıklamasını data-part-click-url şablonuna ({code},{brand}) yönlendirir,
  window.saseWidget.onPartClick/onDecode callback'leri; CORP cross-origin
  (helmet same-origin varsayılanı müşteri sitesinde script'i bloklardı)
- GET /widget?key=sase_pk_...: iframe shell — vanilla JS katalog uygulaması
  (VIN formu, çok-aday seçimi, kategori grid + breadcrumb, parça tablosu,
  şema görseli, açık/koyu tema). Domain kilidi = anahtarın metadata.domains
  listesinden üretilen frame-ancestors CSP'si (tarayıcı zorlar); publishable
  olmayan anahtar 403; X-Frame-Options bu cevapta kaldırılır
- internal-admin: publishable:true + domains[] ile sase_pk_ anahtar üretimi
- api.sase.tr kısa yolu: /widget* → /api/widget*

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:50:29 +03:00
acadd84e4a fix(api): createApiKey expiresIn saniye cinsinden (ms değil)
30 günlük pilot anahtarı üretimi 'expiresIn is larger than the predefined
maximum value' hatasıyla düşüyordu — plugin expiresIn'i saniye bekliyor,
gün→ms çevriliyordu.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:27:59 +03:00
773e9c35fa feat(api): public API v1 katalog endpoint'leri
- GET /v1/vehicles/:vehicleId/categories — kategori ağacı (rollup parça sayılı)
- GET /v1/vehicles/:vehicleId/categories/:categoryId — kategori detayı + parça
  listesi + şema görselleri (lazy drill/heal aynen çalışır)
- GET /v1/vehicles/:vehicleId/search?q= — araç kataloğunda serbest arama
Hepsi gezinme sayılır, decode kotasından düşmez; Swagger anotasyonlu.
Dashboard'daki kuralla aynı: decode edilmiş araçlar paylaşımlı, varlık
kontrolü 404 için.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:25:38 +03:00
06f247beb7 feat(api): public API docs (Swagger) + api.sase.tr kısa yolları
- Swagger/OpenAPI yalnız PublicApiModule'ü kapsar (iç dashboard yüzeyi spec'e
  girmez); UI /api/v1/docs, JSON /api/v1/docs-json; bearer auth + kota
  açıklamaları TR
- api.sase.tr host'unda /v1/* → /api/v1/* ve /docs* → /api/v1/docs* rewrite —
  müşteriye dokümante edilen taban https://api.sase.tr/v1
- Controller'lara ApiOperation/ApiBody/ApiOkResponse anotasyonları (decode
  tek-eşleşme + çok-aday şemaları, OEM xref)
- Coolify: api.sase.tr dev app'in api servisine ikinci domain olarak eklendi
  (docker_compose_domains)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:40:17 +03:00
c9c33d58e1 Merge branch 'feature/public-api' into dev 2026-07-03 16:15:30 +03:00
7a4fe51d09 feat(api): public API POC — API-key auth + /api/v1 decode & OEM xref
Kapalı-beta public API temeli (rapor: api-widget-analizi-2026-07-03):
- better-auth apiKey plugin (defaultPrefix sase_, enableMetadata, anahtar
  başına 120 istek/dk); enableSessionForAPIKeys KAPALI — anahtar dashboard
  oturumu yerine geçmez, cookie AuthGuard davranışı değişmez
- apikeys tablosu (migration 0023, plugin şemasının birebir karşılığı)
- ApiKeyGuard: Authorization Bearer / x-api-key → verifyApiKey → kullanıcı
  yüklenir (status kontrolü), request.user + request.apiKey doldurulur
- /api/v1/vin/decode: mevcut slim kontrat + aday akışı; günlük başarılı-decode
  kotası (Redis, TR günü, aynı VIN idempotent; limit: key metadata
  dailyDecodeLimit → PUBLIC_API_DAILY_DECODE_LIMIT → 100) ve
  X-Decode-Quota-* header'ları
- /api/v1/oem/:code: P çapraz-referans (kotasız)
- internal/admin/api-keys: x-internal-token ile anahtar üret/listele/kapat
  (düz metin yalnız create cevabında)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:15:17 +03:00
4614d79d77 Merge pull request 'dev' (#155) from dev into main
Reviewed-on: #155
2026-07-01 18:15:57 +03:00
371aed3645 chore(build): wire VITE_GOOGLE_ADS_* build args for conversion tracking
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:11:45 +03:00
98c21fa30e feat(web): Google Ads conversion tracking (gtag + Enhanced Conversions)
Add lib/google-ads.ts (lazy gtag init, gclid capture, Enhanced Conversions,
trackGoogleAdsConversion). Fire sign-up conversion on email registration and
purchase conversion on Stripe success. Gated on VITE_GOOGLE_ADS_* env; no-op
when unset. Mirrors the Meta Pixel/CAPI pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:11:44 +03:00
5b640988c4 Merge pull request 'dev' (#154) from dev into main
Reviewed-on: #154
2026-07-01 16:28:55 +03:00
8735eece13 Merge branch 'feat/trial-7-day' into dev
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Shorten the free trial from 30 to 7 days: backend source of truth
(subscriptions.service TRIAL_DAYS), in-app banner rescale to the 7-day
window, tr/en copy (17 keys each), hardcoded CTAs, and the trial-progress
test. Existing trials keep their stored endDate — new sign-ups only.

Clean 3-way merge with the discount-code billing feature: trial and promo
changes touched disjoint regions of subscription/index.tsx and the message
catalogs. Post-merge verified — web+api typecheck clean, web tests 92/93
(the 1 red is the pre-existing landing index.test.tsx, unrelated to trial).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:26:02 +03:00
373af248b4 Merge pull request 'fix(vinpin): launch VinPower from the Horizon app-launcher on the permanent seat' (#153) from dev into main
Reviewed-on: #153
2026-07-01 10:02:38 +03:00
5763e7fbff fix(vinpin): launch VinPower from the Horizon app-launcher on the permanent seat
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The permanent (paid) Vinpin seat does NOT auto-launch VinPower after login —
it lands on the Horizon HTML-Access app launcher ("Available: VINPIN"), so the
old brand-grid→Fiat flow failed with "Fiat ePER did not open". Add an
OCR-gated ensureBrandGrid step before the ePER-open loop that:

  - detects the launcher (Available / Running / No Running Items) and clicks the
    VINPIN app tile to launch VinPower;
  - satisfies VinPower's own secondary login dialog (username pre-filled,
    password blank) via the webclient-frame DOM password field, with a
    coordinate fallback;
  - dismisses a stray Horizon "Disconnected" dialog / waits through the blank
    connecting screen;
  - no-ops when the brand grid is already up (trial-style auto-launch), keeping
    it backward-compatible.

New coords/waits tuned for the permanent seat live in vinpin.constants.ts.
Never throws (failures fall through to the caller's retry → null). Verified
live on the permanent account: NM426300006A69963 → DOBLO, NM435600006H43436 →
TIPO-EGEA.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 09:52:33 +03:00
9df85c335f Merge pull request 'dev' (#152) from dev into main
Reviewed-on: #152
2026-07-01 09:07:08 +03:00
346ebaa0e6 feat(billing): indirim kodu sistemi (admin panel + Stripe promo + ödeme)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Admin paneli (admin@sase.tr) kod üretir/takip eder, ödeme sayfası kodu
uygular. Kodlar Stripe Coupon + Promotion Code olarak yaşar (limit/süre/
kullanım Stripe redemption'da enforce edilir); yerel discount_codes aynası
panel listeleme + checkout-anı preview içindir.

- migration 0022_discount_codes + schema (discount_codes)
- DiscountCodesModule: admin CRUD (@Roles admin) + /discount-codes/validate (login)
- StripeService: createPromotion / deactivatePromotion / getPromotionRedemptions
  / previewDiscount + resolvePlanAmount; createCheckoutSession opsiyonel
  discountCode -> discounts[{promotion_code}] + metadata.discount_code; webhook
  gerçek tahsilatı (session.amount_total) yazar + ayna times_redeemed++
- web: /dashboard/admin/discount-codes paneli + ödeme adımı indirim alanı
  (validate preview -> indirimli özet -> kod checkout'a); tr/en çeviriler
- 7 birim test (previewDiscount: % / sabit / süre / geçersiz/pasif/dolu/limit)

Not: 2026-04-22 Stripe API promotionCodes.create `promotion:{type:'coupon'}`
kullanıyor (üst-seviye `coupon` değil). dev'de Stripe key yok -> CREATE dev'de
503; E2E için dev'e Stripe TEST key gerekir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 03:40:18 +03:00
a4e53f3cfd feat(trial): shorten free trial from 30 to 7 days
Backend source of truth (subscriptions.service.startFullTrial) now grants a
7-day trial via a named TRIAL_DAYS constant. Existing trials keep their stored
endDate — only new sign-ups are affected, so no migration is needed.

In-app conversion UX rescaled to the 7-day window, preserving the two-banner
no-overlap design:
  - urgency banner: visible the last 3 days (soft d3 / urgent d2 / critical d1)
  - value-upsell: owns days > 3 (the opening days), still proven-value gated
  - progress bars now derive the total span from real start->end dates, so both
    7-day and any remaining legacy 30-day trials render an accurate bar
Subscription-page thresholds (<=3 / >3) and the analytics gate updated to match.

Copy: 17 trial strings each in tr.json/en.json plus hardcoded CTAs in
site-header, _auth, register and demo-footer-cta -> "7 gun / 7 Gun / 7-Day".

Lifecycle emails need no change: trial-ending fires 3-4 days before endDate
and win-back after expiry (both already relative to endDate), and templates
use the dynamic {{daysLeft}} variable (no hardcoded 30).

Test: trial-progress-banner spec updated to 7-day scenarios.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:37:21 +03:00
3de7fa4989 ux(vinpin): brand the decoding state as Sase, drop external-catalog wording
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:13:22 +03:00
fff873a64d fix(vinpin): BullMQ jobId cannot contain ':' — use 'vinpin-<vin>'
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:02:30 +03:00
38c2afa587 chore(deploy): wire VINPIN_* env into api+worker compose (flag off by default)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:43:35 +03:00
8e45d00173 feat(vinpin): vision-loop decode driver (OCR) — reliable VDI automation
Replace the brittle clipboard/fixed-coord extraction with an OCR vision loop:
screenshot -> detect state (login/grid/portal/eper/modal via tesseract keywords)
-> act -> verify -> retry/recover. Adds portal->catalogue detection (the seat
non-deterministically opens a multi-brand portal with no VIN field), not-found
handling, and ffmpeg crop+upscale -> tesseract.js for the decode modal. Only the
model token + year are needed (matcher-tolerant), so OCR garbling is harmless.

Validated live against a trial seat: 6/6 of the ePER-present Fiat VINs decoded to
the correct model (EGEA/DOBLO); 2 genuine "vehicle not found" coverage gaps
reported as null. 8/8 correct outcomes. Still flag-gated off by default; tesseract
is dynamically imported only inside the worker. Warm-session persistence under
swiftshader is the remaining hardening item (currently re-logins per VIN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:00:07 +03:00
dfe90fb968 feat(vehicles): automatic Vinpin VIN-decode fallback (flag-gated, off by default)
When PL24/pcat/emex can't decode a Fiat VIN, decode it via the Vinpin ePER
web catalog (warm-session Playwright worker, single seat, BullMQ concurrency 1),
cache the exact vehicle in vinpin_decodes, match it to PL24's existing
catalog_vehicle for that model, and serve the parts from there. Vinpin = decode
oracle only; PL24 already holds the parts (e.g. Egea/Linea/Doblo).

Strictly gated behind VINPIN_ENABLED (default false) + a Fiat-only brand
allowlist: with the flag off, decodeVin behaviour is byte-identical and the
queue is never touched (covered by tests). Coordinates/selectors in
vinpin.constants.ts are marked TUNE-AGAINST-LIVE-PAID-SEAT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:36:39 +03:00
72889796e5 Merge pull request 'perf(prefetch): pcat rate 8->20/dk + pace 15s->5s (ramp adim 1)' (#151) from dev into main 2026-06-25 09:56:24 +03:00
3096d35113 perf(prefetch): pcat rate 8→20/dk + pace 15s→5s (ramp adım 1)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
pcat kuyruğun %67'si (~26.5k) ve en yavaş drenajı. Proxy = DataImpulse rotating
gateway (74.81.81.81:10000-10999, ~1000 port-rotated çıkış IP — 2026-06-25
doğrulandı: 5 port→5 farklı Alman residential IP), per-IP ban yükü dağılıyor →
8/dk shared cap gereksiz muhafazakâr. Adım 1: 20/dk + pace 5s. (Coolify env yolu
duplike-girdi yüzünden container'a inmedi; kod-default güvenilir yol.) Env override
hâlâ mümkün (PREFETCH_RATE_PCAT/_PCAT_DELAY_MS). 429/403/tunnel izlenecek.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:56:04 +03:00
a4103a8e08 Merge pull request 'fix(subscription): default billing period to monthly, not yearly' (#150) from dev into main
Reviewed-on: #150
2026-06-23 10:09:08 +03:00
31fbcf2821 fix(subscription): default billing period to monthly, not yearly
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Yearly upfront (₺9.990 Full) is the top checkout-abandonment point, so an
annual commitment shouldn't be the default selection. New users now land on
monthly; the yearly tab keeps its +2-month gift badge, and an existing/
pending subscriber's or marketing-preselected period still wins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:57:39 +03:00
0760989b52 Merge pull request 'feat(vehicles): model-browse fallback for no-catalog VINs' (#149) from dev into main
Reviewed-on: #149
2026-06-23 09:13:10 +03:00
9232e40942 feat(vehicles): model-browse fallback for no-catalog VINs
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
When a VIN can't be decoded by any source but its WMI brand is known and
has a browse-able catalog (SERVICE_TO_BRAND), return a structured
{ noCatalog: { brandName, display }, vin } 200 response instead of
dead-ending. The web surfaces a "kataloğunu modelden incele" CTA that
deep-links into the existing /dashboard/catalog browse, where the parts
usually exist (Fiat Egea NM4356 -> PL24 TIPO-EGEA; old Renault VF1 -> 147k
emex parts) but aren't reachable by the specific VIN's index entry.

Brands with no browse catalog (Honda, Maserati, Alfa, ...) keep the
existing informative dead-end. New analytics: vin_decode_no_catalog +
vin_no_catalog_browse_clicked (this case no longer emits vin_decode_error).

Tests: 2 api (browseable -> fallback, non-browseable -> throws) + 1 web
(CTA renders, no error banner). RCA writeup:
/home/s/ss/katalogsiz-vin-rca-2026-06-23.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:58:58 +03:00
00d1e8c961 Merge pull request 'fix(faz6): generic-model skip kaldir (PSA false-poison) — cap-only' (#148) from dev into main 2026-06-23 06:36:23 +03:00
b3dd119c99 fix(prefetch/faz6): generic-model skip'i KALDIR — PSA'yı yanlış zehirliyordu
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Tespit: generic-model (model==brand) sadece Opel'i değil PSA'yı (Peugeot/Citroën)
da kapsıyor; ama PSA generic-model PATLAMIYOR (~200 kat) ve PARÇA ALIYOR (512/290).
Skip onları yanlışlıkla poison'lar + prefetch'i keserdi. Tek doğru guard =
CATEGORY_CAP (marka-bağımsız; yalnız gerçek patlamada >3000 tetiklenir → Opel'i
durdurur, küçük PSA'yı korur). isGenericModel + processInit gate kaldırıldı,
spec sadeleşti (cap + scan-skip kaldı).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 06:35:59 +03:00
335a3b0cae Merge pull request 'Faz 6: anti-poison (generic-model skip + per-arac kategori tavani)' (#147) from dev into main 2026-06-23 06:33:20 +03:00
95061dfa53 feat(prefetch/faz6): anti-poison — generic-model skip + per-araç kategori tavanı
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
5 Opel aracı (model="Opel" decode çözememiş) generic ROOT kataloğa düşüp
419k/102k/21k kategori + 0 PARÇA üretti → 97k kuyruğu zehirledi. İki guard:
- isGenericModel (model==brand veya boş) → processInit skip + markPoison
- CATEGORY_CAP (3000, env PREFETCH_CATEGORY_CAP): processChildren progress.total
  bu tavanı aşınca drill'i durdurur + markPoison
markPoison → prefetch:poison:<id> (30g TTL) + scheduled/progress temizler;
processInit & tryPick poison'ı atlar. +3 spec (generic skip / cap / scan skip).
Full api suite 321 pass. Çöp kategori temizliği ayrıca yapılacak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 06:33:04 +03:00
fd884271b7 Merge pull request 'Tam katalog kapsama: 4-faz prefetch stratejisi (derinlik+skip-completed+per-source+env)' (#146) from dev into main 2026-06-22 11:30:48 +03:00
d119b1eb2c feat(prefetch/faz4): PREFETCH_* env'i api compose bloğuna wire et (Coolify-tunable)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Memory gotcha: Coolify env yalnız compose service `environment:` bloğu ${VAR}
referans ederse container'a ulaşır. Prefetch worker api container'ında in-process
→ 13 anahtar api bloğuna eklendi (CATALOG_BACKFILL_ENABLED + PREFETCH_CONCURRENCY/
RATE_MAX/MAX_DEPTH/RATE_PL24/RATE_EMEX/RATE_PCAT/PCAT_DELAY_MS/PL24_START/PL24_END/
NORESULT_MAX/NORESULT_TTL_DAYS/COMPLETE_TTL_DAYS). Hepsi `:-` boş→kod-default
(davranış değişmez); artık rate/concurrency Coolify UI'dan deploy'suz ramp edilebilir.
PL24 rate yükseltme rotating proxy + izleme ile (Faz 5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:27:19 +03:00
5200d5a61d feat(prefetch/faz3): per-source paralel rate limit (PL24/EMEX/pcat bağımsız)
Tek global 20/dk limiter tüm kaynakları paylaştırıyordu → yavaş pcat PL24/emex'i
açlığa düşürüyordu. Artık her kaynak Redis fixed-window ile BAĞIMSIZ kısılıyor
(SOURCE_RATE_MAX: pl24=20, emex=20, pcat=8; env PREFETCH_RATE_PL24/_EMEX/_PCAT).
Global WORKER_RATE_MAX güvenlik tavanı (20→60). RateLimitError yeni cause
"source-rate" → process() per-job defer eder (emex/pcat PL24'ü beklemez); cooldown
hâlâ tüm worker'ı duraklatır. PL24 default'u 20'de KALDI (ban riski; yüksek-rate
Faz 4 rotating proxy ile). +spec: under/over ceiling + unknown-source unlimited.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:25:41 +03:00
7c715af0c5 feat(prefetch/faz2): tamamlanan araçları işaretle + Phase-2 rescan'de atla
Chain parça ile tamamlanınca prefetch:complete:<id> set edilir (TTL 21g,
env PREFETCH_COMPLETE_TTL_DAYS). tryPick bu işaretliyi atlar → Phase-2 bitmiş
ağaçları her dalga yeniden gezmez (eski "sonsuz churn" durur). Başarısız job
isFinished'e ulaştırmaz → kısmi ağaç asla complete işaretlenmez, gap-fill korunur.
TTL periyodik re-validasyon sağlar. +spec: complete-marker'lı araç Phase-2'de atlanır.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:22:02 +03:00
61436db48f feat(prefetch/faz1): tam-derinlik (MAX_DEPTH 2→12) + no-op children job üretimini durdur
Hedef = decode edilmiş tüm araçların TAM kategori+parça ağacı DB'de. Cap artık
hedef değil güvenlik tavanı (gerçek ağaçlar ~5-7 derin; doğal yaprak tespiti
durduruyor). queueCategoryJob non-leaf dalı `depth < MAX_DEPTH` ile kapılandı:
processChildren'ın early-return'ünü aynalar → kuyruğun ~%85'ini oluşturan
boş depth-job'ları (rate-limit slotu harcayan no-op) hiç üretilmez.
+spec: depth>=cap'te children enqueue YOK, cap altında VAR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:17:36 +03:00
a36008017c Merge pull request 'Iletisim: Merkez adresini tek satira sadelestir' (#145) from dev into main 2026-06-22 09:42:29 +03:00
70b6597811 feat(contact): Merkez adresini tek satıra sadeleştir
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Firma Adı/Adres/Şehir/Ülke/Vergi No döküm yerine tek satır:
"1209 Mountain Road PL NE #11131, Albuquerque, NM 87110, USA" (Türkiye Ofisi ile tutarlı).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:39:45 +03:00
64b2c9fb06 Merge pull request 'Iletisim: Merkez/Turkiye Ofisi adresleri + click-to-call telefon' (#144) from dev into main 2026-06-22 09:31:38 +03:00
690bdeb647 feat(contact): USA adresini "Merkez" yap + Türkiye Ofisi ekle + click-to-call telefon
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
İletişim sayfası: mevcut THINXTRA LLC (USA) adresi "Merkez" başlığı altına alındı,
"Türkiye Ofisi" (Bahçekapı mah, 2473. cad. No: 98 Etimesgut / Ankara) eklendi.
Destek kartında mailin yanına 0 850 840 8939 tel: linkiyle (click-to-call) eklendi.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:26:48 +03:00
3cadf80286 Merge pull request 'Backfill BFS derinligini 5->2'ye indir (sig pre-warm)' (#143) from dev into main 2026-06-21 16:59:54 +03:00
50cef50289 feat(prefetch): backfill BFS derinliğini 5→2'ye indir (sığ pre-warm + tembel derin)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Exhaustive depth-5 pre-warm ~460k'lık sınırsız BFS fan-out üretiyordu, hiç
drenaj olmuyor ve faydalı sığ + reactive işi açlığa düşürüyordu. Üst ~2
seviyeyi her decode edilmiş araç için pre-warm etmek sınırlı iş (günlerde
biter), boş katalog sayfasını öldürür, ilk tıklamaları anında yapar. Derin
kategoriler on-view (getCategoryTree) tembel çekilip kalıcı cache'lenir.
Env-tunable: PREFETCH_MAX_DEPTH. MAX_DEPTH guard mevcut derin job'ları no-op'a
çevirir (PL24'e yük yok).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:05:31 +03:00
34d68510e3 Merge pull request 'PL24 scrape penceresini devre disi birak (7/24 akis)' (#142) from dev into main 2026-06-21 09:24:46 +03:00
bee68b6a34 feat(prefetch): PL24 scrape penceresini varsayılan olarak devre dışı bırak (7/24 akış)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
PL24 prefetch artık 09:00-18:00 İstanbul penceresine bağlı değil; backfill
backlog'unun ~%96.7'si PL24 children olduğundan 9 saatlik pencere drenajı
boğuyordu. Pencere env ile geri daraltılabilir (PREFETCH_PL24_START/_END).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:14:06 +03:00
5aad3e8b22 Merge pull request 'feat(proxy): otomatik Floxy→DataImpulse failover (decode regresyonu)' (#140) from dev into main
Reviewed-on: #140
2026-06-13 18:30:45 +00:00
b992c0075b feat(proxy): otomatik Floxy→DataImpulse failover (decode regresyonu)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Floxy residential proxy bitince/çökünce (402 bakiye veya tünel reddi —
ERR_TUNNEL_CONNECTION_FAILED), EMEX + pcat decode istekleri ölü proxy'ye
çarpıp decode başarı oranını çökertiyordu (2026-06-13: ~17k tünel hatası/24s,
decode %78→%45). Önceki "DataImpulse last-ditch fallback" tarayıcı yolunda hiç
yoktu ve HTTP yolunda her istekte 2 ölü Floxy denemesi ziyan ediyordu.

Paylaşılan ProxyHealthService kapısı (in-memory cooldown): herhangi bir tüketici
bir Floxy bağlantı hatası görünce kapıyı tetikler; cooldown boyunca TÜM tüketiciler
DataImpulse'a düşer. Floxy'den ilk başarılı yanıt veya cooldown bitişi kapıyı
temizler (kendi kendini iyileştirir, periyodik yeniden-deneme). FLOXY_FAILOVER_COOLDOWN_MS
ile ayarlanır (varsayılan 180s).

Bağlanan tüketiciler:
- EMEX HTTP (fetchEmexHtml): kapı açıkken DataImpulse-öncelikli zamanlama
- EMEX tarayıcı (Playwright): launch'ta dinamik sağlayıcı seçimi + ensureSession
  health-gate'i (Floxy ölünce DataImpulse'a relaunch, kapı temizlenince Floxy'i
  yeniden dene); scrape-içi tünel ölümünde tripFloxyFailover
- pcat (buildProxy): hem call hem capture (Playwright JWT) bacakları DataImpulse'a düşer

isProxyConnectFailure(): yalnız gerçek bağlantı hatalarında tetikler — yavaş-ama-
canlı exit'in nav timeout'u failover'ı tetiklemez.

Test: ProxyHealthService + isProxyConnectFailure birim testleri; tüm api suite (303) yeşil.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 21:03:23 +03:00
aa4503f1c9 Merge pull request 'fix(web): Turnstile yüklenemeyince kullanıcıya uyarı göster' (#139) from dev into main
Reviewed-on: #139
2026-06-12 13:51:18 +00:00
aba8e5eb9b fix(web): Turnstile yüklenemeyince kullanıcıya uyarı göster
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Reklam engelleyici / gizlilik eklentisi challenges.cloudflare.com'u
kestiğinde widget sessizce kayboluyordu; form token'sız gidince
kullanıcı yanıltıcı 'E-posta veya şifre hatalı' görüyordu. Artık
script yüklenemediğinde veya widget error-callback verdiğinde
widget'ın altında açıklayıcı bir uyarı çıkıyor (login/register/iletişim).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:45:26 +03:00
97c1dbcece Merge pull request 'fix(pcat): robust year extraction across key spelling + date format' (#138) from dev into main
Reviewed-on: #138
2026-06-12 13:11:22 +00:00
d5362a5390 fix(pcat): robust year extraction across key spelling + date format
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Broadened extractYearFromPcatCar after auditing 176 null-year prod pcat
vehicles: the build date sits under "production date" (space) far more often
than "production_date" (underscore), and values come both year-first
(2006/02/27) and day-first (08/03/2016). Match the date key by spelling-agnostic
substring and pull the 4-digit year via regex so parseInt no longer reads the
day. Concrete Year param still wins over build date.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:09:14 +03:00
7571438a6b Merge pull request 'dev' (#137) from dev into main 2026-06-12 13:08:00 +00:00
e57a9968b2 fix(pcat): extract real Year param, not the year=All filter axis
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
pcat decode emits a faceting axis param key="year" value="All" BEFORE the
concrete per-car key="Year" value="2021". extractYearFromPcatCar used
find(includes("year")) which grabbed the axis → parseInt("All")=NaN → null,
so pcat-sourced vehicles (e.g. Opel ASTRA-J W0VPD5EC1MG063839) saved with a
blank year despite the year being present in raw_data. Scan all year-ish
params for a plausible value, then fall back to production_date.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:07:11 +03:00
6c6b74a8e5 feat(payments): recurring billing — Stripe Checkout switches to subscription mode
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Monthly/yearly purchases were one-time charges: our "subscription" was just
an end_date stamp, access silently died at period end and no renewal
machinery existed (no auto-charge, no reminder) — every paying customer had
to notice the lockout and re-buy by hand.

- Checkout now mode:"subscription" with inline recurring price_data; the
  Stripe customer is stored on first purchase and reused (saved card +
  invoice history on one record, with a stale-customer retry guard)
- invoice.paid webhook: extends end_date to the billing-line period end,
  records a completed payment (deduped on stripe_invoice_id against webhook
  retries), captures subscription_renewed with $revenue, mails the receipt;
  late dunning recovery re-activates the row and re-grants Full-plan brands
- invoice.payment_failed webhook: dunning mail with Stripe's next retry
  date; access is NOT cut — end_date governs and the nightly cron closes it
  if every retry fails. Product rule: mail on success, mail on failure,
  never a pre-charge reminder
- customer.subscription.deleted: stamps cancelledAt; renewals stop and
  access runs out at end_date naturally
- cancel()/resume() sync cancel_at_period_end to Stripe (forwardRef pair) —
  an in-app cancel that leaves the card being charged was unacceptable
- subscription_create invoices only enrich the checkout's payment row
  (payment intent + invoice id for receipts/panel refunds); activation,
  revenue and the receipt stay on checkout.session.completed
- migration 0020: users.stripe_customer_id,
  user_subscriptions.stripe_subscription_id (+idx),
  payments.stripe_invoice_id (+idx)

Legacy one-time subs (3 live payers) are untouched: they expire at their
end_date as before and board recurring on their next manual checkout.

Promote checklist: add invoice.paid / invoice.payment_failed /
customer.subscription.deleted to the prod webhook endpoint; verify Stripe
"Customer emails" upcoming-renewal reminders stay OFF.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:55:44 +03:00
4879f7cdef fix(subscriptions): keep trial alive until payment actually lands
Clicking "Öde" ran create(), which expired the user's live trial BEFORE
any money moved. Anyone who then abandoned the Stripe page lost their
remaining trial days and was fully locked out (brand-access requires
active|trial) — every abandoned checkout became silent churn (3 real
victims on prod: 06-02 and 2x 06-10, incl. a 9.990 TRY yearly intent).

- create(): no longer touches the trial; only stale pending rows are
  expired at checkout start
- activateSubscription(): supersedes the trial here instead, once the
  payment has actually landed (single legitimate early-end point;
  idempotency guard unaffected)
- getMySubscription(): status-priority pick (active > pending > trial >
  cancelled > rest) so the expired remains of an abandoned checkout —
  always the newest row — no longer eclipse a live trial on the
  subscription page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:15:47 +03:00
f394897a08 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>
2026-06-12 14:55:44 +03:00
51d8104cdc polish(part-prices): dialog'da çift başlık kaldırıldı, 'son güncelleme' → 'son fiyat değişimi'
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:45:58 +03:00
500738fcd4 chore(deploy): SUPPLIER_PRICE_DB env'leri sonrası dev redeploy tetikleyici
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:41:05 +03:00
532e1ad9ef feat(part-prices): parça kodu bazlı tedarikçi fiyat geçmişi + OEM sayfasında p50/p95/p99 grafiği
- pg: part_price_tracks + part_price_daily (0018) — (kod, kaynak, gün) başına
  stoktaki tekliflerin p50/p95/p99 + teklif sayısı; source='supplier' şimdilik,
  perakende ileride aynı tabloya 'retail' olarak girer. Tedarikçi kimliği yok.
- API: GET /part-prices/series (ilk istekte takip history'sinden lazy-backfill,
  sonrası salt-pg + Redis) ve POST /part-prices/current-batch (sayfadaki kodlar
  için canlı güncel istatistik). P-servisi sözleşmesi: asla throw yok, fail-open.
- Worker: part-price-refresh cron'u 19:30 Europe/Istanbul (takip sync'i 19:05'te
  bitiyor) — izlenen kodlara bugünün satırını upsert eder, sku_map'i artımlı
  bakar, Redis cache düşürür. SUPPLIER_PRICE_DB_* yoksa sessiz no-op.
- Kaynak köprüsü: takip.sku_map (code_norm → product_id; tam sku / ilk-boşluk /
  ilk-tire sonrası normalize adayları) vmi MySQL'inde kurulu; 6,8M satır.
- Web: OEM detayında "Tedarikçi fiyat analizi" kartı (güncel medyan + P95/P99 +
  teklif sayısı + 30g delta, 30G/90G/Tümü aralıklı step grafik, recharts) ve
  article/muadil/OE satırlarında fiyat çipi → dialog'da tam geçmiş.
- Fix(p): td snapshot'ında gerçek üretici kodu articles.name'de (article_number
  %96 upstream sayısal ID) — sayfa artık kopyalanabilir gerçek kodu gösteriyor.
- compose: SUPPLIER_PRICE_DB_ENABLED/URL api+worker bloklarına eklendi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:33:37 +03:00
6a54e63c0f Merge pull request 'fix(backfill): in-process scan timer (cron marker buried behind 444k backlog)' (#136) from fix/backfill-inprocess-scan into main 2026-06-12 09:14:25 +00:00
7d10107f4b fix(backfill): run hourly scan on an in-process timer (cron marker gets buried)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The BullMQ cron scan never fired on prod (ic frozen at 28 for days, 0
scans completed) even after the lifo change. Root cause, confirmed live:
a cron scheduler enqueues a DELAYED marker, and BullMQ promotes delayed
jobs to the wait-list HEAD via LPUSH — `promoteDelayedJobs` ignores lifo —
while the worker pops from the tail. Behind the ~444k deep-drill backlog
the scan marker sat at wait position 1 (head) with ~444k jobs ahead of it
→ unreachable for weeks. So scheduler-level lifo cannot work; the marker
must not go through the queue at all.

Run the scan on an in-process hourly timer in PrefetchWorkerService
instead (kickoff 60s after boot, then hourly), prod-host gated and
single-fired via a Redis NX lock. The scan's Phase-1 still enqueues
NON-delayed `lifo` init jobs, which DO jump the wait list (RPUSH tail →
popped next), so newly-decoded / zero-parts vehicles get onboarded ahead
of the backlog while it drains. jobs.module now removes the old cron
scheduler so stale markers stop being produced; the backfill-scan job
handler stays so any already-queued legacy marker runs harmlessly.

Tested: spec covers the NX lock (run when acquired / skip when held) plus
the existing lifo + Phase-1/Phase-2 gating; full api suite 272 passed,
`pnpm build` + biome green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:14:09 +03:00
a304420456 Merge pull request 'promote: sezon kazananları admin'e maillenir (otomatik üyelik uzatma kaldırıldı)' (#135) from dev into main 2026-06-12 08:57:03 +00:00
2c848df97d change(gamification): sezon ödülleri otomatik tanımlanmaz — kazananlar admin'e maillenir
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
expert-rewards cron'u artık üyelik uzatmıyor: ay kapanışında ilk 3'ü
hesaplayıp admin@sase.tr'ye Postal üzerinden kazanan listesini yollar
(sıra, ad, e-posta, puan, ödül 30/15/7 gün, kullanıcı ID) ve
oem_expert_rewards'a denetim satırı yazar; günleri admin elle tanımlar.
Dev DB'den atılan mailler "[dev]" konu önekiyle ayrışır. Mail-önce-satır-
sonra sırası retry'da mail kaybını önler (nadir çift mail kabul edilir).
Abonelik/kredi dokunuşu tamamen kaldırıldı.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:53:02 +03:00
33e59c9554 Merge pull request 'fix(backfill): nest-build spec typing (unblocks #133 deploy)' (#134) from fix/backfill-lifo-fast-lane into main 2026-06-12 08:47:25 +00:00
a7678acbe1 fix(backfill): type spec queue mock so production nest build compiles it
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The prod docker build runs `nest build`, which compiles spec files too —
the untyped `vi.fn(async () => undefined)` mock made `queue.add.mock.calls[i]`
a zero-length tuple, so the destructures tripped TS2493/TS2352 and failed
`pnpm build` (tsc --noEmit had skipped the spec). Type the mock args as
unknown[]. No behaviour change; tests still 7/7, `pnpm build` green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:45:28 +03:00
cbdff3311f Merge pull request 'perf(backfill): fast-lane Phase-1 via lifo + run scan despite deep backlog' (#133) from fix/backfill-lifo-fast-lane into main
Reviewed-on: #133
2026-06-12 08:39:41 +00:00
137d0c07a6 Merge pull request 'promote: Parça Uzmanları programı (flag-gated) + revert düzeltmesi' (#132) from promote-expert-program into main 2026-06-12 08:25:35 +00:00
8c1d247c45 Merge remote-tracking branch 'origin/dev'
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
2026-06-12 11:16:18 +03:00
579ee8d275 tweak(gamification): uzman programı kriteri 2/2 → 5/5
En az 5 FARKLI başarılı VIN decode VE en az 5 FARKLI OEM kodu kopyası.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:16:01 +03:00
4df06ab3a1 Merge remote-tracking branch 'origin/dev' 2026-06-12 11:05:41 +03:00
58a0faeb99 Reapply "Merge pull request 'dev' (#130) from dev into main"
This reverts commit 5c7803e12a.
2026-06-12 11:05:41 +03:00
6c414f9a59 feat(gamification): Parça Uzmanları programı feature-flag + kullanım kriteri kapısı
Program (oylama + muadil önerisi + liderlik) artık iki katmanlı kapının
arkasında: PostHog "oem-expert-program" ana şalteri (local-eval, fail-open)
VE kullanım kriteri — en az 2 FARKLI başarılı VIN decode VE en az 2 FARKLI
OEM kodu kopyası. Karar sunucuda tek noktada (ExpertAccessService.check);
web GET /oem-votes/access ile bir kez sorar, kriterler istemciye sızmaz.

Kapalıyken: sidebar nav linki görünmez, OEM detayındaki oy kartı + öneri
bölümü render edilmez, /dashboard/uzmanlar kademeli-açılış mesajı gösterir
ve liderlik sorgusu atılmaz. Yazma uçları (oy, öneri) sunucu tarafında da
ForbiddenException ile korunur — UI gizlemek tek başına güven sınırı değil.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:04:20 +03:00
fe35bbd826 feat(gamification): aylık uzman sezonları — ilk 3'e otomatik üyelik uzatması
Parça Uzmanları sıralaması artık aylık sezon: liderlik tablosu içinde
bulunulan TR-ayının (Europe/Istanbul) puanlarını gösterir ve her ayın
1'i 00:00 TR'de kendiliğinden sıfırlanır. expert-rewards cron'u
(BullMQ scheduler, "0 0 1 * *" tz=Europe/Istanbul) aynı anda biten
sezonu kapatır ve ilk 3 oylayıcıya üyelik uzatması verir:
1. → 30 gün, 2. → 15 gün, 3. → 7 gün (EXPERT_REWARD_LADDER).

Ödül mekaniği referral'la birebir: canlı active/trial abonelik endDate
+gün uzar, yoksa günler users.referral_credit_days'e bankalanır (sonraki
trial/aktivasyonda tüketilir). oem_expert_rewards (migration 0017,
period+rank UNIQUE) hem denetim kaydı hem run-once garantisi — retry ya
da elle tetik çift ödül veremez. Sıralama ölçütü job ve leaderboard'da
birebir aynı (puan desc, eşitlikte puana erken ulaşan önde).

Web: uzmanlar sayfasına sezon şeridi ("Haziran 2026 sezonu" + 🥇1 ay ·
🥈15 gün · 🥉7 gün rozetleri); alt küçük-punto kural satırına aylık
sıfırlama + ödül notu eklendi. Leaderboard cevabına periodStart eklendi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:48:36 +03:00
55894bec0e polish(web): oy butonlarından sayaçları kaldır
Uyumlu/Uyumsuz butonlarında oy sayıları artık görünmüyor (çoğunluğu ele
vermek oyu yönlendiriyordu); özet yine çekilir — kullanıcının kendi oyu
butonda işaretli kalır.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:34:44 +03:00
4068091a21 Merge pull request 'Revert PR #130 (erken prod merge geri alındı — dev'de kalacak, sonra promote)' (#131) from revert-pr130 into main 2026-06-11 21:50:14 +00:00
5c7803e12a Revert "Merge pull request 'dev' (#130) from dev into main"
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
This reverts commit eccecf73e6, reversing
changes made to 4bcac424e0.
2026-06-12 00:48:58 +03:00
24e668ea01 polish(web): oy kartı header'a — OEM kodunun sağ simetriğine, araç+parça bağlamıyla
Topluluk oyu kartının koyu dolgusu kalktı (sayfa arkaplanıyla aynı, yalnız
çerçeve); desktop'ta başlıkta OEM kodunun sağına yerleşti, mobilde kodun
altına iner. Katalogdan gelişte araç etiketi + parça adı search-param'la
taşınır (v/p/vid) ve kartta butonların üstünde "araç · parça" satırı olarak
gösterilir — neyle neyin uyumlu olduğu tek alanda. vid oy kaydına analitik
bağlam olarak geri eklendi; doğrudan ziyarette genel soru metnine düşülür.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:47:06 +03:00
eccecf73e6 Merge pull request 'dev' (#130) from dev into main
Reviewed-on: #130
2026-06-11 21:45:35 +00:00
6575c611e8 polish(web): puan kuralları yalnız Uzmanlar sayfası altında, oyunlaştırılmış dille
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Üstteki üç-kartlı kural bloğu ve oy kartındaki kural cümlesi kaldırıldı;
kurallar tek satır küçük puntoyla sayfanın en altında ("Oy ver +1 ·
çoğunluğu tuttur +2 bonus · kodu ilk değerlendiren 3 puanı kapar").
Başlık ve boş-durum metinleri de oyunlaştırıldı; oy kartında yalnız
"Her oy puan kazandırır · Parça Uzmanları sıralaması" linki kaldı.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:33:37 +03:00
4072c6e736 feat(catalog): OEM oyları detay sayfasına taşındı + topluluk muadil önerileri
Parça tablosu sadeleşti: Uyum ve Adet kolonları kalktı, liste açılışındaki
toplu istekler (/oem-votes/lookup ve /p/matched) tamamen kaldırıldı. Her OEM
kodu artık koşulsuz /dashboard/oem/$code'a linklenir — P eşleşmesi olmayan
kodda da sayfa dolu: topluluk oyu kartı, muadil önerileri ve ters katalog.

OEM detay sayfası: OemVoteCard (uyumlu/uyumsuz, sayaçlar, puan toast'ı,
puanlama özeti + Parça Uzmanları linki) ve OemSuggestionsSection — eşleşme
bulunamayan kodlar için kullanıcıdan marka + parça kodu önerisi toplar.
Öneriler oem_suggestions tablosunda (kullanıcı+kod+normalize öneri başına
tek satır, ON CONFLICT yutulur), markaya+normalize koda göre gruplanıp
"× N kullanıcı" rozetiyle listelenir; önerilen kod kendi detayına linklenir.
Şimdilik öneri puan kazandırmaz; status kolonu moderasyon kancası.

API: oem-suggestions modülü (POST 10/dk throttle, GET ?code=), migration
0016_oem_suggestions. Eski tablo-içi OemVoteButtons bileşeni silindi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:26:20 +03:00
0de6bd3faa perf(backfill): fast-lane Phase-1 via lifo + run scan despite deep backlog
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The hourly catalog-backfill scan had gone effectively dead on prod: its
job sat behind a ~430k-deep wait list and, even when it ran, self-skipped
because the queue backlog (431k) was far over the maxBacklog ceiling
(1000). Net result: newly-decoded / zero-parts vehicles were never
onboarded — they starved behind the deep-drill backlog (ic stuck at 28
for days, 0 backfill-scan jobs ever completed).

Root constraint (verified against bullmq 5.68 lua): moveToActive drains
the `wait` list (RPOPLPUSH from the tail) BEFORE the `prioritized` ZSET,
so a `priority` job is starved behind an already-deep wait queue — the
opposite of what's wanted. The lever that works is `lifo`: it RPUSHes to
the tail, where the very next RPOPLPUSH picks it, ahead of the FIFO
backlog. addJobFromScheduler honours lifo too, so the scan job itself can
jump the queue.

Changes:
- Thread a `fast` flag through the init→children→parts chain; fast jobs
  are enqueued with `lifo:true` so the whole chain jumps the backlog.
- Backfill scan: Phase-1 (zero-parts vehicles) now runs every wave in the
  fast lane even when the backlog is over the ceiling; only Phase-2 (the
  rolling rescan that piles on) is suspended while the queue is deep.
- Register the hourly scan job with `lifo:true` so it fires on the next
  tick instead of being buried for days.

Tested: new prefetch-worker.service.spec (lifo wiring + Phase-1/Phase-2
gating), full api suite green (248 passed), typecheck + biome clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:18:37 +03:00
d922fb7a02 test(catalog): cover P4 VIN group-node gate (children + loadError)
Mirrors the PSA VIN-scope tests for the new …group.action parent gate: a Ford
json-vin-sub-group.action node with a positional link_wid routes to children
(not a silent empty leaf), and an empty drill surfaces loadError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:02:50 +03:00
8fddd09087 feat(catalog): OEM uyumluluk oylaması + Parça Uzmanları liderlik tablosu
Parça satırlarına topluluk oyu eklendi: her OEM kodu için uyumlu/uyumsuz
ikilisi (👍/👎, sayaçlı). Oylar oem_votes'a (kullanıcı+kod başına tek oy,
fikir değişikliği günceller, puan üretmez), ödüller oem_vote_points
ledger'ına yazılır: oy +1, kesin çoğunlukla aynı yönde +2 (kodu ilk
oylayan her zaman 3 alır); ödüller oy anında kesinleşir, çoğunluk sonra
dönse de geri alınmaz. Aynı koda eşzamanlı oylar advisory lock ile
sıralanır.

/dashboard/uzmanlar: Trophy Gamification UI Kit'ten (ui.trophy.so, MIT)
uyarlanan kürsü + sıralama + puan rozetiyle "Parça Uzmanları" liderlik
sayfası; adlar KVKK-maskeli (S*** Y***), cevap kullanıcı id sızdırmaz.

Not: sidebar nav linki, tr/en i18n anahtarları ve routeTree 09a9487'de
gitmişti; bu commit eksik kalan rota/bileşen/API dosyalarını tamamlayarak
dev build'ini düzeltir. Migration: 0015_oem_votes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:00:51 +03:00
09a9487564 remove changelog page and sidebar link
Drop /dashboard/changelog route, ChangelogTab component, useChangelog
hook, the sidebar nav item, and the related tr/en translation keys.
API-side changelog schemas are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:51:54 +03:00
e6c8c9c32e fix(catalog): drill P4 VIN group nodes on live click (Ford/Nissan/Opel parent-gate gap)
getCategoryWithPartsInner had parent gates for PSA (psa::/json-vin-main-groups),
pcat (hasSubgroups), pl24 link_wid *Group* tables, Volvo (vin-group.action) and
emex — but P4 VIN/legacy group nodes whose link_wid is a positional code
("1","2","CAP1"…) and whose path is a *…group.action* drill (Ford
json-vin-sub-group, Nissan/Opel json-sub-group, etc.) matched none of them. They
fell through to the leaf parts path, fetched no parts and rendered a silent empty
panel — the dominant current '0 parça' cluster (mekanik/kaporta/Elektrikli aksam…
across many vehicles; PostHog-confirmed). Add a gate that drills any pl24
…group.action node (excluding Volvo's own vin-group.action branch) → subgroups or
retryable loadError. Lazy-heals existing flag-less rows, no reseed.

Also set has_subgroups at seed time: PSA scopes → true (always parents);
P4-legacy cats → true when linkPath is a …group.action drill. Fixes folder
signposting + lets prefetch pre-drill them instead of treating them as leaves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:48:04 +03:00
4bcac424e0 Merge pull request 'dev' (#126) from dev into main
Reviewed-on: #126
2026-06-11 14:34:37 +00:00
e91159003e chore(web): remove 'click to reopen' hint under recent searches
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:33:49 +03:00
45f7e3f5a4 feat(proxy): proxy_logs telemetry — per-attempt provider/ban/latency tracking
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
One row per proxied upstream attempt (pcat call/capture/validate, emex http)
written fire-and-forget by the new ProxyTelemetryService (buffered, capped,
errors swallowed — telemetry can never hurt the request path).

- banned = upstream 403/429 (IP-block signal), distinct from auth/data errors
- sticky legs (pcat capture, emex floxy) carry a session_key; pcat capture
  also resolves the actual residential exit IP via a parallel ipify probe
  through the same sticky session → concrete banned-IP tracking
- rotating legs log provider + outcome (ban *rate* instead of per-IP)
- 30-day retention piggybacked on the query-cleanup job

Feeds the Süper Panel /analytics/proxy page (provider grading + banned IPs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:27:48 +03:00
9f47d15312 feat(web): hide Chatwoot launcher bubble on mobile
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Floating launcher covered content on small screens. Bubble is hidden
below 768px via injected CSS; programmatic openChatwoot() callers
(subscription page, onboarding modal) still open the fullscreen widget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:26:04 +03:00
3c9245bd8b feat(web): keep sidebar on blog & contact for logged-in users
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Sidebar linked /blog and /contact pointed at public marketing pages, so
clicking them dropped the user out of the dashboard shell. Extract page
content into shared components (blog-content, contact-content) and add
dashboard-wrapped routes /dashboard/blog, /dashboard/blog/$slug and
/dashboard/contact; sidebar now links to those. Public SEO pages stay
unchanged and remain the canonical URLs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:25:14 +03:00
ffcd417939 Merge pull request 'hotfix(pcat): widen JWT capture for slow residential IPs' (#129) from hotfix-pcat-capture into main 2026-06-11 13:59:37 +00:00
75d5b11475 fix(pcat): widen JWT capture for slow residential IPs (domcontentloaded + 12s poll)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
After the Floxy account rotation, the new residential exit IPs are slower:
measured DOM-ready ~7s and the widget's /v3/api/proxy token call firing ~11.8s —
past the 10s PAGE_TIMEOUT + networkidle, so every capture timed out ("No token
after 12s") and the warm pool stayed empty (pcat dead despite valid creds).

- waitUntil networkidle → domcontentloaded (reliable ~7s; networkidle often never
  settles on JS catalog sites through a slow proxy → goto times out pre-token).
- PAGE_TIMEOUT 10s → 25s (env PCAT_PAGE_TIMEOUT_MS); token fired at ~11.8s.
- CAPTURE_POLL_AFTER_OK 5s → 12s (env PCAT_CAPTURE_POLL_OK) to cover the ~5s
  gap between DOM-ready and the token call. Capture is background (warm pool,
  cold-pool fast-fail never blocks users), so the longer cap is free insurance.

Verified via standalone Playwright probe through the new Floxy: token captured at
11842ms (DOM 7023ms). typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:59:34 +03:00
4a6e673af1 fix(pcat): widen JWT capture for slow residential IPs (domcontentloaded + 12s poll)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
After the Floxy account rotation, the new residential exit IPs are slower:
measured DOM-ready ~7s and the widget's /v3/api/proxy token call firing ~11.8s —
past the 10s PAGE_TIMEOUT + networkidle, so every capture timed out ("No token
after 12s") and the warm pool stayed empty (pcat dead despite valid creds).

- waitUntil networkidle → domcontentloaded (reliable ~7s; networkidle often never
  settles on JS catalog sites through a slow proxy → goto times out pre-token).
- PAGE_TIMEOUT 10s → 25s (env PCAT_PAGE_TIMEOUT_MS); token fired at ~11.8s.
- CAPTURE_POLL_AFTER_OK 5s → 12s (env PCAT_CAPTURE_POLL_OK) to cover the ~5s
  gap between DOM-ready and the token call. Capture is background (warm pool,
  cold-pool fast-fail never blocks users), so the longer cap is free insurance.

Verified via standalone Playwright probe through the new Floxy: token captured at
11842ms (DOM 7023ms). typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:59:17 +03:00
d82bcb560b Merge pull request 'hotfix(proxy): rotate Floxy creds (402 balance) — restore pcat' (#128) from hotfix-floxy-creds into main 2026-06-11 13:49:58 +00:00
46e4b7cfcb fix(proxy): rotate Floxy creds (prior account hit HTTP 402 / balance exhausted)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The hardcoded Floxy account (d739255e819b) ran out of balance → 402 Payment
Required on every CONNECT → pcat decode dead on prod since ~07:16 UTC 2026-06-11
(pcat has no proxy fallback; EMEX limped on its DataImpulse fallback). New funded
account cac4b0d96a80 verified working (call leg 401-reachable, capture leg 200).
Updates the hardcoded defaults in all 3 Floxy consumers (pcat auth, emex http,
emex browser); env overrides PCAT_FLOXY_USER/PASS + EMEX_FLOXY_USER/PASS unchanged.

Follow-up: wire FLOXY_USER/PASS into docker-compose env so future rotations are
Coolify-env-only (no code change / rebuild).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:49:56 +03:00
2e6c8ac751 fix(proxy): rotate Floxy creds (prior account hit HTTP 402 / balance exhausted)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The hardcoded Floxy account (d739255e819b) ran out of balance → 402 Payment
Required on every CONNECT → pcat decode dead on prod since ~07:16 UTC 2026-06-11
(pcat has no proxy fallback; EMEX limped on its DataImpulse fallback). New funded
account cac4b0d96a80 verified working (call leg 401-reachable, capture leg 200).
Updates the hardcoded defaults in all 3 Floxy consumers (pcat auth, emex http,
emex browser); env overrides PCAT_FLOXY_USER/PASS + EMEX_FLOXY_USER/PASS unchanged.

Follow-up: wire FLOXY_USER/PASS into docker-compose env so future rotations are
Coolify-env-only (no code change / rebuild).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:49:37 +03:00
2f6a0e3158 Merge pull request 'Merge dev → main: surveys, P/OEM page, trial upsell, catalog-issue report, pcat timings' (#127) from merge-dev-to-main into main 2026-06-11 13:28:15 +00:00
b6e4deb7bd Merge dev into main: catalog-issue report + pcat skip/transient timings
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Resolves the recurring promote-by-cherry-pick divergence (decode/pcat/emex live
on both branches with different SHAs — all auto-merged). Net-new to main:
- fix(decode) fd78957: mark pcat skip/transient in timings (stop false catalog-gap reads)
- feat(catalog) c972a17: one-click report on empty/error catalog states
- compose: P_DB_ENABLED/P_DB_URL env (inert until set in Coolify)

Conflict: docker-compose.coolify.yml — both sides added env at the same spot;
took dev's superset (main's Directus/Blog vars + dev's P_DB vars).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	docker-compose.coolify.yml
2026-06-11 16:25:57 +03:00
c972a17670 feat(catalog): one-click report on empty/error catalog states — no form
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
'Çalışmadı mı? Bildir' opened the Sentry feedback dialog; most users
bail at a form. Now a single click captures a catalog_issue_reported
PostHog event with the vehicle/category context (vin, vehicle_label,
category_name, source, page) and flips to 'Bildirildi — teşekkürler'.
The identified person already carries the reporter's email; the Süper
Panel lists these under /analytics/reported-categories. The automatic
Sentry degradation report (with session replay) still fires via
useReportCatalogDegradation — unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:00:23 +03:00
fd789574fd fix(decode): mark pcat skip/transient in timings — stop false catalog-gap reads
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Cold-pool fast-fail (and transport blips) make pcat return null without
ever evaluating the VIN; EMEX then identifies the car and the row lands
as 'No catalog — identified as X' (pcat sub-timing 1-2ms). Downstream
catalog-gap detection can't tell these from true coverage gaps and
flags covered brands (Audi, 2026-06-11, 7 retries by one user).

- timings.pcat_skipped=1 on cold-pool fast-fail
- timings.pcat_transient=1 on transport failure (circuit-open skips
  already carry pcat_circuit_open)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:50:36 +03:00
4b5f3bb41e Merge pull request 'promote(emex): Floxy residential primary transport' (#125) from promote-emex-floxy into main 2026-06-10 19:24:21 +00:00
63908b1fca perf(emex): Floxy residential primary transport (was DataImpulse-first)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
EMEX egress was DataImpulse-first: fetchEmexHtml tried 3 dead-prone DataImpulse
ports before falling to the Floxy fallback, and the browser scraper launched on a
random DataImpulse port. With ~50% of DataImpulse ports dead, this burned the 25s
decode budget before reaching Floxy (observed: a Ford VIN where pcat correctly
returned null fast, then EMEX's DataImpulse retries ate the budget).

ssd tokens are replayed from emex's own HTML and are NOT strictly IP-bound on the
.aspx endpoints (the prior random-port-per-call primary proved that), so a sticky
Floxy IP across the flow is strictly safer.

- EMEX_PROXY_PROVIDER (default "floxy" | "dataimpulse" | "none"). floxy →
  fetchEmexHtml schedule = [floxy, floxy, dataimpulse] (Floxy sticky primary,
  rolls IP on transport failure, single DataImpulse last-ditch). dataimpulse →
  legacy [di,di,di,floxy,floxy]. Default HTTP agent (image-dims) follows provider.
- EmexBrowserService launches chromium on a sticky Floxy session (one residential
  exit IP for the browser lifetime; fresh id per relaunch) under the same flag.
- Reuses the existing EMEX_FLOXY_* config (same Floxy account pcat now uses).

Rollback: EMEX_PROXY_PROVIDER=dataimpulse. typecheck+biome+11 emex tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:23:36 +03:00
ef4ab9bcd5 perf(emex): Floxy residential primary transport (was DataImpulse-first)
EMEX egress was DataImpulse-first: fetchEmexHtml tried 3 dead-prone DataImpulse
ports before falling to the Floxy fallback, and the browser scraper launched on a
random DataImpulse port. With ~50% of DataImpulse ports dead, this burned the 25s
decode budget before reaching Floxy (observed: a Ford VIN where pcat correctly
returned null fast, then EMEX's DataImpulse retries ate the budget).

ssd tokens are replayed from emex's own HTML and are NOT strictly IP-bound on the
.aspx endpoints (the prior random-port-per-call primary proved that), so a sticky
Floxy IP across the flow is strictly safer.

- EMEX_PROXY_PROVIDER (default "floxy" | "dataimpulse" | "none"). floxy →
  fetchEmexHtml schedule = [floxy, floxy, dataimpulse] (Floxy sticky primary,
  rolls IP on transport failure, single DataImpulse last-ditch). dataimpulse →
  legacy [di,di,di,floxy,floxy]. Default HTTP agent (image-dims) follows provider.
- EmexBrowserService launches chromium on a sticky Floxy session (one residential
  exit IP for the browser lifetime; fresh id per relaunch) under the same flag.
- Reuses the existing EMEX_FLOXY_* config (same Floxy account pcat now uses).

Rollback: EMEX_PROXY_PROVIDER=dataimpulse. typecheck+biome+11 emex tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:18:29 +03:00
1392a642a9 Merge pull request 'promote(pcat): rotating Floxy residential transport + resilience' (#124) from promote-pcat-floxy into main 2026-06-10 18:58:32 +00:00
b886a05b5b perf(pcat): rotating Floxy residential transport + resilience (decode latency)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Prod decode was dominated by the DataImpulse proxy: ~50% of ports dead at any
moment, 57% of captured tokens discarded by validation, the dead-port slot
never evicted (invalidateSession only fired on 401/403, never on transport
timeout), and a cold pool blocked the user inline on a multi-second capture.
pcat sub-timing was p50 8.3s / p90 56s, blowing the 25s decode budget.

Verified on prod: the TWS- token is NOT request-time IP-bound (0 auth 401/403
across thousands of calls; a token captured on one IP replays 200 through
arbitrary fresh residential IPs). So the proxy can rotate freely.

- Transport swap to Floxy residential (PCAT_PROXY_PROVIDER=floxy|dataimpulse|none).
  Call/validate leg = a fresh exit IP per request (max IP diversity → min per-IP
  ban); capture leg = a sticky session per Playwright attempt. DataImpulse +
  direct kept as env fallbacks. Token decoupled from the slot (proxyPort removed).
- Prune 3 zero-yield JWT sites (knkauto/autodo.kz/flynestauto — 0 captures, ~80%
  of attempts); add PCAT_JWT_SITES env override.
- Lower catalog-scrape timeout 30s→10s (env-tunable) so a slow IP aborts within
  budget and the retry rotates to a fresh IP.
- Cold-pool fast-fail on the decode race: warm in background, let EMEX/PL24
  answer instead of blocking inline; treated as a transient miss (no neg-cache).
- Evict a token after N consecutive transport failures on rotating IPs (the
  dead-slot bug); reset on any 2xx.
- PCAT circuit breaker mirroring PL24 (transient-only; cold-skip neutral; skip
  marks outcome.transient so a pcat-only VIN isn't negative-cached 30m).

Typecheck + Biome + unit tests (categories/vehicles) green. Default flips pcat
to Floxy on next deploy; instant rollback via PCAT_PROXY_PROVIDER.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:56:45 +03:00
25ba37103d perf(pcat): rotating Floxy residential transport + resilience (decode latency)
Prod decode was dominated by the DataImpulse proxy: ~50% of ports dead at any
moment, 57% of captured tokens discarded by validation, the dead-port slot
never evicted (invalidateSession only fired on 401/403, never on transport
timeout), and a cold pool blocked the user inline on a multi-second capture.
pcat sub-timing was p50 8.3s / p90 56s, blowing the 25s decode budget.

Verified on prod: the TWS- token is NOT request-time IP-bound (0 auth 401/403
across thousands of calls; a token captured on one IP replays 200 through
arbitrary fresh residential IPs). So the proxy can rotate freely.

- Transport swap to Floxy residential (PCAT_PROXY_PROVIDER=floxy|dataimpulse|none).
  Call/validate leg = a fresh exit IP per request (max IP diversity → min per-IP
  ban); capture leg = a sticky session per Playwright attempt. DataImpulse +
  direct kept as env fallbacks. Token decoupled from the slot (proxyPort removed).
- Prune 3 zero-yield JWT sites (knkauto/autodo.kz/flynestauto — 0 captures, ~80%
  of attempts); add PCAT_JWT_SITES env override.
- Lower catalog-scrape timeout 30s→10s (env-tunable) so a slow IP aborts within
  budget and the retry rotates to a fresh IP.
- Cold-pool fast-fail on the decode race: warm in background, let EMEX/PL24
  answer instead of blocking inline; treated as a transient miss (no neg-cache).
- Evict a token after N consecutive transport failures on rotating IPs (the
  dead-slot bug); reset on any 2xx.
- PCAT circuit breaker mirroring PL24 (transient-only; cold-skip neutral; skip
  marks outcome.transient so a pcat-only VIN isn't negative-cached 30m).

Typecheck + Biome + unit tests (categories/vehicles) green. Default flips pcat
to Floxy on next deploy; instant rollback via PCAT_PROXY_PROVIDER.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:19:14 +03:00
a237e55874 docs: add repository index (PROJECT_INDEX.md + PROJECT_INDEX.json)
Token-efficient session bootstrap: module map, entry points, routes,
jobs, deps, test/deploy conventions. Regenerate via /sc:index-repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:25:35 +03:00
8b7dc83bd2 feat(p): reverse catalog on OEM detail — your vehicles using this code
New "Bu kod kataloğunuzda" section lists the user's decoded vehicles
whose parts include the queried OEM code, each linking to the schema
page that shows it. Pure sase data (parts.oem_code → vehicles, indexed
exact match) — no TecDoc / vehicle-structure dependency.
GET /parts/oem-vehicles?code=.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:26:48 +03:00
c2dfa5cd2e fix(decode): recognize VXK WMI as Opel (PSA/Stellantis-platform Corsa F, Mokka B)
Customer entered VXKUPHNKSP4032040 (2023 Corsa F) and got a bare
'Marka desteklenmiyor' — VXK was missing from all three WMI maps, so
identifyBasic could not even name the brand. PL24's opel_parts catalog
ends at CORSA-E (2015-2019, verified via the model-picker page), so the
catalog itself stays a gap, but the user now gets the honest
'Opel 2023 olarak tanındı, katalog henüz mevcut değil' message and the
miss is logged for the coverage backlog.

- WMI_BRAND_MAP + PL24_WMI_SERVICE_MAP + EMEX CATALOG_MAP: VXK -> Opel
- DECODE_CHAIN_VERSION 2 -> 3 (brand-mapping change; invalidates the
  stale negative cache for the customer's VIN)
- vin-cache-keys.spec: lock-key assertion checked the bare version char,
  which now appears inside the test VIN — assert the ✌️ segment instead

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:36:51 +03:00
0652fb3a18 Merge pull request 'promote(decode): opaque candidate keys + slim decode response' (#122) from promote-decode into main 2026-06-10 09:04:20 +00:00
7aafdc92b6 perf(decode): slim decode response to id + display summary
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
POST /vehicles/decode returned the full vehicles row while every consumer
(search, landing, service-test) only navigates by id and re-fetches via
GET /vehicles/:id. That shipped the raw upstream decode payload to the
client on every decode — rawData is 19KB avg / 127KB p95 / 207KB max in
prod — plus the provider name, for nothing.

The response is now { id, brandName, model, year } on both the DB-hit and
fresh-decode paths. Frontend drops the (now absent) source property from
the vin_decode_success event; decode source remains queryable server-side
via query_logs.source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 02c8321023)
2026-06-10 12:01:40 +03:00
6154656c3e refactor(decode): keep decode-source details server-side, opaque candidate keys
The multi-candidate decode response leaked provider internals (source name,
pcat car ids, EMEX _ssd/_vid/_quickGroupsUrl/catalogId) and made the client
carry them between requests: the frontend stored candidateSource and echoed
pcatCarId/emexCarIndex back on selection.

Now the candidate list returned to the client carries only display fields
(name, description, parameters) plus an opaque key, and the provider mapping
is stashed in Redis (vin:candidates:*, 30m TTL, resolve-cache fallback). The
pick request sends just { vin, candidate }. Legacy pcatCarId/emexCarIndex
body params still work for already-loaded bundles.

Also drops `source` from the public /vehicles/preview response — no consumer
used it, and provider names must never be public (same policy as
teaser-stats).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 2077a9724a)
2026-06-10 12:01:40 +03:00
02c8321023 perf(decode): slim decode response to id + display summary
POST /vehicles/decode returned the full vehicles row while every consumer
(search, landing, service-test) only navigates by id and re-fetches via
GET /vehicles/:id. That shipped the raw upstream decode payload to the
client on every decode — rawData is 19KB avg / 127KB p95 / 207KB max in
prod — plus the provider name, for nothing.

The response is now { id, brandName, model, year } on both the DB-hit and
fresh-decode paths. Frontend drops the (now absent) source property from
the vin_decode_success event; decode source remains queryable server-side
via query_logs.source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 02:52:43 +03:00
2077a9724a refactor(decode): keep decode-source details server-side, opaque candidate keys
The multi-candidate decode response leaked provider internals (source name,
pcat car ids, EMEX _ssd/_vid/_quickGroupsUrl/catalogId) and made the client
carry them between requests: the frontend stored candidateSource and echoed
pcatCarId/emexCarIndex back on selection.

Now the candidate list returned to the client carries only display fields
(name, description, parameters) plus an opaque key, and the provider mapping
is stashed in Redis (vin:candidates:*, 30m TTL, resolve-cache fallback). The
pick request sends just { vin, candidate }. Legacy pcatCarId/emexCarIndex
body params still work for already-loaded bundles.

Also drops `source` from the public /vehicles/preview response — no consumer
used it, and provider names must never be public (same policy as
teaser-stats).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 02:37:02 +03:00
43dd4d38ac feat(surveys): entrance animation, step progress, Esc-to-close
Polish pass on the survey popover:
- Entrance: slide-up + fade on mount (300ms ease-out, respects
  prefers-reduced-motion via motion-reduce:transition-none).
- Step progress: "1 / N" indicator on multi-question surveys so users know
  how much is left.
- Esc closes the card (counts as a dismissal); listener attached once with a
  dismissRef so it captures current answers, not a stale first-render closure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 01:28:45 +03:00
34cb361b67 fix(surveys): one-row rating scale, rating auto-advance, mobile spacing
Design review of the live survey popover surfaced three issues:
1. NPS 0–10 wrapped to two rows (flex-wrap + flex-1 made the 8/9/10 row
   stretch absurdly wide) — now a single equal-width grid row (grid-cols-N
   by scale; static map so Tailwind keeps the classes).
2. Single-question ratings forced a redundant "Devam" tap — tapping a score
   now auto-advances (450ms debounce to allow changing the pick) and the
   submit button is hidden on rating questions.
3. On mobile the card crowded the Chatwoot launcher — bumped to bottom-28
   (sm:bottom-24 keeps desktop unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 01:19:59 +03:00
c18c513139 feat(p): drop EAN from the OEM detail page
Remove the EAN line from the matched-article cards and stop fetching/
shipping eans in /p/oem (its only consumer was that line). Snapshot
still holds the data; the endpoint just no longer queries it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 01:17:22 +03:00
5a13f13e8e perf(p): instant OEM nav + trim 30x-bloated payload
OEM detail "slow load" was two things, not the query (DB 135ms / API
13-120ms):
1. The parts-panel link opened a NEW TAB → full SPA cold boot every
   click. Switch to in-app client navigation on plain click (real href
   kept, so ctrl/cmd/middle-click still opens a new tab).
2. The /p/oem response shipped each article's oeNumbers + compatible
   lists (up to 200 each × 60 articles) that the UI never renders — 96%
   of a 370 KB payload. Ship lean articles; aggregates already carry
   the cross-refs. 60-article code: 370 KB → ~12 KB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 01:06:29 +03:00
f29730fae0 fix(surveys): hold captures until the survey event receiver is hooked
trial_urgency_banner_viewed fires ~1s into the page load and raced the lazy
surveys extension — events captured before its hook exists can never activate
an event-triggered survey (affects real users, not just e2e). capture() now
awaits surveysReceiverReady() (onSurveysLoaded, 3s safety timeout) and runs the
eligibility check after the event has gone through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:55:06 +03:00
c1ae18e5f1 feat(p): brand/code search box on the OEM detail page
Long cross-reference lists (some parts have 90+ aftermarket numbers,
160+ OE codes) were hard to scan. Add a filter input that matches
across all three sections (matched articles, aftermarket equivalents,
OE cross-refs) by brand (substring) or code (normalised, so "1j0 973"
finds "1J0 973 702"). Clear button + "no results" state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:51:13 +03:00
7f5db3234b fix(posthog): disable bot UA filter on staging so e2e can exercise surveys
posthog-js silently drops every capture when userAgentData.brands contains
HeadlessChrome (UA string/webdriver masking doesn't help), which also starves
event-triggered surveys in Playwright runs against dev.sase.tr. Production
keeps the bot filter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:49:20 +03:00
72c3fe0dc2 feat(surveys): replace Formbricks with self-rendered PostHog API surveys
Formbricks CE turned out to gate person-based targeting (setUserId/attributes
→ 403 enterprise) — the exact flexibility surveys need. PostHog already holds
the person properties, so surveys move back there in API mode: posthog-js
evaluates eligibility (event triggers, targeting flags like
subscription_status=active, wait periods, per-distinct_id dedup) via
getActiveMatchingSurveys, and we render the popover ourselves — zero PostHog
branding, sase.tr dark-theme styling.

- lib/surveys.ts: display manager + capture payload builders that mirror
  posthog-js's own "survey shown/dismissed/sent" shapes exactly
  ($survey_response_<qid>, $set $survey_dismissed/<id>[/iter], seenSurvey_*,
  lastSeenSurveyDate) so the PostHog Surveys results UI works unchanged
- components/survey-popover.tsx: single_choice (+Diğer), open text, rating/NPS
- posthog.ts: capture/pageview/identify now schedule survey checks; register
  deploy_env (dev.sase.tr ships the key now → staging traffic is filterable)
- remove @formbricks/js, its CSP entries and build args

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:40:18 +03:00
7ce8e34d81 Merge pull request 'feat(blog): CMS cover images via MinIO, instant cache purge, drop embedded posts' (#121) from blog-cms-assets into main 2026-06-09 21:22:51 +00:00
4ffc3577a5 feat(p): link only OEM codes with a cross-reference match
Every OEM code was a link → unmatched codes (the majority — fasteners,
clips, body parts TecDoc doesn't carry) opened an empty "no
equivalents" page. Now the schema page batch-checks its codes against
the snapshot (POST /p/matched, one indexed query) and links ONLY the
matched ones; unmatched codes render as plain text. Copy stays for all.
No more dead-end empty pages from the parts panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:21:25 +03:00
572337ba62 feat(blog): CMS cover images via MinIO, instant cache purge, drop embedded posts
- cover_file uploads in Directus land in the public blog-assets MinIO bucket;
  API resolves them to storage.sase.tr URLs (Directus itself is Tailscale-only)
- POST /blog/cache/purge (automation token) lets a Directus Flow drop the
  30-min list cache the moment a post changes in the CMS
- blog list + detail pages now render purely from the API; the 4 hand-authored
  posts were migrated to Directus earlier and the JSX copies are removed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:17:40 +03:00
07cf710bc3 fix(csp): allow Formbricks SDK in script-src
@formbricks/js is a loader shim that injects the real SDK as an external
script from anket.sase.tr — connect-src alone wasn't enough; the browser
blocked /js/formbricks.umd.cjs and no survey could render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:03:05 +03:00
f955bb7ef8 fix(vehicles): canonicalise brand casing (case-insensitive brand match)
Decode persist looked brands up with a case-sensitive eq(), so an
uppercase decode string ("FORD") missed canonical "Ford" → brand_id
NULL + raw uppercase stored as brand_name, splitting one brand across
casing variants in analytics/catalog. Now matches brands
case-insensitively and stores the canonical name. Migration
0013_fix_brand_casing backfills existing rows (60 on prod, 1 on dev).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:47:33 +03:00
597391bbdf feat(surveys): Formbricks in-app survey bridge over PostHog events
Self-hosted Formbricks (anket.sase.tr) replaces PostHog surveys (free-tier
branding). PostHog stays the single instrumentation source: capture() forwards
allowlisted trigger events (trial_urgency_banner_viewed, subscription_cancelled,
onboarding_completed, vin_decode_error, empty_catalog_cta_clicked) to the
Formbricks SDK, identify/reset/people-properties mirror into Formbricks
attributes, and $pageview registers SPA route changes for no-code triggers.

- apps/web/src/lib/formbricks.ts: lazy fire-and-forget wrapper (inert without
  VITE_FORMBRICKS_APP_URL + VITE_FORMBRICKS_ENV_ID)
- CSP: allow anket.sase.tr in connect-src/img-src
- Dockerfile + compose: bake the two VITE_ build args

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:44:45 +03:00
4716232398 fix(p): natural empty-state copy (drop literal "P" from user text)
"P karşılığı bulunamadı" / "P kapsamında" read as nonsense — "P" is
the internal name, not a user-facing word. Use plain Turkish: "Bu OEM
kodu için uyumlu parça bulunamadı" and describe the catalog gap
without a brand or single-letter label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:37:15 +03:00
e91af4b953 refactor(p): complete TecDoc → P content rename
Finishes 3b14431 (which only captured the file renames): apply the
identifier/endpoint/env/UI changes so the code matches the new paths
— PModule/PController/PSourceDbService, @Controller("p"), /p/oem,
config key `p`, P_DB_ENABLED/P_DB_URL, "P kataloğundan…" copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:30:46 +03:00
3b144313b3 refactor(p): rename TecDoc → P (parts) across the cross-reference feature
Drop the third-party brand name from the product surface and code.
The OEM detail feature is now "P" (short for parts) everywhere:
endpoint /p/oem, PModule/PController/PSourceDbService, config key
`p`, env P_DB_ENABLED/P_DB_URL, UI copy ("P kataloğundan…"), and the
unused tab label. Physical snapshot DB stays `td` (server-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:29:40 +03:00
aeb438e442 feat(blog): back blog module with central Directus CMS
Blog posts now live in the shared Directus instance (Coolify / Süper Panel
project) instead of the per-env blog_posts table, so prod and staging serve
identical content. API response shape is unchanged; Redis list cache and the
n8n automation endpoint keep working as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:13:03 +03:00
24e44a49f8 fix(tecdoc): only surface publicly-resolvable part images
The snapshot stores scrape-local '/_debug/...' image paths that 404
off-host. Filter the OEM lookup to http(s) image URLs and render the
thumbnail only when one exists, so the detail page shows clean
code-focused cards instead of a grid of broken-image placeholders.
Real CDN URLs will surface automatically once present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:00:12 +03:00
eae373c553 fix(web): accuracy sweep — drop unbuilt-feature claims, VIN→şase, B2B framing
Full marketing-copy audit. Removes price-comparison claims (pricing
step, hero variant B, blog tip) that aren't built; normalises VIN→şase
across TR copy (plan features, e-commerce section, FAQ, blog, about) —
keeping VIN only in the educational "VIN nedir" post and the EN file;
de-consumerises framing (FAQ "aracınız", about "bireysel kullanıcılar").
Fabricated metrics/testimonials left untouched pending review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:00:08 +03:00
fb2c28faf1 feat(tecdoc): OEM detail page with TecDoc cross-references
Resolve a catalog OEM code to its TecDoc equivalents on a new
/dashboard/oem/$code page: the aftermarket parts that carry it
(brand + article number + image + EAN), buyable supplier
substitutes, and OE cross-references (same part under other makes).

- API: TecdocModule (read-only postgres-js client to the imported
  `td` snapshot), GET /tecdoc/oem?code=. Normalisation-based match
  (TecDoc stores `1J0 973 702`, catalog gives `1J0973702`); exact
  match recovers ~1/10 vs normalised ~5/10 on real codes. Self-
  disables without TECDOC_DB_* env → { matched: false }.
- Web: OEM code in the parts panel is now a link (new tab) to the
  detail page; "N/A" stays plain text.
- Mirrors CatalogSourceDbModule (raw queries, no Drizzle modelling).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:52:51 +03:00
5e1675f95a fix(web): replace unbuilt feature claims with real capabilities
The landing OEM card, the manual-vs-Sase comparison table, and a blog
post advertised "price comparison" / "order history & part tracking" —
features that aren't built. Replaced with real capabilities
(exploded-diagram matching, interactive part diagrams, brand catalogs)
in both tr + en. Mirrors the same accuracy fix already shipped to the
Novu lifecycle emails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:44:58 +03:00
d3ae278c75 feat(trial): value-moment upsell to convert engaged trials earlier
Only ~1.6% of trials reach checkout: the 30-day full-access trial shows the
(urgency) banner solely in its final 7 days, leaving ~3 weeks with no in-app
push, and the value moment (first few queries) goes unconverted.

Add TrialValueUpsell — a calm, value-framed banner that fires on PROVEN VALUE
(user has decoded >= 3 customer vehicles, read server-truth via /vehicles/history)
and only OUTSIDE the urgency window (days > 7), so it fills the gap without ever
overlapping or competing with the urgency banner. B2B copy: frames around
"müşteri araçlarını sınırsız sorgulama", never personal-vehicle wording.
Tracks viewed/cta_clicked/dismissed in PostHog; dismissal persists per trial.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:33:10 +03:00
cd54cf9443 fix(observability): harden Sentry init for catalog-degradation reporting
- initSentry() is now idempotent via a cached promise, and the degradation
  reporter / feedback opener await it — so an empty/error state that renders
  before the SDK's dynamic import resolves no longer drops the event (not missing
  the failure is the whole point of the auto-capture).
- environment now resolves hostname-first (sase.tr → production, dev.sase.tr →
  staging), so the compose-baked VITE_SENTRY_ENVIRONMENT default ("production")
  no longer makes dev browser events indistinguishable from prod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:32:22 +03:00
f02f97f69f fix(payments): prevent paid-but-not-activated on Stripe webhook retry
handleCheckoutCompleted marked the payment 'completed' BEFORE activating the
subscription, but the idempotency guard keys off payment.status === 'completed'.
So if activateSubscription threw after the payment was marked completed, the
Stripe webhook retry would hit the guard, skip activation, and leave a
paid-but-never-activated subscription.

Fix: activate FIRST, then mark completed — a mid-activation failure leaves the
payment 'pending' so the retry re-runs cleanly. Guard activateSubscription with
an idempotency check (return early when already active) so a retry after a
successful activate but failed mark-completed cannot re-fire revenue events
(PostHog subscription_activated + Meta CAPI Purchase), re-consume referral
credit, or double-insert Full-plan brands. Admin manual-activate already
pre-rejects 'active', so legitimate first activations are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:30:15 +03:00
11975d8b5a feat(payments): remove EFT/havale, Stripe-only checkout + Turkish locale
EFT/Havale was retired; Stripe is now the sole payment method. Remove the
EFT code surface (shared PaymentMethod "eft" + EftPaymentInput + eftReceiptUrl,
EFT_RECEIPT_REQUIRED error code, billing UI receipt/filter/label paths,
payments.service eft read paths). DB columns (eft_receipt_url, bank_account_id,
bank_accounts) are kept and marked @deprecated to preserve historical records
and avoid a destructive migration — same pattern as the retired iyzico column.

Faz 3 conversion lever: set locale "tr" on the Stripe Checkout session. The
audience is Turkish B2B and ~60% of sessions reached the foreign-language
hosted page but never started a payment intent (pure abandonment, not decline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:17:10 +03:00
d6c88ede73 feat(observability): session-replay + in-app feedback on catalog UX failures
The two things only the browser can add on top of the backend catalog-degradation
reporting:

- Replay-on-failure: when the catalog UI renders empty-tree (decoded vehicle, no
  categories) or a drill loadError, capture a browser Sentry warning and flush the
  Session Replay → you can WATCH the user hit the dead-end (serkan's session,
  reproducible). Per-session deduped (one replay/session covers the whole journey).
- In-app feedback: a "Çalışmadı mı? Bildir" button on the empty-parts, empty-tree
  and loadError states opens the Sentry feedback dialog pre-tagged with the
  vehicle/category (+ session replay) — turns a parts shop's complaint into a
  structured, triageable report instead of an email.

Browser events are fingerprinted source="browser" so they form their own
"what users actually saw" issues (carrying replays) next to the server-side
detections. tsc + biome + web build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:07:49 +03:00
284 changed files with 35719 additions and 2448 deletions

View File

@@ -30,14 +30,20 @@ ARG VITE_POSTHOG_KEY=
ARG VITE_META_PIXEL_ID=
ARG VITE_CHATWOOT_BASE_URL=
ARG VITE_CHATWOOT_WEBSITE_TOKEN=
ARG VITE_GOOGLE_ADS_ID=
ARG VITE_GOOGLE_ADS_SIGNUP_LABEL=
RUN pnpm build
# ── Stage 4: Production ───────────────────────────────
FROM node:22-alpine AS production
# Chromium for Playwright (EMEX/PartsCatalogs)
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
# Chromium for Playwright (EMEX/PartsCatalogs). ffmpeg is required by the Vinpin
# OCR pipeline (vinpin.ocr cropScale): it crops+3x-upscales the tiny centered
# Rpartstore launch-error / decode-modal region so tesseract can read it. Without
# it cropScale silently returns the full 1600x900 frame and every clipped OCR
# degrades to an illegible full-frame 1x read (the DOWN-Rpartstore misclassify bug).
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont ffmpeg
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1

127
PROJECT_INDEX.json Normal file
View File

@@ -0,0 +1,127 @@
{
"project": "sase-v2 (sase.tr)",
"description": "Turkish B2B SaaS: VIN decode + OEM parts-catalog platform for parts dealers & repair shops",
"generated": "2026-06-10",
"generator": "/sc:index-repo",
"humanReadable": "PROJECT_INDEX.md",
"stack": {
"packageManager": "pnpm@10.29.3",
"node": "22 (alpine in Docker)",
"monorepo": "pnpm workspaces + turbo",
"language": "TypeScript",
"lint": "biome"
},
"workspaces": {
"apps/api": { "name": "api", "framework": "NestJS ^10.4", "role": "REST API :4000 + BullMQ worker (worker.ts)" },
"apps/web": { "name": "web", "framework": "React ^19 + TanStack Router ^1.120 + Vite", "role": "SPA dashboard + marketing pages" },
"packages/shared": { "name": "@sase/shared", "role": "zod schemas, types, constants (plans, error-codes, regex), utils (vin-validator, formatters, currency)" },
"packages/config": { "name": "@sase/config", "role": "shared config" },
"packages/ui": { "name": "@sase/ui", "role": "shadcn-style component library" }
},
"entryPoints": {
"api": "apps/api/src/main.ts",
"worker": "apps/api/src/worker.ts",
"web": "apps/web/src/main.tsx",
"migrations": "apps/api/src/database/migrate.ts"
},
"apiModules": {
"vehicles": "VIN decode orchestration; fallback chain Corgi→PartsCatalogs→PL24→EMEX→NHTSA; slim response + Redis candidate stash (vin-cache-keys.ts)",
"integrations/pl24": "PartsLink24 — shared P4 core + brand services: pl24.service, pl24-psa, pl24-volvo, pl24-ford, pl24-hyundai-kia, pl24-opel, pl24-ford-legacy, pl24-auth, parsers/",
"integrations/emex": "emexdwc.ae scrape — HTTP (undici ProxyAgent) primary, Playwright fallback (emex.browser.ts), emex-tree.parser",
"integrations/parts-catalogs": "PartsCatalogs API (pcat)",
"integrations/p": "OEM cross-reference 'P' — TecDoc-derived MySQL (P_DB_* env): p.controller, p-source-db.service",
"integrations/corgi": "offline WMI lookup (instant)",
"integrations/vin-api": "NHTSA fallback decoder",
"integrations/catalog-source-db": "upstream source-DB bridge",
"catalog": "catalog browse — catalog/pcat-catalog/emex-catalog controllers+services",
"categories": "category tree: partCount rollup, lazy drill/heal, PSA parent gates",
"parts": "part listings",
"brands": "brand canonicalization (case-insensitive lookup)",
"auth": "better-auth cookie sessions",
"users": "user management",
"referrals": "referral program",
"payments": "Stripe",
"subscriptions": "subscription lifecycle",
"plans": "plan definitions",
"jobs": "BullMQ — queues: catalog-prefetch, emex-scrape, translation, lifecycle-email, subscription-expiry, query-cleanup; processors mirror queues; prefetch-worker.service",
"posthog": "server-side feature-flag eval + kill-source-* switches",
"meta-capi": "Meta Conversions API (event_id dedup, fail-open)",
"notifications": "Novu lifecycle triggers",
"email": "Postal HTTP API ops mail",
"chatwoot": "support-desk integration",
"analytics": "product analytics endpoints",
"telemetry": "OpenTelemetry → OTLP exporters",
"common": "shared infra incl. catalog-degradation → Sentry (fingerprinted, Redis-deduped)",
"redis": "ioredis provider",
"storage": "S3/MinIO",
"translations": "OpenAI-backed translation",
"admin": "admin endpoints",
"internal-admin": "internal admin",
"blog": "blog content",
"changelog": "changelog content",
"demo": "public demo flow",
"contact": "contact form",
"config": "configuration.ts + env.validation.ts (zod; empty-string trap)",
"database": "Drizzle provider; schema split: core, pl24, emex, parts-catalogs, relations"
},
"webRoutes": {
"public": ["index", "pricing", "blog", "blog_/$slug", "demo", "demo_/categories_/$categoryId", "about", "contact", "kvkk", "privacy", "terms"],
"auth": ["login", "register", "forgot-password", "reset-password", "email-verified"],
"dashboard": [
"index", "search", "history", "settings", "billing", "changelog",
"vehicles_/$id", "vehicles_/$id/categories_/$categoryId",
"catalog/index", "catalog_/$brandName", "catalog_/$brandName_/$modelId", "catalog_/$brandName_/$modelId/categories_/$categoryId",
"catalog_/pcat/$catalogId[/_$modelId/_$carId[/groups/$groupId]]",
"catalog_/emex/$catalogCode[/_$vehicleId[/groups/$groupId]]",
"oem.$code", "subscription", "service-test"
],
"admin": ["admin/index", "admin/users", "admin/analytics", "admin/referrals", "admin/copy-logs"]
},
"webLib": ["api-client", "auth-client", "posthog", "surveys", "meta-pixel", "faro", "sentry", "chatwoot", "i18n", "vehicle", "toast", "user-settings", "keys", "category-icons", "emex-group-hierarchy", "recently-used-brands"],
"configFiles": {
"turbo.json": "task pipeline",
"biome.json": "lint/format",
"docker-compose.coolify.yml": "prod/staging: api + worker + sase-redis; env vars must be referenced as ${VAR} per service block",
"Dockerfile": "multi-stage node:22-alpine + Chromium; runtime imports must be in dependencies",
"ecosystem.config.js": "PM2",
"apps/api/src/config/env.validation.ts": "zod env gate"
},
"database": {
"primary": "PostgreSQL via drizzle-orm ^0.41",
"migrations": "apps/api/drizzle/ — 14 SQL files (0000–0013) + meta/_journal.json; custom SHA256-tracked runner (src/database/migrate.ts)",
"schemas": ["core", "pl24", "emex", "parts-catalogs", "relations"],
"secondary": "MySQL (mysql2) — TecDoc-derived 'P' OEM cross-ref db",
"cache": "Redis (sase-redis container) — BullMQ + VIN candidate stash + dedup"
},
"tests": {
"api": { "framework": "vitest", "count": 30, "pattern": "*.spec.ts", "run": "pnpm --filter api test" },
"web": { "framework": "vitest (no Playwright e2e)", "count": 24, "pattern": "*.test.ts(x)", "run": "pnpm --filter web test" },
"qa": "qa/post-deploy smoke checks"
},
"keyDependencies": {
"api": ["@nestjs/* ^10.4", "drizzle-orm + postgres", "better-auth", "bullmq + ioredis", "stripe", "posthog-node", "openai", "undici", "@sentry/nestjs", "@opentelemetry/*", "mysql2", "zod"],
"web": ["react ^19", "@tanstack/react-router + react-query", "zustand", "posthog-js", "@grafana/faro-web-sdk", "@sentry/react", "remotion", "better-auth", "zod"]
},
"commands": {
"install": "pnpm install",
"dev": "pnpm dev",
"build": "pnpm build",
"lint": "pnpm lint",
"typecheck": "pnpm typecheck",
"test": "pnpm test",
"dbMigrate": "pnpm --filter api db:migrate",
"dbStudio": "pnpm --filter api db:studio"
},
"deployment": {
"platform": "Coolify (cool.semih.ai)",
"staging": { "branch": "dev", "fqdn": "dev.sase.tr", "rule": "verify every change here first" },
"production": { "branch": "main", "fqdn": "sase.tr", "rule": "promote only after dev verified" },
"policy": "never edit on server/container; push-to-deploy via Gitea webhooks"
},
"docs": {
"assistant": ["CLAUDE.md", "AGENTS.md"],
"internal": "docs/ (INDEX.md, 00-testing.md, design-specs/, pl24-catalog/, product/, clarification/)",
"rootReports": ["upstream-prob.md", "undecoded-vin-rca.md", "posthog-max-leverage.md", "posthog-feature-roadmap.md", "katalogayapi.md", "knowledge.md", "ralpRapor.md", "emex-hierarchy-plan.md", "MANIFEST.md", "MANIFEST-FN-403.md", "RECOVERY-FN-412.md", "SCOPING-FN-411.md", "SASE_PRD_Hesap_Paylasimi_Onleme_v1.1.md"],
"bizWorkspace": "/home/s/ss/INDEX.md"
}
}

88
PROJECT_INDEX.md Normal file
View File

@@ -0,0 +1,88 @@
# Project Index: sase.tr (sase-v2)
Generated: 2026-06-10 · regenerate via `/sc:index-repo` · machine-readable twin: `PROJECT_INDEX.json`
Turkish B2B SaaS: VIN decode + OEM parts-catalog platform for parts dealers & repair shops.
pnpm 10.29.3 workspaces + Turbo · Node 22 · TypeScript · Biome.
## Structure
```
apps/api NestJS 10 backend — REST API (:4000) + separate BullMQ worker
apps/web React 19 + TanStack Router/Query + Vite SPA (Remotion video, i18n)
packages/shared @sase/shared — zod schemas, types, constants (plans, error-codes), utils (vin-validator, formatters)
packages/config @sase/config — shared config
packages/ui @sase/ui — shadcn-style components
scripts/ one-off ops/debug (emex scrapers, backfills, deploy.sh, pl24-catalog-explorer)
qa/post-deploy post-deploy smoke checks
docs/ internal docs — see docs/INDEX.md (testing, design-specs, pl24-catalog, product)
docker/ local dev compose (minio, nginx)
```
## Entry Points
- `apps/api/src/main.ts` — API bootstrap (also serves built web via ServeStatic; no nginx in prod)
- `apps/api/src/worker.ts` — BullMQ worker process (separate container)
- `apps/api/src/database/migrate.ts` — custom SHA256-tracked migration runner over `apps/api/drizzle/` (14 SQL migrations + `meta/_journal.json`)
- `apps/web/src/main.tsx` — SPA bootstrap (`routeTree.gen.ts` generated by `tsr generate`)
## API Modules (`apps/api/src/`)
- **vehicles/** — VIN decode orchestration; fallback chain Corgi → PartsCatalogs → PL24 → EMEX → NHTSA; slim decode response + Redis candidate stash (`vin-cache-keys.ts`)
- **integrations/pl24/** — PartsLink24: shared P4 core + per-brand services (`psa`, `volvo`, `ford`, `hyundai-kia`, `opel`, `ford-legacy`) + `parsers/`
- **integrations/emex/** — emexdwc.ae scrape: HTTP via undici ProxyAgent, Playwright fallback (`emex.browser.ts`), tree parser
- **integrations/parts-catalogs/** — PartsCatalogs API ("pcat")
- **integrations/p/** — OEM cross-reference ("P", TecDoc-derived MySQL db; `P_DB_*` env)
- **integrations/corgi/**, **vin-api/** — offline WMI lookup; NHTSA fallback
- **integrations/catalog-source-db/** — upstream source-DB bridge
- **catalog/** — catalog browse endpoints (`catalog`, `pcat-catalog`, `emex-catalog` controllers/services)
- **categories/**, **parts/**, **brands/** — category tree (partCount rollup, lazy drill/heal), parts, brand canonicalization
- **auth/** (better-auth cookie sessions), **users/**, **referrals/**
- **payments/** (Stripe), **subscriptions/**, **plans/**
- **jobs/** — BullMQ queues + processors: `catalog-prefetch`, `emex-scrape`, `translation`, `lifecycle-email`, `subscription-expiry`, `query-cleanup`
- **posthog/** — server-side flag eval + `kill-source-*` switches · **meta-capi/** — Meta Conversions API
- **notifications/** (Novu), **email/** (Postal), **chatwoot/**
- **analytics/**, **telemetry/** (OTel → OTLP), **common/** (incl. catalog-degradation → Sentry), **redis/**, **storage/** (S3/MinIO), **translations/** (OpenAI), **admin/**, **internal-admin/**, **blog/**, **changelog/**, **demo/**, **contact/**
- **config/** — `env.validation.ts` zod gate (beware `""` vs undefined), `configuration.ts`
- **database/** — Drizzle provider + `schema/` split: `core`, `pl24`, `emex`, `parts-catalogs`, `relations`
## Web (`apps/web/src/`)
- **routes/** — public: `index`, `pricing`, `blog`, `demo`, `about/contact/kvkk/privacy/terms` · `_auth/`: login, register, forgot/reset password, email-verified · `dashboard/`: `search` (main VIN flow), `vehicles_/$id[/categories_/$categoryId]`, catalog trees (`catalog_/$brandName/...`, `catalog_/pcat/...`, `catalog_/emex/...`), `oem.$code` (cross-ref), `subscription`, `billing`, `history`, `settings`, `service-test` (admin), `admin/*` (users, analytics, referrals, copy-logs)
- **lib/** — `api-client`, `auth-client`, `posthog`, `surveys` (API-mode survey renderer), `meta-pixel`, `faro`, `sentry`, `chatwoot`, `i18n`, `vehicle`
- **stores/** (zustand), **components/**, **remotion/**, **messages/** (i18n), **hooks/**
## Configuration
- `turbo.json`, `biome.json`, `tsconfig.json` — build/lint/typecheck pipeline
- `docker-compose.coolify.yml` — prod/staging: `api` + `worker` + `sase-redis` (Coolify env reaches a service ONLY if its `environment:` block references `${VAR}`)
- `Dockerfile` — multi-stage node:22-alpine + Chromium (runtime imports MUST be in `dependencies`)
- `ecosystem.config.js` — PM2 config
## Tests
- API: 30 `*.spec.ts` (Vitest) → `pnpm --filter api test`
- Web: 24 `*.test.ts(x)` (Vitest only; **no Playwright e2e**) → `pnpm --filter web test`; plus per-package `index.spec.ts`
- `qa/post-deploy/` smoke checks
## Key Dependencies
NestJS 10 · drizzle-orm 0.41 + postgres · better-auth · bullmq + ioredis · stripe · posthog-node · openai (translations) · undici (proxied fetch — global fetch lacks `dispatcher`) · Sentry + OpenTelemetry · mysql2 (P/TecDoc) ·· React 19 · TanStack Router/Query · zustand · posthog-js · @grafana/faro · remotion · zod throughout.
## Quick Start
1. `pnpm install`
2. `pnpm dev` (turbo: api :4000 + web :3000)
3. `pnpm lint` / `pnpm typecheck` / `pnpm test`
4. DB: `pnpm --filter api db:migrate` (custom runner) · `db:studio` · `db:seed`
## Deployment (Coolify — NEVER edit on server)
- Push `dev` → auto-deploys staging **dev.sase.tr** — verify every change here first
- Merge to `main` → auto-deploys prod **sase.tr** (only after dev verified)
## Docs Map
- `CLAUDE.md` / `AGENTS.md` — assistant instructions · `docs/INDEX.md` — internal docs index
- Root reports: `upstream-prob.md`, `undecoded-vin-rca.md`, `posthog-max-leverage.md`, `posthog-feature-roadmap.md`, `katalogayapi.md`, `knowledge.md`, `ralpRapor.md`, `emex-hierarchy-plan.md`, `MANIFEST*.md`, `RECOVERY-FN-412.md`, `SCOPING-FN-411.md`, `SASE_PRD_Hesap_Paylasimi_Onleme_v1.1.md`
- Business/ops workspace knowledge base: `/home/s/ss/INDEX.md`

View File

@@ -23,3 +23,8 @@ NOVU_API_KEY=
APP_PUBLIC_URL=https://sase.tr
# HMAC secret for signed track.sase.tr click links (Bitwarden "mailtrack tracking (track.sase.tr)"). Empty → no click tracking.
MAILTRACK_SECRET=
# Unsubscribe links (List-Unsubscribe header + mail footer). Empty secret → mailto-only header, no footer link (dev default).
UNSUBSCRIBE_SECRET=
# Defaults to <APP_PUBLIC_URL>/api/email/unsubscribe when empty.
UNSUBSCRIBE_URL_BASE=
UNSUBSCRIBE_EMAIL=unsubscribe@sase.tr

View File

@@ -0,0 +1,28 @@
-- Canonicalise vehicle brand casing + backfill missing brand_id.
--
-- Root cause: the decode persist (vehicles.service.ts) looked brands up with a
-- case-SENSITIVE `eq(brands.name, brandName)`. A decode source that yields an
-- uppercase brand string (e.g. "FORD", "MERCEDES-BENZ") missed the canonical
-- "Ford" / "Mercedes-Benz" row → brand_id stayed NULL and the raw uppercase
-- string was stored as brand_name. That split the same brand across casing
-- variants in analytics + the catalog ("Ford" vs "FORD"). The companion code
-- fix makes the lookup case-insensitive and stores the canonical name; this
-- migration repairs the rows already written.
--
-- Backfill every vehicle whose brand_name matches a canonical brand
-- case-insensitively: set the FK and the canonical-cased name. (catalog_vehicles
-- is intentionally untouched — its brand_name namespace does not match the
-- brands table, so 0 rows would qualify.)
UPDATE "vehicles" v
SET brand_id = b.id, brand_name = b.name
FROM "brands" b
WHERE v.brand_id IS NULL AND lower(v.brand_name) = lower(b.name);
--> statement-breakpoint
-- Defensive: realign any vehicle whose brand_id is set but whose denormalised
-- brand_name has drifted from the canonical brands.name (none today, but keeps
-- the FK and the denormalised name consistent going forward).
UPDATE "vehicles" v
SET brand_name = b.name
FROM "brands" b
WHERE v.brand_id = b.id AND v.brand_name <> b.name;

View File

@@ -0,0 +1,23 @@
-- Proxy telemetry: one row per proxied upstream attempt (pcat call/capture/
-- validate, emex http). Lets the Süper Panel grade proxy providers per service
-- (success rate, ban rate, latency) and track banned residential exit IPs /
-- sticky sessions. Written fire-and-forget by ProxyTelemetryService; 30-day
-- retention enforced by the query-cleanup job.
CREATE TABLE "proxy_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"service" varchar(30) NOT NULL,
"provider" varchar(20) NOT NULL,
"session_key" varchar(60),
"exit_ip" varchar(45),
"target_host" varchar(120),
"status_code" integer,
"error_kind" varchar(30),
"success" boolean DEFAULT false NOT NULL,
"banned" boolean DEFAULT false NOT NULL,
"duration_ms" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "proxy_logs_created_at_idx" ON "proxy_logs" ("created_at");
--> statement-breakpoint
CREATE INDEX "proxy_logs_banned_created_at_idx" ON "proxy_logs" ("banned","created_at");

View File

@@ -0,0 +1,37 @@
-- OEM community voting + gamification ledger.
-- oem_votes: one row per (user, oem_code) uyumlu/uyumsuz verdict cast from the
-- catalog part rows. Votes pool globally per code; part/vehicle/category are
-- analytics breadcrumbs only (catalog routes pass catalog_vehicles ids → no FK,
-- mirrors oem_code_copies). Re-votes update the row in place.
-- oem_vote_points: append-only, exactly one row per vote (vote_id unique),
-- written in the same transaction. points = 1 (vote) + 2 (agreed with strict
-- majority at cast time; first voter always 3). Never retro-adjusted.
CREATE TABLE "oem_votes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"oem_code" varchar(100) NOT NULL,
"vote" varchar(12) NOT NULL,
"part_id" uuid,
"vehicle_id" uuid,
"category_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "oem_vote_points" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"vote_id" uuid NOT NULL,
"oem_code" varchar(100) NOT NULL,
"points" integer NOT NULL,
"correct" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "oem_votes" ADD CONSTRAINT "oem_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_vote_id_oem_votes_id_fk" FOREIGN KEY ("vote_id") REFERENCES "public"."oem_votes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "oem_votes_user_oem_idx" ON "oem_votes" USING btree ("user_id","oem_code");--> statement-breakpoint
CREATE INDEX "oem_votes_oem_code_idx" ON "oem_votes" USING btree ("oem_code");--> statement-breakpoint
CREATE UNIQUE INDEX "oem_vote_points_vote_id_idx" ON "oem_vote_points" USING btree ("vote_id");--> statement-breakpoint
CREATE INDEX "oem_vote_points_user_id_idx" ON "oem_vote_points" USING btree ("user_id");

View File

@@ -0,0 +1,18 @@
-- Community OEM cross-reference suggestions, collected on the OEM detail page
-- ("muadil parça öner": brand + code). Primarily for codes the P snapshot has
-- no match for. suggested_code_norm (upper alphanumerics) powers grouping and
-- the per-user dedup; status is a moderation hook (active|hidden).
CREATE TABLE "oem_suggestions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"oem_code" varchar(100) NOT NULL,
"brand" varchar(80) NOT NULL,
"suggested_code" varchar(100) NOT NULL,
"suggested_code_norm" varchar(100) NOT NULL,
"status" varchar(16) DEFAULT 'active' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "oem_suggestions" ADD CONSTRAINT "oem_suggestions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "oem_suggestions_user_oem_code_idx" ON "oem_suggestions" USING btree ("user_id","oem_code","suggested_code_norm");--> statement-breakpoint
CREATE INDEX "oem_suggestions_oem_code_idx" ON "oem_suggestions" USING btree ("oem_code");

View File

@@ -0,0 +1,18 @@
-- Monthly Parça Uzmanları prizes: one row per (TR-month, rank). Written by the
-- expert-rewards cron (1st of month 00:00 Europe/Istanbul) when it closes the
-- finished season and grants the top 3 voters a subscription extension
-- (30/15/7 days). The unique index is the run-once guard — a re-run for the
-- same period inserts nothing, so days are never granted twice.
CREATE TABLE "oem_expert_rewards" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"period_start" timestamp with time zone NOT NULL,
"user_id" uuid NOT NULL,
"rank" integer NOT NULL,
"points" integer NOT NULL,
"reward_days" integer NOT NULL,
"granted_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "oem_expert_rewards" ADD CONSTRAINT "oem_expert_rewards_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "oem_expert_rewards_period_rank_idx" ON "oem_expert_rewards" USING btree ("period_start","rank");--> statement-breakpoint
CREATE INDEX "oem_expert_rewards_user_id_idx" ON "oem_expert_rewards" USING btree ("user_id");

View File

@@ -0,0 +1,25 @@
-- Tedarikçi fiyat geçmişi (parça kodu bazlı, takip MySQL'den beslenir).
-- part_price_tracks: hangi kod izleniyor (ilk seriyi API lazy-backfill yazar,
-- günlük cron sadece buradaki kodları tazeler). part_price_daily: (kod, kaynak,
-- gün) başına stoktaki tekliflerin p50/p95/p99'u + teklif sayısı. source şimdilik
-- hep 'supplier'; perakende verisi geldiğinde aynı tabloya 'retail' olarak girer.
-- Tedarikçi kimliği bilinçli olarak HİÇBİR kolonda yok.
CREATE TABLE "part_price_tracks" (
"code_norm" varchar(64) PRIMARY KEY 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
);
--> statement-breakpoint
CREATE TABLE "part_price_daily" (
"code_norm" varchar(64) 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_source_date_pk" PRIMARY KEY("code_norm","source","date")
);
--> statement-breakpoint
CREATE INDEX "part_price_daily_code_date_idx" ON "part_price_daily" USING btree ("code_norm","date");

View 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");

View File

@@ -0,0 +1,17 @@
-- Stripe recurring billing (Checkout mode:"subscription") bağlantı kolonları.
-- users.stripe_customer_id → sonraki checkout'lar aynı Stripe Customer'da
-- toplanır (kayıtlı kart + fatura geçmişi tek kayıtta).
-- user_subscriptions.stripe_subscription_id → yenileme faturaları
-- (invoice.paid/payment_failed webhook'ları) bizim abonelik satırına bu
-- kolondan çözülür; trial ve eski tek-seferlik satırlarda NULL kalır.
-- payments.stripe_invoice_id → invoice.paid webhook retry'larında mükerrer
-- gelir kaydını önleyen dedupe anahtarı.
ALTER TABLE "users" ADD COLUMN "stripe_customer_id" text;
--> statement-breakpoint
ALTER TABLE "user_subscriptions" ADD COLUMN "stripe_subscription_id" text;
--> statement-breakpoint
ALTER TABLE "payments" ADD COLUMN "stripe_invoice_id" text;
--> statement-breakpoint
CREATE INDEX "user_subscriptions_stripe_sub_idx" ON "user_subscriptions" USING btree ("stripe_subscription_id");
--> statement-breakpoint
CREATE INDEX "payments_stripe_invoice_id_idx" ON "payments" USING btree ("stripe_invoice_id");

View File

@@ -0,0 +1,30 @@
-- Vinpin ePER decode-oracle cache (feature-flagged: VINPIN_ENABLED).
-- One row per VIN. The vinpin-decode BullMQ job drives the Vinpin ePER web
-- catalog for Fiat VINs the normal chain (PL24/pcat/emex) can't identify, then
-- matches the decoded model to an EXISTING PL24 catalog_vehicle. Vinpin is ONLY
-- a decode oracle — parts are always served from PL24's existing catalog.
-- status: 'pending' | 'decoded' | 'not_found' | 'failed'.
CREATE TABLE IF NOT EXISTS "vinpin_decodes" (
"vin" text PRIMARY KEY NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"brand_name" text,
"model" text,
"sincom" text,
"trim" text,
"model_year" text,
"engine" text,
"catalog_vehicle_id" uuid,
"raw" jsonb,
"attempts" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone,
"decoded_at" timestamp with time zone
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "vinpin_decodes" ADD CONSTRAINT "vinpin_decodes_catalog_vehicle_id_catalog_vehicles_id_fk" FOREIGN KEY ("catalog_vehicle_id") REFERENCES "public"."catalog_vehicles"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "vinpin_decodes_status_idx" ON "vinpin_decodes" USING btree ("status");

View File

@@ -0,0 +1,34 @@
-- Discount codes for checkout promotions. Each row mirrors a Stripe Coupon +
-- Promotion Code; Stripe enforces the redemption cap / expiry at checkout while
-- this table powers the admin panel's listing + usage tracking and lets us
-- preview a code without a per-render Stripe call. Amounts in kuruş (TRY
-- smallest unit), matching plans.price* / payments.amount.
CREATE TABLE IF NOT EXISTS "discount_codes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" varchar(50) NOT NULL,
"stripe_coupon_id" text,
"stripe_promotion_code_id" text,
"discount_type" varchar(12) NOT NULL,
"percent_off" integer,
"amount_off" integer,
"duration" varchar(12) DEFAULT 'once' NOT NULL,
"duration_in_months" integer,
"max_redemptions" integer,
"times_redeemed" integer DEFAULT 0 NOT NULL,
"expires_at" timestamp with time zone,
"is_active" boolean DEFAULT true NOT NULL,
"note" text,
"created_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "discount_codes" ADD CONSTRAINT "discount_codes_created_by_users_id_fk"
FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "discount_codes_code_idx" ON "discount_codes" USING btree ("code");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "discount_codes_active_idx" ON "discount_codes" USING btree ("is_active");

View File

@@ -0,0 +1,38 @@
-- Public API anahtarları — better-auth `apiKey` plugin'inin tablosu (model
-- "apikey", usePlural → "apikeys"). Kolon seti plugin şemasının birebir
-- karşılığı; "key" kolonu secret'ın SHA256 hash'i (düz metin sadece üretim
-- anında bir kez gösterilir). "metadata" JSON string (dailyDecodeLimit gibi
-- anahtar-bazlı limitler burada taşınır).
CREATE TABLE IF NOT EXISTS "apikeys" (
"id" text PRIMARY KEY NOT NULL,
"name" text,
"start" text,
"prefix" text,
"key" text NOT NULL,
"user_id" uuid NOT NULL,
"refill_interval" integer,
"refill_amount" integer,
"last_refill_at" timestamp with time zone,
"enabled" boolean DEFAULT true NOT NULL,
"rate_limit_enabled" boolean DEFAULT true NOT NULL,
"rate_limit_time_window" integer,
"rate_limit_max" integer,
"request_count" integer DEFAULT 0 NOT NULL,
"remaining" integer,
"last_request" timestamp with time zone,
"expires_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"permissions" text,
"metadata" text
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "apikeys" ADD CONSTRAINT "apikeys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "apikeys_key_idx" ON "apikeys" ("key");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "apikeys_user_id_idx" ON "apikeys" ("user_id");

View File

@@ -0,0 +1,70 @@
-- Unified cross-source parts taxonomy. The raw per-source category trees
-- (categories.parent_id chains + pl24/emex/pcat mirrors) are left untouched;
-- this adds an orthogonal canonical spine so every manufacturer's OEM microfiche
-- can be browsed/searched under one set of headings. See schema/core.ts for the
-- design rationale (name-level mapping, not row-level).
CREATE TABLE IF NOT EXISTS "canonical_categories" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"parent_id" uuid,
"slug" varchar(100) NOT NULL,
"name" varchar(200) NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "canonical_categories_slug_idx" ON "canonical_categories" ("slug");
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "category_canonical_map" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"source" varchar(20) NOT NULL,
"normalized_name" varchar(500) NOT NULL,
"canonical_category_id" uuid NOT NULL,
"method" varchar(20) DEFAULT 'keyword' NOT NULL,
"confidence" numeric(4,3),
"is_manual" boolean DEFAULT false NOT NULL,
"ambiguous" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "category_canonical_map" ADD CONSTRAINT "category_canonical_map_canonical_id_fk"
FOREIGN KEY ("canonical_category_id") REFERENCES "public"."canonical_categories"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "category_canonical_map_source_name_idx" ON "category_canonical_map" ("source", "normalized_name");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "category_canonical_map_canonical_idx" ON "category_canonical_map" ("canonical_category_id");
--> statement-breakpoint
ALTER TABLE "categories" ADD COLUMN IF NOT EXISTS "canonical_category_id" uuid;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "categories_canonical_category_id_idx" ON "categories" ("canonical_category_id");
--> statement-breakpoint
-- Seed the top-level canonical taxonomy (18 buckets: aloparca's 17 retail tops
-- + "İç Döşeme ve Koltuk" which retail sites don't carry but OEM catalogs do —
-- ~16% of parts). Idempotent via ON CONFLICT on the unique slug.
INSERT INTO "canonical_categories" ("slug", "name", "sort_order") VALUES
('motor', 'Motor', 1),
('yakit-sistemi', 'Yakıt Sistemi', 2),
('atesleme-sistemi', 'Ateşleme Sistemi', 3),
('triger-zincir-rulman', 'Triger, Zincir ve Rulman', 4),
('sogutma', 'Soğutma ve Isıtma', 5),
('filtreler', 'Filtreler', 6),
('fren', 'Fren', 7),
('debriyaj', 'Debriyaj', 8),
('sanziman', 'Şanzıman ve Diferansiyel', 9),
('suspansiyon', 'Süspansiyon', 10),
('direksiyon', 'Direksiyon', 11),
('elektrik', 'Elektrik', 12),
('aydinlatma', 'Aydınlatma', 13),
('dis-kaporta', 'Dış Kaporta Aksamı', 14),
('tampon', 'Tampon ve Aksamı', 15),
('panjur-panel', 'Panjur ve Panel', 16),
('ic-doseme', 'İç Döşeme ve Koltuk', 17),
('yaglar-sivilar', 'Yağlar ve Sıvılar', 18)
ON CONFLICT ("slug") DO NOTHING;

View File

@@ -0,0 +1,7 @@
-- Two more canonical top buckets the OEM catalogs need but the retail-derived
-- seed lacked: "Jant ve Lastik" (pcat "Jantlar, Lastikler" appears on ~every
-- vehicle) and "Aksesuar". Idempotent via the unique slug.
INSERT INTO "canonical_categories" ("slug", "name", "sort_order") VALUES
('jant-lastik', 'Jant ve Lastik', 19),
('aksesuar', 'Aksesuar', 20)
ON CONFLICT ("slug") DO NOTHING;

View File

@@ -0,0 +1,294 @@
-- 0026: second-level canonical sub-categories (otoparcasan-derived)
ALTER TABLE "categories" ADD COLUMN IF NOT EXISTS "canonical_subcategory_id" uuid;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "categories_canonical_subcategory_id_idx" ON "categories" ("canonical_subcategory_id");
--> statement-breakpoint
ALTER TABLE "category_canonical_map" ADD COLUMN IF NOT EXISTS "canonical_subcategory_id" uuid;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-abs', 'ABS Sensörü / Ünitesi', 1 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-balata', 'Balata', 2 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-disk', 'Fren Diski / Ayna', 3 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-kaliper', 'Kaliper', 4 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-kampana', 'Kampana', 5 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-merkez', 'Ana Merkez / Silindir', 6 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-guclendirici', 'Fren Güçlendirici (Servo)', 7 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-hidrolik', 'Hortum / Boru / Hidrolik', 8 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-elfren', 'El Freni', 9 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-musur', 'Fren Müşürü / İkaz', 10 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-vakum', 'Vakum Pompası', 11 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-pedal', 'Fren Pedalı', 12 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-donanim', 'Bağlantı ve Donanım', 13 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-balata', 'Debriyaj Balatası', 1 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-baski', 'Baskı', 2 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-set', 'Debriyaj Seti', 3 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-rulman', 'Debriyaj Rulmanı', 4 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-merkez', 'Debriyaj Merkezi', 5 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-volan', 'Volan', 6 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-catal', 'Çatal / Bilya', 7 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-tel', 'Debriyaj Teli / Pedalı', 8 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-donanim', 'Bağlantı ve Donanım', 9 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-silindirkapak', 'Silindir Kapağı', 1 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-piston', 'Piston / Segman / Gömlek', 2 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-krank', 'Krank / Biyel', 3 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-supap', 'Supap / Külbütör', 4 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-conta', 'Conta', 5 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-kece', 'Keçe', 6 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-karter', 'Karter', 7 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-yagpompa', 'Yağ Pompası', 8 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-takoz', 'Motor Takozu', 9 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-turbo', 'Turbo', 10 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-manifold', 'Manifold', 11 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-komple', 'Motor Komple / Blok', 12 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-donanim', 'Bağlantı ve Donanım', 13 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-enjektor', 'Enjektör', 1 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-pompa', 'Yakıt Pompası', 2 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-kelebek', 'Gaz Kelebeği', 3 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-depo', 'Yakıt Deposu / Şamandıra', 4 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-boru', 'Yakıt Borusu', 5 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-egr', 'EGR', 6 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-karburator', 'Karbüratör', 7 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-egzoz', 'Egzoz', 8 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-sistemi-donanim', 'Bağlantı ve Donanım', 9 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-buji', 'Buji', 1 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-bobin', 'Ateşleme Bobini', 2 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-kablo', 'Buji Kablosu', 3 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-distributor', 'Distribütör', 4 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-kayis', 'Triger Kayışı / Seti', 1 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-zincir', 'Triger Zinciri', 2 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-gergi', 'Gergi / Rulman', 3 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-radyator', 'Su Radyatörü', 1 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-termostat', 'Termostat', 2 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-supompa', 'Devirdaim / Su Pompası', 3 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-klima', 'Klima (Kompresör/Radyatör)', 4 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-kalorifer', 'Kalorifer', 5 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-fan', 'Fan / Pervane', 6 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-hortum', 'Su Hortumu', 7 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-musur', 'Hararet / Fan Müşürü', 8 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-intercooler', 'Intercooler / Turbo Radyatör', 9 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-donanim', 'Bağlantı ve Donanım', 10 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-yag', 'Yağ Filtresi', 1 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-hava', 'Hava Filtresi', 2 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-yakit', 'Yakıt Filtresi', 3 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-polen', 'Polen / Kabin Filtresi', 4 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-diger', 'Diğer Filtre', 5 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-amortisor', 'Amortisör', 1 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-salincak', 'Salıncak', 2 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rotil', 'Rotil', 3 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rot', 'Rot / Rot Başı', 4 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-viraj', 'Viraj Demiri / Z Rot', 5 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-helezon', 'Helezon Yay', 6 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rulman', 'Aks / Porya Rulmanı', 7 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-aks', 'Aks / Aktarma', 8 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-burc', 'Burç / Takoz', 9 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-bijon', 'Bijon', 10 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'suspansiyon-donanim', 'Bağlantı ve Donanım', 11 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-pompa', 'Direksiyon Pompası', 1 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-kutu', 'Direksiyon Kutusu / Kremayer', 2 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-airbag', 'Airbag', 3 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-koruk', 'Direksiyon Körüğü', 4 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-mil', 'Direksiyon Mili / Kolu', 5 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-vites', 'Vites Kolu / Mekanizması', 1 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-takoz', 'Şanzıman Takozu', 2 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-saft', 'Şaft / Mafsal', 3 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-diferansiyel', 'Diferansiyel', 4 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-sensor', 'Hız / Km Sensörü', 5 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-karter', 'Şanzıman Karteri / Yağ', 6 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-donanim', 'Bağlantı ve Donanım', 7 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-aku', 'Akü', 1 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-alternator', 'Alternatör', 2 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-mars', 'Marş Motoru', 3 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-sensor', 'Sensör', 4 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-sigorta', 'Sigorta / Röle', 5 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-motor', 'Cam / Silecek Motoru', 6 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-kontak', 'Kontak / Marş Anahtarı', 7 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-modul', 'Modül / Beyin / ECU', 8 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-kablo', 'Kablo / Tesisat', 9 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-donanim', 'Bağlantı ve Donanım', 10 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-far', 'Far', 1 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-stop', 'Stop Lambası', 2 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-sinyal', 'Sinyal', 3 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-sis', 'Sis Farı', 4 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-plaka', 'Plaka Lambası', 5 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-ic', 'İç / Tavan Lambası', 6 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-ampul', 'Ampul', 7 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kapi', 'Kapı / Menteşe / Kilit', 1 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-ayna', 'Dikiz Aynası', 2 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-cam', 'Cam / Kriko / Fitil', 3 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-bagaj', 'Bagaj Kapağı', 4 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kaput', 'Motor Kaputu', 5 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kol', 'Kapı Kolu', 6 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-camurluk', 'Çamurluk', 1 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-panjur', 'Panjur / Ön Panel', 2 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-marspiyel', 'Marşpiyel', 3 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-farcerceve', 'Far Çerçevesi / Sacı', 4 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-travers', 'Travers / Takviye', 5 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-etek', 'Etek Sacı / Muhafaza', 6 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-tampon', 'Tampon', 1 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-demir', 'Tampon Demiri / Travers', 2 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-izgara', 'Izgara / Panjur', 3 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-braket', 'Braket / Sac', 4 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-cita', 'Çıta / Kaplama', 5 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-koltuk', 'Koltuk / Kılıf', 1 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-torpido', 'Torpido / Konsol', 2 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-tavan', 'Tavan Döşeme', 3 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-kemer', 'Emniyet Kemeri', 4 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-kolcak', 'Kolçak / Bardaklık', 5 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-paspas', 'Paspas / Halı', 6 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-motor', 'Motor Yağı', 1 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-sanziman', 'Şanzıman / Dif. Yağı', 2 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-fren', 'Fren Hidroliği', 3 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-direksiyon', 'Direksiyon Yağı', 4 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-antifriz', 'Antifriz', 5 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-gres', 'Gres', 6 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-adblue', 'AdBlue', 7 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-katki', 'Katkı / Sprey', 8 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-jant', 'Jant', 1 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-tire', 'Lastik', 2 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-kapak', 'Jant Kapağı / Bijon', 3 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-sibop', 'Sibop', 4 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-rulman', 'Teker Rulmanı / Göbek', 5 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-zincir', 'Kar Zinciri', 6 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint

View File

@@ -0,0 +1,301 @@
-- 0027: additional sub-leaves for weak buckets (yaglar/panjur/direksiyon)
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-abs', 'ABS Sensörü / Ünitesi', 1 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-balata', 'Balata', 2 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-disk', 'Fren Diski / Ayna', 3 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-kaliper', 'Kaliper', 4 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-kampana', 'Kampana', 5 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-merkez', 'Ana Merkez / Silindir', 6 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-guclendirici', 'Fren Güçlendirici (Servo)', 7 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-hidrolik', 'Hortum / Boru / Hidrolik', 8 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-elfren', 'El Freni', 9 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-musur', 'Fren Müşürü / İkaz', 10 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-vakum', 'Vakum Pompası', 11 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-pedal', 'Fren Pedalı', 12 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-donanim', 'Bağlantı ve Donanım', 13 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-balata', 'Debriyaj Balatası', 1 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-baski', 'Baskı', 2 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-set', 'Debriyaj Seti', 3 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-rulman', 'Debriyaj Rulmanı', 4 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-merkez', 'Debriyaj Merkezi / Hidrolik', 5 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-volan', 'Volan', 6 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-catal', 'Çatal / Bilya', 7 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-tel', 'Debriyaj Teli / Pedalı', 8 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-donanim', 'Bağlantı ve Donanım', 9 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-silindirkapak', 'Silindir Kapağı', 1 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-piston', 'Piston / Segman / Gömlek', 2 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-krank', 'Krank / Biyel', 3 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-supap', 'Supap / Külbütör', 4 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-conta', 'Conta', 5 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-kece', 'Keçe', 6 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-karter', 'Karter', 7 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-yagpompa', 'Yağ Pompası', 8 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-takoz', 'Motor Takozu', 9 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-turbo', 'Turbo', 10 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-manifold', 'Manifold', 11 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-komple', 'Motor Komple / Blok', 12 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-donanim', 'Bağlantı ve Donanım', 13 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-enjektor', 'Enjektör', 1 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-pompa', 'Yakıt Pompası', 2 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-kelebek', 'Gaz Kelebeği', 3 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-depo', 'Yakıt Deposu / Şamandıra', 4 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-boru', 'Yakıt Borusu', 5 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-egr', 'EGR', 6 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-karburator', 'Karbüratör', 7 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-egzoz', 'Egzoz', 8 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-sistemi-donanim', 'Bağlantı ve Donanım', 9 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-buji', 'Buji', 1 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-bobin', 'Ateşleme Bobini', 2 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-kablo', 'Buji Kablosu', 3 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-distributor', 'Distribütör', 4 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-kayis', 'Triger Kayışı / Seti', 1 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-zincir', 'Triger Zinciri', 2 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-gergi', 'Gergi / Rulman', 3 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-radyator', 'Su Radyatörü', 1 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-termostat', 'Termostat', 2 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-supompa', 'Devirdaim / Su Pompası', 3 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-klima', 'Klima (Kompresör/Radyatör)', 4 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-kalorifer', 'Kalorifer', 5 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-fan', 'Fan / Pervane', 6 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-hortum', 'Su Hortumu', 7 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-musur', 'Hararet / Fan Müşürü', 8 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-intercooler', 'Intercooler / Turbo Radyatör', 9 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-donanim', 'Bağlantı ve Donanım', 10 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-yag', 'Yağ Filtresi', 1 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-hava', 'Hava Filtresi', 2 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-yakit', 'Yakıt Filtresi', 3 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-polen', 'Polen / Kabin Filtresi', 4 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-diger', 'Diğer Filtre', 5 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-amortisor', 'Amortisör', 1 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-salincak', 'Salıncak', 2 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rotil', 'Rotil', 3 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rot', 'Rot / Rot Başı', 4 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-viraj', 'Viraj Demiri / Z Rot', 5 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-helezon', 'Helezon Yay', 6 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rulman', 'Aks / Porya Rulmanı', 7 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-aks', 'Aks / Aktarma', 8 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-burc', 'Burç / Takoz', 9 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-bijon', 'Bijon', 10 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'suspansiyon-donanim', 'Bağlantı ve Donanım', 11 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-simit', 'Direksiyon Simidi', 1 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-pompa', 'Direksiyon Pompası / Hidrolik', 2 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-kutu', 'Direksiyon Kutusu / Dişli / Kremayer', 3 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-airbag', 'Airbag', 4 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-koruk', 'Direksiyon Körüğü', 5 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-elektronik', 'Kumanda / Sensör', 6 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-mil', 'Direksiyon Mili / Boğaz / Göbek', 7 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-vites', 'Vites Kolu / Mekanizması', 1 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-takoz', 'Şanzıman Takozu', 2 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-saft', 'Şaft / Mafsal', 3 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-diferansiyel', 'Diferansiyel', 4 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-sensor', 'Hız / Km Sensörü', 5 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-karter', 'Şanzıman Karteri / Yağ', 6 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-donanim', 'Bağlantı ve Donanım', 7 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-aku', 'Akü', 1 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-alternator', 'Alternatör', 2 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-mars', 'Marş Motoru', 3 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-sensor', 'Sensör', 4 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-sigorta', 'Sigorta / Röle', 5 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-motor', 'Cam / Silecek Motoru', 6 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-kontak', 'Kontak / Marş Anahtarı', 7 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-modul', 'Modül / Beyin / ECU', 8 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-kablo', 'Kablo / Tesisat', 9 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-donanim', 'Bağlantı ve Donanım', 10 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-far', 'Far', 1 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-stop', 'Stop Lambası', 2 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-sinyal', 'Sinyal', 3 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-sis', 'Sis Farı', 4 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-plaka', 'Plaka Lambası', 5 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-ic', 'İç / Tavan Lambası', 6 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-ampul', 'Ampul', 7 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kapi', 'Kapı / Menteşe / Kilit', 1 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-ayna', 'Dikiz Aynası', 2 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-cam', 'Cam / Kriko / Fitil', 3 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-bagaj', 'Bagaj Kapağı', 4 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kaput', 'Motor Kaputu', 5 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kol', 'Kapı Kolu', 6 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-camurluk', 'Çamurluk / Kanat', 1 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-kaput', 'Kaput / Kaporta', 2 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-tavan', 'Tavan', 3 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-sasi', 'Şasi / Alt Panel', 4 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-cam', 'Cam / Karo', 5 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-panjur', 'Panjur / Ön Panel', 6 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-marspiyel', 'Marşpiyel', 7 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-farcerceve', 'Far Çerçevesi / Sacı', 8 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-travers', 'Travers / Takviye', 9 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-etek', 'Etek Sacı / Muhafaza', 10 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-tampon', 'Tampon', 1 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-demir', 'Tampon Demiri / Travers', 2 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-izgara', 'Izgara / Panjur', 3 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-braket', 'Braket / Sac', 4 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-cita', 'Çıta / Kaplama', 5 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-koltuk', 'Koltuk / Kılıf', 1 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-torpido', 'Torpido / Konsol', 2 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-tavan', 'Tavan Döşeme', 3 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-kemer', 'Emniyet Kemeri', 4 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-kolcak', 'Kolçak / Bardaklık', 5 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-paspas', 'Paspas / Halı', 6 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-motor', 'Motor Yağı', 1 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-sanziman', 'Şanzıman / Dif. Yağı', 2 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-fren', 'Fren Hidroliği', 3 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-direksiyon', 'Direksiyon Yağı', 4 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-antifriz', 'Antifriz', 5 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-gres', 'Gres', 6 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-adblue', 'AdBlue', 7 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-katki', 'Katkı / Sprey', 8 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-sistemi', 'Yağlama Sistemi (Pompa/Çubuk/Boru)', 9 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-jant', 'Jant', 1 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-tire', 'Lastik', 2 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-kapak', 'Jant Kapağı / Bijon', 3 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-sibop', 'Sibop', 4 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-rulman', 'Teker Rulmanı / Göbek', 5 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-zincir', 'Kar Zinciri', 6 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint

View File

@@ -0,0 +1,305 @@
-- 0028: sub-leaves for queue-shrink additions (silecek, korna + kw)
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-abs', 'ABS Sensörü / Ünitesi', 1 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-balata', 'Balata', 2 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-disk', 'Fren Diski / Ayna', 3 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-kaliper', 'Kaliper', 4 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-kampana', 'Kampana', 5 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-merkez', 'Ana Merkez / Silindir', 6 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-guclendirici', 'Fren Güçlendirici (Servo)', 7 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-hidrolik', 'Hortum / Boru / Hidrolik', 8 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-elfren', 'El Freni', 9 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-musur', 'Fren Müşürü / İkaz', 10 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-vakum', 'Vakum Pompası', 11 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-pedal', 'Fren Pedalı', 12 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-donanim', 'Bağlantı ve Donanım', 13 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-balata', 'Debriyaj Balatası', 1 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-baski', 'Baskı', 2 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-set', 'Debriyaj Seti', 3 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-rulman', 'Debriyaj Rulmanı', 4 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-merkez', 'Debriyaj Merkezi / Hidrolik', 5 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-volan', 'Volan', 6 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-catal', 'Çatal / Bilya', 7 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-tel', 'Debriyaj Teli / Pedalı', 8 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-donanim', 'Bağlantı ve Donanım', 9 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-silindirkapak', 'Silindir Kapağı', 1 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-piston', 'Piston / Segman / Gömlek', 2 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-krank', 'Krank / Biyel', 3 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-supap', 'Supap / Külbütör', 4 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-conta', 'Conta', 5 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-kece', 'Keçe', 6 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-karter', 'Karter', 7 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-yagpompa', 'Yağ Pompası', 8 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-takoz', 'Motor Takozu', 9 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-turbo', 'Turbo', 10 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-manifold', 'Manifold', 11 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-komple', 'Motor Komple / Blok / Takım', 12 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-donanim', 'Bağlantı ve Donanım', 13 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-enjektor', 'Enjektör', 1 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-pompa', 'Yakıt Pompası', 2 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-kelebek', 'Gaz Kelebeği', 3 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-depo', 'Yakıt Deposu / Şamandıra', 4 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-boru', 'Yakıt Borusu', 5 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-egr', 'EGR', 6 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-karburator', 'Karbüratör', 7 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-egzoz', 'Egzoz / Lambda', 8 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-sistemi-donanim', 'Bağlantı ve Donanım', 9 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-buji', 'Buji', 1 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-bobin', 'Ateşleme Bobini', 2 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-kablo', 'Buji Kablosu', 3 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-distributor', 'Distribütör', 4 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-kayis', 'Triger Kayışı / Seti', 1 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-zincir', 'Triger Zinciri', 2 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-gergi', 'Gergi / Rulman', 3 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-radyator', 'Radyatör / Soğutucu', 1 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-termostat', 'Termostat', 2 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-supompa', 'Devirdaim / Su Pompası', 3 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-klima', 'Klima (Kompresör/Radyatör)', 4 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-kalorifer', 'Kalorifer / Havalandırma / Isıtma', 5 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-fan', 'Fan / Pervane', 6 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-hortum', 'Su Hortumu', 7 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-musur', 'Hararet / Fan Müşürü', 8 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-intercooler', 'Intercooler / Turbo Radyatör', 9 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-donanim', 'Bağlantı ve Donanım', 10 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-yag', 'Yağ Filtresi', 1 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-hava', 'Hava Filtresi', 2 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-yakit', 'Yakıt Filtresi', 3 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-polen', 'Polen / Kabin Filtresi', 4 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-diger', 'Diğer Filtre', 5 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-amortisor', 'Amortisör', 1 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-salincak', 'Salıncak', 2 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rotil', 'Rotil', 3 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rot', 'Rot / Rot Başı', 4 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-viraj', 'Viraj Demiri / Z Rot / Burkma', 5 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-helezon', 'Helezon Yay', 6 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rulman', 'Aks / Porya Rulmanı', 7 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-aks', 'Aks / Aktarma', 8 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-burc', 'Burç / Takoz / Pim', 9 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-bijon', 'Bijon', 10 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'suspansiyon-donanim', 'Bağlantı ve Donanım', 11 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-simit', 'Direksiyon Simidi', 1 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-pompa', 'Direksiyon Pompası / Hidrolik', 2 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-kutu', 'Direksiyon Kutusu / Dişli / Kremayer', 3 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-airbag', 'Airbag', 4 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-koruk', 'Direksiyon Körüğü', 5 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-elektronik', 'Kumanda / Sensör', 6 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-mil', 'Direksiyon Mili / Boğaz / Göbek', 7 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-vites', 'Vites / İç Kontroller', 1 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-takoz', 'Şanzıman Takozu', 2 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-saft', 'Şaft / Mafsal', 3 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-diferansiyel', 'Diferansiyel', 4 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-sensor', 'Hız / Km Sensörü', 5 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-karter', 'Şanzıman Karteri / Yağ', 6 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-donanim', 'Bağlantı ve Donanım', 7 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-aku', 'Akü', 1 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-alternator', 'Alternatör', 2 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-mars', 'Marş Motoru', 3 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-sensor', 'Sensör', 4 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-sigorta', 'Sigorta / Röle', 5 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-motor', 'Cam / Silecek Motoru', 6 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-silecek', 'Silecek', 7 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-korna', 'Korna', 8 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-kontak', 'Kontak / Marş Anahtarı', 9 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-modul', 'Modül / Gösterge / Elektronik', 10 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-kablo', 'Kablo / Tesisat', 11 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-donanim', 'Bağlantı ve Donanım', 12 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-far', 'Far', 1 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-stop', 'Stop Lambası', 2 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-sinyal', 'Sinyal', 3 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-sis', 'Sis Farı', 4 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-plaka', 'Plaka Lambası', 5 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-ic', 'İç / Tavan Lambası', 6 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-ampul', 'Ampul', 7 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kapi', 'Kapı / Menteşe / Kilit', 1 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-ayna', 'Dikiz Aynası', 2 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-cam', 'Cam / Kriko / Fitil', 3 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-bagaj', 'Bagaj Kapağı', 4 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kaput', 'Motor Kaputu', 5 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kol', 'Kapı Kolu', 6 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-camurluk', 'Çamurluk / Kanat', 1 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-kaput', 'Kaput / Kaporta', 2 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-tavan', 'Tavan', 3 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-sasi', 'Şasi / Kiriş / Alt Panel', 4 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-cam', 'Cam / Karo', 5 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-panjur', 'Panjur / Ön Panel', 6 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-marspiyel', 'Marşpiyel', 7 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-farcerceve', 'Far Çerçevesi / Sacı', 8 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-travers', 'Travers / Takviye', 9 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-etek', 'Etek Sacı / Muhafaza', 10 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-tampon', 'Tampon', 1 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-demir', 'Tampon Demiri / Travers', 2 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-izgara', 'Izgara / Panjur', 3 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-braket', 'Braket / Sac', 4 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-cita', 'Çıta / Kaplama', 5 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-koltuk', 'Koltuk / Kılıf', 1 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-torpido', 'Torpido / Konsol', 2 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-tavan', 'Tavan Döşeme / Güneşlik', 3 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-kemer', 'Emniyet Kemeri', 4 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-kolcak', 'Kolçak / Bardaklık', 5 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-paspas', 'Paspas / Halı', 6 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-motor', 'Motor Yağı', 1 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-sanziman', 'Şanzıman / Dif. Yağı', 2 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-fren', 'Fren Hidroliği', 3 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-direksiyon', 'Direksiyon Yağı', 4 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-antifriz', 'Antifriz', 5 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-gres', 'Gres', 6 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-adblue', 'AdBlue', 7 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-katki', 'Katkı / Sprey', 8 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-sistemi', 'Yağlama Sistemi (Pompa/Tava/Keçe)', 9 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-jant', 'Jant', 1 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-tire', 'Lastik', 2 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-kapak', 'Jant Kapağı / Bijon', 3 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-sibop', 'Sibop', 4 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-rulman', 'Teker Rulmanı / Göbek', 5 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-zincir', 'Kar Zinciri', 6 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
--> statement-breakpoint

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS "canonical_structure_template" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"source" varchar(20) NOT NULL,
"brand" varchar(120) NOT NULL,
"canonical_category_id" uuid NOT NULL,
"canonical_subcategory_id" uuid,
"vehicle_count" integer DEFAULT 0 NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "canonical_structure_template_source_brand_idx" ON "canonical_structure_template" ("source","brand");

View File

@@ -0,0 +1,91 @@
-- 0030: merge duplicate categories, then enforce uniqueness with NULLS NOT DISTINCT.
--
-- Root cause: categories_vehicle_name_source_idx (vehicle_id, catalog_vehicle_id,
-- name, source) treats NULLs as distinct, and emex/pcat rows have NULL
-- catalog_vehicle_id (pl24 rows have NULL vehicle_id) — so decode retries slipped
-- past every onConflictDoNothing and re-inserted the same category (+3k/day).
--
-- This migration (idempotent):
-- 1. Iteratively merges duplicate groups (same vehicle_id, catalog_vehicle_id,
-- name, source — NULLs equal): winner = most children, then most parts,
-- then oldest. Children are reparented (which can create next-level dupes —
-- hence the loop), parts are repointed with parts_dedup_idx-safe staging,
-- schema_pics / oem_code_copies / oem_votes are repointed, losers deleted.
-- 2. Swaps the unique index to NULLS NOT DISTINCT (PG15+; prod is PG17), which
-- makes all existing onConflictDoNothing inserts actually dedupe.
DO $$
DECLARE
merged integer;
pass integer := 0;
BEGIN
LOOP
pass := pass + 1;
CREATE TEMP TABLE dedup_map ON COMMIT DROP AS
WITH r AS (
SELECT id,
first_value(id) OVER w AS winner,
row_number() OVER w AS rn
FROM categories c
WINDOW w AS (
PARTITION BY vehicle_id, catalog_vehicle_id, name, source
ORDER BY (EXISTS(SELECT 1 FROM categories ch WHERE ch.parent_id = c.id)) DESC,
(SELECT count(*) FROM parts p WHERE p.category_id = c.id) DESC,
created_at, id
)
)
SELECT id AS loser, winner FROM r WHERE rn > 1;
SELECT count(*) INTO merged FROM dedup_map;
IF merged = 0 THEN
DROP TABLE dedup_map;
EXIT;
END IF;
RAISE NOTICE 'dedup pass %: merging % duplicate categories', pass, merged;
-- reparent children of losers onto the winner (may create next-level dupes)
UPDATE categories SET parent_id = m.winner FROM dedup_map m
WHERE categories.parent_id = m.loser;
-- repoint parts: stage with row_number over the TARGET dedup key so no two
-- moved rows collide with each other; skip ones colliding with the winner's
-- existing parts; delete the remainder (exact duplicates).
CREATE TEMP TABLE dedup_mv ON COMMIT DROP AS
SELECT p.id, m.winner,
row_number() OVER (
PARTITION BY p.vehicle_id, m.winner, p.oem_code, p.name, p."position"
ORDER BY p.id
) AS rn
FROM parts p JOIN dedup_map m ON m.loser = p.category_id;
UPDATE parts SET category_id = mv.winner FROM dedup_mv mv
WHERE parts.id = mv.id AND mv.rn = 1
AND NOT EXISTS (
SELECT 1 FROM parts w
WHERE w.category_id = mv.winner
AND w.vehicle_id IS NOT DISTINCT FROM parts.vehicle_id
AND w.oem_code IS NOT DISTINCT FROM parts.oem_code
AND w.name IS NOT DISTINCT FROM parts.name
AND w."position" IS NOT DISTINCT FROM parts."position"
);
DELETE FROM parts USING dedup_map m WHERE parts.category_id = m.loser;
UPDATE schema_pics SET category_id = m.winner FROM dedup_map m
WHERE schema_pics.category_id = m.loser;
UPDATE oem_code_copies SET category_id = m.winner FROM dedup_map m
WHERE oem_code_copies.category_id = m.loser;
UPDATE oem_votes SET category_id = m.winner FROM dedup_map m
WHERE oem_votes.category_id = m.loser;
DELETE FROM categories USING dedup_map m WHERE categories.id = m.loser;
DROP TABLE dedup_map;
DROP TABLE dedup_mv;
END LOOP;
RAISE NOTICE 'dedup complete after % pass(es)', pass;
END $$;
--> statement-breakpoint
DROP INDEX IF EXISTS "categories_vehicle_name_source_idx";
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "categories_vehicle_name_source_idx"
ON "categories" ("vehicle_id", "catalog_vehicle_id", "name", "source") NULLS NOT DISTINCT;

View File

@@ -0,0 +1,17 @@
-- 0031: brand-template ENTRY names — the browsable source-category names every
-- vehicle of (source, brand) shares. Lets a freshly-decoded vehicle show the
-- FULL canonical structure (named entries under each bucket) before its own
-- deep drill; only part counts/schemas are vehicle-specific. Rebuilt by the
-- global canonical-backfill run alongside canonical_structure_template.
CREATE TABLE IF NOT EXISTS "canonical_template_entries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"source" varchar(20) NOT NULL,
"brand" varchar(120) NOT NULL,
"canonical_category_id" uuid NOT NULL,
"canonical_subcategory_id" uuid,
"name" varchar(500) NOT NULL,
"vehicle_count" integer DEFAULT 0 NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "canonical_template_entries_sb_idx" ON "canonical_template_entries" ("source","brand");

View File

@@ -0,0 +1,8 @@
-- Fiyat görünümleri artık markasız/araç-markalı bağlamlarda yalnız ORİJİNAL
-- (kokpit tur='orjinal' etiketli) teklifleri içerir. Mevcut satırlar eski
-- kuralla (orijinal + yan sanayi tek havuz) hesaplandığından tutarsız — sil;
-- seriler ilk görüntülenmede takip history'sinden OE-only kurallarla yeniden
-- backfill edilir (product_history 2026-01-29 epoch'undan beri eksiksiz),
-- cron sonraki günleri aynı kuralla ekler. (~166 track / ~4,8k satır — ucuz.)
DELETE FROM "part_price_daily";--> statement-breakpoint
DELETE FROM "part_price_tracks";

View File

@@ -0,0 +1,9 @@
-- OEM kodu normalize-eşleşme index'i. Reverse-catalog (vehiclesByOem) ve
-- fiyat-kartı marka çözümü artık normalize edilmiş koda göre eşleşir: URL
-- kodu boşluksuz ("9827622780") gelirken katalog boşluklu saklayabilir
-- ("9827 622 780"; özellikle PSA/Peugeot/Citroen) → exact match kaçırıyordu.
-- Functional btree, sorgudaki ifadeyle birebir aynı olmalı ki planner kullansın.
-- Not: non-concurrent build kısa süreli YAZMA kilidi alır (prod ~10s, salt-okuma
-- etkilenmez); parts ~9,3M satır. IF NOT EXISTS → idempotent/yeniden koşulur.
CREATE INDEX IF NOT EXISTS "parts_oem_code_norm_idx"
ON "parts" (regexp_replace(upper("oem_code"), '[^A-Z0-9]', '', 'g'));

View File

@@ -0,0 +1,9 @@
-- Kalıcı tamlık işareti: prefetch zinciri parça ile bittiğinde true olur
-- (PrefetchWorkerService.incrementCompleted). Redis prefetch:complete:* (21g TTL,
-- operasyonel skip mantığı) yerine SÜRESİZ ölçüm — "% tam çekilmiş" güvenilir
-- takibi. _at son doğrulanma zamanını tutar. IF NOT EXISTS → idempotent.
ALTER TABLE "vehicles" ADD COLUMN IF NOT EXISTS "fully_fetched" boolean DEFAULT false NOT NULL;
--> statement-breakpoint
ALTER TABLE "vehicles" ADD COLUMN IF NOT EXISTS "fully_fetched_at" timestamp with time zone;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "vehicles_fully_fetched_idx" ON "vehicles" USING btree ("fully_fetched");

View File

@@ -0,0 +1,9 @@
-- Dunning görünürlüğü: yenileme tahsilatı patlayınca (invoice.payment_failed)
-- damgalanır, fatura ödenince (invoice.paid) veya abonelik kesin iptal olunca
-- temizlenir. dunning_invoice_url = Stripe hosted fatura sayfası — kullanıcının
-- açık faturayı ödeyebileceği/yeni kart girebileceği TEK self-serve yüzey
-- (uygulamada kart-güncelleme yok; %0 dunning kurtarmanın kök nedeni buydu).
-- IF NOT EXISTS → idempotent.
ALTER TABLE "user_subscriptions" ADD COLUMN IF NOT EXISTS "dunning_since" timestamp with time zone;
--> statement-breakpoint
ALTER TABLE "user_subscriptions" ADD COLUMN IF NOT EXISTS "dunning_invoice_url" text;

View File

@@ -0,0 +1,20 @@
-- Mitsubishi'nin parça listesi grup sanılıyordu.
--
-- `/p5mitsubishi/extern/details/vinDetails` yanıtı `partno`/`qty` taşıyan bir
-- PARÇA listesi, ama her kaydın kendi linki `partInfoTable` (parça-detay) ve ne
-- wid ne de yol sınıflandırıcıda karşılık buluyordu. Sonuç: 2.193 parça listesi
-- grup düğümüne dönüştü ve içlerindeki 19.576 tekil parça ("SCREW,LOCK CYLINDER",
-- "BOLT,STEERING COLUMN WASHER") kategori olarak kaydedildi. Ölçüm (prod,
-- 2026-09-20): bu 19.576 sahte düğümün TOPLAM 2 tanesinde parça var, ve her
-- prefetch turunda yeniden çekiliyorlar.
--
-- Sınıflandırıcı düzeltildi (detailsTable artık yaprak, partInfoTable hiç
-- kuyruklanmıyor), ama okuma yolu bir düğümün ÖNCE çocuklarına bakıyor: sahte
-- çocuklar dururken parça listesi asla çekilmez. Bu yüzden satırların silinmesi
-- düzeltmenin parçası, ayrı bir temizlik değil.
--
-- Güvenli: yalnız pl24 kaynaklı ve yalnız bu iki imzayı taşıyan satırlar; ikisi
-- de prod'da %100 Mitsubishi. Silinen ~2 parça satırı üst listeden yeniden gelir.
DELETE FROM "categories"
WHERE "source" = 'pl24'
AND ("link_wid" = 'partInfoTable' OR "link_path" ILIKE '%/details/vinpartinfo%');

View File

@@ -0,0 +1,34 @@
-- RPartStore (rpartstore.renault.com, Renault Group dealer portal) VIN-decode
-- fallback cache (feature-flagged: RPARTSTORE_ENABLED). One row per VIN. The
-- rpartstore-decode BullMQ job asks the RPartStore BFF (STOMP over WebSocket)
-- for Renault/Dacia VINs the normal chain (pcat/PL24/emex) can't identify, then
-- matches the decoded model to an EXISTING PL24 catalog_vehicle. RPartStore is
-- ONLY a decode oracle here — parts are served from PL24's existing catalog.
-- status: 'pending' | 'decoded' | 'not_found' | 'capped' | 'failed'.
CREATE TABLE IF NOT EXISTS "rpartstore_decodes" (
"vin" text PRIMARY KEY NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"brand_name" text,
"model" text,
"model_code" text,
"family_code" text,
"model_year" text,
"engine" text,
"gearbox" text,
"energy_type" text,
"manufacturing_date" text,
"catalog_vehicle_id" uuid,
"raw" jsonb,
"attempts" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone,
"decoded_at" timestamp with time zone
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "rpartstore_decodes" ADD CONSTRAINT "rpartstore_decodes_catalog_vehicle_id_catalog_vehicles_id_fk" FOREIGN KEY ("catalog_vehicle_id") REFERENCES "public"."catalog_vehicles"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "rpartstore_decodes_status_idx" ON "rpartstore_decodes" USING btree ("status");

View File

@@ -92,6 +92,181 @@
"when": 1780581755559,
"tag": "0012_lifecycle_email_sent",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1780600000000,
"tag": "0013_fix_brand_casing",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1781136000000,
"tag": "0014_proxy_logs",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1781222400000,
"tag": "0015_oem_votes",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1781308800000,
"tag": "0016_oem_suggestions",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1781395200000,
"tag": "0017_oem_expert_rewards",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1781481600000,
"tag": "0018_part_price_history",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1781485200000,
"tag": "0019_part_price_brand",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1781268490495,
"tag": "0020_stripe_recurring",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1781568000000,
"tag": "0021_vinpin_decodes",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1782652379976,
"tag": "0022_discount_codes",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1783084194145,
"tag": "0023_api_keys",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1783085194145,
"tag": "0024_canonical_categories",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1783086194145,
"tag": "0025_canonical_wheel_accessory",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1783087194145,
"tag": "0026_canonical_subcategories",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1783088194145,
"tag": "0027_canonical_subcategories_v2",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1783089194145,
"tag": "0028_canonical_subcat_v3",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1783090194145,
"tag": "0029_canonical_structure_template",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1783091194145,
"tag": "0030_categories_dedup_unique",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1783092194145,
"tag": "0031_canonical_template_entries",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1784023885041,
"tag": "0032_part_price_oe_reset",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1784027180912,
"tag": "0033_parts_oem_code_norm_idx",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1784965129879,
"tag": "0034_vehicle_fully_fetched",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1786435493000,
"tag": "0035_subscription_dunning",
"breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1786521893000,
"tag": "0036_pl24_mitsubishi_partinfo_cleanup",
"breakpoints": true
},
{
"idx": 37,
"version": "7",
"when": 1790332800000,
"tag": "0037_rpartstore_decodes",
"breakpoints": true
}
]
}
}

View File

@@ -65,6 +65,7 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"stripe": "^22.1.1",
"tesseract.js": "^5.1.1",
"undici": "^7.22.0",
"zod": "^3.24.0"
},

View File

@@ -21,22 +21,30 @@ import { RolesGuard } from "./common/guards/roles.guard";
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor";
import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor";
import { TransformInterceptor } from "./common/interceptors/transform.interceptor";
import { TelegramModule } from "./common/telegram.module";
import configuration from "./config/configuration";
import { validate } from "./config/env.validation";
import { ContactModule } from "./contact/contact.module";
import { DatabaseModule } from "./database/database.module";
import { DemoModule } from "./demo/demo.module";
import { DiscountCodesModule } from "./discount-codes/discount-codes.module";
import { EmailModule } from "./email/email.module";
import { HealthController } from "./health.controller";
import { EmexModule } from "./integrations/emex/emex.module";
import { PModule } from "./integrations/p/p.module";
import { VinpinModule } from "./integrations/vinpin/vinpin.module";
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
import { JobsModule } from "./jobs/jobs.module";
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
import { NotificationsModule } from "./notifications/notifications.module";
import { OemSuggestionsModule } from "./oem-suggestions/oem-suggestions.module";
import { OemVotesModule } from "./oem-votes/oem-votes.module";
import { PartPricesModule } from "./part-prices/part-prices.module";
import { PartsModule } from "./parts/parts.module";
import { PaymentsModule } from "./payments/payments.module";
import { PlansModule } from "./plans/plans.module";
import { PostHogModule } from "./posthog/posthog.module";
import { PublicApiModule } from "./public-api/public-api.module";
import { RedisModule } from "./redis/redis.module";
import { ReferralsModule } from "./referrals/referrals.module";
import { StorageModule } from "./storage/storage.module";
@@ -72,6 +80,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
]),
DatabaseModule,
RedisModule,
TelegramModule,
AuthModule,
UsersModule,
EmailModule,
@@ -81,16 +90,22 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
PlansModule,
SubscriptionsModule,
PaymentsModule,
DiscountCodesModule,
ReferralsModule,
VehiclesModule,
CategoriesModule,
DemoModule,
PartsModule,
PModule,
PartPricesModule,
JobsModule,
EmexModule,
VinpinModule,
TranslationsModule,
AdminModule,
AnalyticsModule,
OemVotesModule,
OemSuggestionsModule,
CatalogModule,
ChangelogModule,
ChatwootModule,
@@ -100,6 +115,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
PostHogModule,
TelemetryModule,
InternalAdminModule,
PublicApiModule,
],
controllers: [HealthController],
providers: [

View File

@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { generateReferralCode, normalizeName } from "@sase/shared";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { captcha } from "better-auth/plugins";
import { apiKey, captcha } from "better-auth/plugins";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
@@ -224,6 +224,21 @@ export function createAuth(
"http://localhost:4000",
],
plugins: [
// Public API anahtarları (api.sase.tr / /api/v1). Doğrulama ApiKeyGuard
// üzerinden auth.api.verifyApiKey ile yapılır; enableSessionForAPIKeys
// bilinçli olarak KAPALI — API anahtarı dashboard oturumu yerine geçmez,
// cookie tabanlı AuthGuard'ın davranışı değişmez.
apiKey({
defaultPrefix: "sase_",
enableMetadata: true,
// Anahtar başına istek tavanı (verify başına sayaç). Günlük decode
// kotasından ayrıdır — o, PublicApiQuotaService'te Redis ile tutulur.
rateLimit: {
enabled: true,
timeWindow: 60_000, // 1 dakika
maxRequests: 120,
},
}),
// Cloudflare Turnstile: yalnızca secret tanımlıysa aktif. sign-in/sign-up
// uçları "x-captcha-response" header'ındaki token ile doğrulanır.
...(process.env.TURNSTILE_SECRET_KEY
@@ -247,3 +262,42 @@ export function getAuth() {
}
return authInstance;
}
/** apiKey plugin'inin döndürdüğü anahtar kaydı (verify/create cevapları). */
export interface ApiKeyRecord {
id: string;
userId: string;
name: string | null;
start: string | null;
prefix: string | null;
enabled: boolean;
expiresAt: Date | null;
metadata: Record<string, unknown> | null;
}
/**
* apiKey plugin uçları. `authInstance` geniş `ReturnType<typeof betterAuth>`
* ile tiplendiği için plugin uçları tipte görünmüyor (runtime'da mevcutlar);
* bu erişimci onları dar ve doğru imzalarla expose eder.
*/
export function getApiKeyApi() {
return getAuth().api as unknown as {
verifyApiKey: (input: { body: { key: string } }) => Promise<{
valid: boolean;
error: { message?: string; code: string } | null;
key: ApiKeyRecord | null;
}>;
createApiKey: (input: {
body: {
userId: string;
name?: string;
prefix?: string;
expiresIn?: number;
metadata?: Record<string, unknown>;
rateLimitEnabled?: boolean;
rateLimitTimeWindow?: number;
rateLimitMax?: number;
};
}) => Promise<ApiKeyRecord & { key: string }>;
};
}

View File

@@ -52,10 +52,7 @@ export class BlogController {
// Automation endpoint — published by the Süper Panel content pipeline via n8n.
@Post("posts/internal")
@Public()
async createInternal(
@Headers("authorization") authHeader: string,
@Body() body: CreateBlogPost,
) {
async createInternal(@Headers("authorization") authHeader: string, @Body() body: CreateBlogPost) {
assertAutomationToken(authHeader);
if (!body?.title || !body?.body_markdown) {
throw new UnauthorizedException("title and body_markdown are required");

View File

@@ -1,9 +1,4 @@
import {
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
} from "@nestjs/common";
import { Injectable, Logger, NotFoundException, ServiceUnavailableException } from "@nestjs/common";
import { RedisService } from "../redis/redis.service";
import type { BlogPost, BlogPostListItem, CreateBlogPost } from "./blog.dto";
@@ -56,19 +51,31 @@ function resolveCover(row: Pick<DirectusPost, "cover_image" | "cover_file">): st
// slug, but we slugify defensively (and to derive one if it's missing).
function slugify(input: string): string {
const map: Record<string, string> = {
ç: "c", ğ: "g", ı: "i", İ: "i", ö: "o", ş: "s", ü: "u",
Ç: "c", Ğ: "g", Ö: "o", Ş: "s", Ü: "u",
ç: "c",
ğ: "g",
ı: "i",
İ: "i",
ö: "o",
ş: "s",
ü: "u",
Ç: "c",
Ğ: "g",
Ö: "o",
Ş: "s",
Ü: "u",
};
return input
.split("")
.map((c) => map[c] ?? c)
.join("")
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 160) || "yazi";
return (
input
.split("")
.map((c) => map[c] ?? c)
.join("")
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 160) || "yazi"
);
}
@Injectable()
@@ -95,7 +102,9 @@ export class BlogService {
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`Directus ${init.method ?? "GET"} ${path} -> ${res.status}: ${detail.slice(0, 300)}`);
throw new Error(
`Directus ${init.method ?? "GET"} ${path} -> ${res.status}: ${detail.slice(0, 300)}`,
);
}
const json = (await res.json()) as { data: T };
return json.data;
@@ -136,7 +145,8 @@ export class BlogService {
"filter[project][_eq]": DIRECTUS_PROJECT,
"filter[status][_eq]": "published",
sort: "-published_at",
fields: "slug,title,meta_description,tags,cover_image,cover_file.filename_disk,published_at",
fields:
"slug,title,meta_description,tags,cover_image,cover_file.filename_disk,published_at",
limit: "-1",
}),
);

View File

@@ -0,0 +1,192 @@
import { describe, expect, it, vi } from "vitest";
import { isStaleBrowseArchitecture } from "../integrations/pl24/pl24-tree";
import { CatalogService } from "./catalog.service";
/**
* Regression lock for the pinned-browse-rows bug (plv2.md, finding consumers-09).
*
* `catalog_vehicles` is a permanent cache: `getModels` returns early as soon as
* a brand has any row, so `fetchVehicleList` never runs again for that brand.
* The 188 rows listed while PSA and Volvo were still P4 (127 LEGACY_PSA + 61
* LEGACY_VOLVO on prod, 2026-09-20) were therefore stuck serving the frozen
* 2024-02-13 PSA snapshot and a Volvo endpoint that answers HTTP 503.
*/
type Row = {
id: string;
serviceName: string;
architecture: string | null;
brandId: string | null;
};
const psaRow = (id: string): Row => ({
id,
serviceName: "peugeot_parts",
architecture: "LEGACY_PSA",
brandId: "b1",
});
const freshRow = (id: string): Row => ({
id,
serviceName: "peugeot_parts",
architecture: "P5_MODERN",
brandId: "b1",
});
function makeService(opts: {
lockFree?: boolean;
fetched?: Array<{ model: string; vehicleId: string }>;
fetchThrows?: boolean;
refetched?: Row[];
}) {
const deleted: string[][] = [];
const inserted: unknown[][] = [];
const tx = {
insert: () => ({
values: (v: unknown[]) => {
inserted.push(v);
return { onConflictDoNothing: async () => undefined };
},
}),
delete: () => ({
where: async (cond: unknown) => {
// drizzle's inArray() puts its bound values in a `queryChunks` entry
// that is itself an array of Param objects; pull the plain ids back out
// so a test can assert WHICH rows were removed, not just how many.
const chunks = (cond as { queryChunks?: unknown[] })?.queryChunks ?? [];
const params = chunks.find((c): c is unknown[] => Array.isArray(c)) ?? [];
const ids = params
.map((param) => (param as { value?: unknown })?.value)
.filter((v): v is string => typeof v === "string");
deleted.push(ids);
return undefined;
},
}),
};
const db = {
select: () => ({ from: () => ({ where: async () => opts.refetched ?? [] }) }),
transaction: async (cb: (t: typeof tx) => Promise<void>) => cb(tx),
};
const redis = { setNx: vi.fn(async () => opts.lockFree !== false) };
const pl24Service = {
fetchVehicleList: vi.fn(async () => {
if (opts.fetchThrows) throw new Error("upstream 503");
return opts.fetched ?? [];
}),
};
const svc = new CatalogService(
db as never,
redis as never,
pl24Service as never,
{} as never,
) as never as {
healStaleBrowseRows: (brand: string, rows: Row[], where: unknown[]) => Promise<Row[] | null>;
};
return { svc, redis, pl24Service, deleted, inserted };
}
describe("isStaleBrowseArchitecture", () => {
it("flags a row whose stored architecture is not the current one", () => {
expect(
isStaleBrowseArchitecture({
storedArchitecture: "LEGACY_PSA",
currentArchitecture: "P5_MODERN",
}),
).toBe(true);
expect(
isStaleBrowseArchitecture({
storedArchitecture: "LEGACY_VOLVO",
currentArchitecture: "P5_MODERN",
}),
).toBe(true);
});
it("leaves a matching row alone", () => {
expect(
isStaleBrowseArchitecture({
storedArchitecture: "P5_MODERN",
currentArchitecture: "P5_MODERN",
}),
).toBe(false);
// Brands that are still P4 upstream must not be touched.
expect(
isStaleBrowseArchitecture({
storedArchitecture: "LEGACY_FORD",
currentArchitecture: "LEGACY_FORD",
}),
).toBe(false);
});
it("does nothing without both sides (unknown service / unlabelled row)", () => {
expect(
isStaleBrowseArchitecture({ storedArchitecture: null, currentArchitecture: "P5_MODERN" }),
).toBe(false);
expect(
isStaleBrowseArchitecture({ storedArchitecture: "LEGACY_PSA", currentArchitecture: null }),
).toBe(false);
});
});
describe("healStaleBrowseRows", () => {
it("does not touch PL24 when every row is current", async () => {
const { svc, pl24Service, redis } = makeService({});
const out = await svc.healStaleBrowseRows("Peugeot", [freshRow("a"), freshRow("b")], []);
expect(out).toBeNull();
expect(pl24Service.fetchVehicleList).not.toHaveBeenCalled();
expect(redis.setNx).not.toHaveBeenCalled();
});
it("re-lists a stale brand and replaces its rows", async () => {
const refetched = [freshRow("new1"), freshRow("new2")];
const { svc, pl24Service, deleted, inserted } = makeService({
fetched: [
{ model: "208", vehicleId: "p5-208" },
{ model: "308", vehicleId: "p5-308" },
],
refetched,
});
const out = await svc.healStaleBrowseRows("Peugeot", [psaRow("old1"), psaRow("old2")], []);
expect(pl24Service.fetchVehicleList).toHaveBeenCalledWith("peugeot_parts");
expect(inserted).toHaveLength(1);
expect(inserted[0]).toHaveLength(2);
expect(deleted).toHaveLength(1);
expect(out).toEqual(refetched);
});
it("deletes only the stale rows when a service holds a mix", async () => {
// A row already re-listed under the new architecture conflicts on insert and
// is skipped, so deleting it would drop it for good.
const { svc, deleted } = makeService({
fetched: [{ model: "208", vehicleId: "p5-208" }],
refetched: [freshRow("keep")],
});
await svc.healStaleBrowseRows("Peugeot", [psaRow("old1"), freshRow("keep")], []);
expect(deleted).toHaveLength(1);
expect(deleted).toEqual([["old1"]]);
});
it("keeps the stale rows when the re-list throws", async () => {
const { svc, deleted, inserted } = makeService({ fetchThrows: true });
const out = await svc.healStaleBrowseRows("Volvo", [psaRow("old1")], []);
expect(out).toBeNull();
expect(inserted).toHaveLength(0);
expect(deleted).toHaveLength(0);
});
it("keeps the stale rows when upstream returns an empty model list", async () => {
const { svc, deleted, inserted } = makeService({ fetched: [] });
const out = await svc.healStaleBrowseRows("Volvo", [psaRow("old1")], []);
expect(out).toBeNull();
expect(inserted).toHaveLength(0);
expect(deleted).toHaveLength(0);
});
it("attempts at most once a day per brand", async () => {
const { svc, pl24Service } = makeService({ lockFree: false });
const out = await svc.healStaleBrowseRows("Peugeot", [psaRow("old1")], []);
expect(out).toBeNull();
expect(pl24Service.fetchVehicleList).not.toHaveBeenCalled();
});
});

View File

@@ -81,6 +81,19 @@ export class CatalogController {
return this.catalogService.getCategoryTree(id, user.id, body, engine, gearbox, mainGroupsPath);
}
// Unified canonical browse for a catalog vehicle (pl24 flow).
@Get("vehicles/:id/canonical")
getCanonicalTree(
@Param("id") id: string,
@Query("body") body: string | undefined,
@Query("engine") engine: string | undefined,
@Query("gearbox") gearbox: string | undefined,
@Query("mgp") mainGroupsPath: string | undefined,
@CurrentUser() user: { id: string },
) {
return this.catalogService.getCanonicalTree(id, user.id, body, engine, gearbox, mainGroupsPath);
}
@Get("vehicles/:id/categories/:categoryId")
getCategoryWithParts(
@Param("id") id: string,

View File

@@ -1,8 +1,16 @@
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { and, eq, inArray, or, sql } from "drizzle-orm";
import { and, eq, gte, inArray, isNotNull, isNull, or, sql } from "drizzle-orm";
import {
buildCanonicalInput,
groupCategoriesByCanonical,
overlayTemplate,
} from "../categories/canonical-grouping";
import { DATABASE, type Database } from "../database/database.provider";
import {
brands,
canonicalCategories,
canonicalStructureTemplate,
canonicalTemplateEntries,
catalogVehicles,
categories,
parts,
@@ -11,6 +19,7 @@ import {
userBrands,
userSubscriptions,
} from "../database/schema/core";
import { isPl24LeafNode, isStaleBrowseArchitecture } from "../integrations/pl24/pl24-tree";
import { PL24Service } from "../integrations/pl24/pl24.service";
import {
type PL24DecodedCategory,
@@ -161,7 +170,8 @@ export class CatalogService {
.where(and(...whereConditions));
if (dbVehicles.length > 0) {
return dbVehicles;
const healed = await this.healStaleBrowseRows(brandName, dbVehicles, whereConditions);
return healed ?? dbVehicles;
}
// Fetch from PL24 for each service
@@ -216,6 +226,118 @@ export class CatalogService {
return allVehicles;
}
/**
* Re-list a brand whose stored browse rows were created under a retired PL24
* architecture, and replace them with the current ones.
*
* WHY (plv2.md, finding consumers-09): `getModels` treats `catalog_vehicles`
* as a permanent cache — one row for the brand and `fetchVehicleList` is never
* called again. The rows listed while PSA and Volvo were still P4 are pinned
* forever, so Peugeot/Citroën browse keeps serving the frozen 2024-02-13
* snapshot and Volvo/Polestar browse keeps calling an endpoint that answers
* HTTP 503. A code fix alone never reaches these rows.
*
* Deliberately conservative, mirroring `healStalePl24Architecture` on the
* VIN side:
* - only rows whose stored architecture disagrees with the service table;
* - one attempt per brand per day (Redis lock) so a permanently failing
* re-list cannot hammer the one surviving account on every page view;
* - a failed or empty re-list leaves the old rows in place — stale data
* beats an empty catalog;
* - the stale rows are deleted only once the replacements are committed,
* and per service, so one broken sub-catalog cannot wipe a working one.
*
* Deleting a browse row cascades to its categories and parts. That is
* intended: those rows describe the retired tree and would otherwise survive
* as unreachable orphans under the new listing.
*
* Returns the refreshed rows, or null when nothing was migrated (caller keeps
* what it already had).
*/
private async healStaleBrowseRows(
brandName: string,
dbVehicles: (typeof catalogVehicles.$inferSelect)[],
whereConditions: ReturnType<typeof eq>[],
): Promise<(typeof catalogVehicles.$inferSelect)[] | null> {
const isStale = (v: typeof catalogVehicles.$inferSelect) =>
isStaleBrowseArchitecture({
storedArchitecture: v.architecture,
currentArchitecture: PL24_SERVICE_CATALOGS[v.serviceName]?.architecture,
});
const staleServices = [...new Set(dbVehicles.filter(isStale).map((v) => v.serviceName))];
if (staleServices.length === 0) return null;
if (!(await this.redis.setNx(`pl24:browse-heal:${brandName}`, "1", 86_400))) return null;
this.logger.log(
`[pl24-browse-heal] ${brandName}: ${staleServices.join(", ")} listed under a retired architecture — re-listing`,
);
let migrated = 0;
for (const svc of staleServices) {
// ONLY the stale rows. A service can hold a mix — some rows already
// re-listed under the new architecture — and those must survive: the
// insert below skips them on conflict, so deleting them here would drop
// them for good.
const staleIds = dbVehicles
.filter((v) => v.serviceName === svc && isStale(v))
.map((v) => v.id);
try {
const fetched = await this.pl24Service.fetchVehicleList(svc);
if (fetched.length === 0) {
this.logger.warn(
`[pl24-browse-heal] ${svc}: upstream returned no models — keeping ${staleIds.length} stale row(s)`,
);
continue;
}
const config = PL24_SERVICE_CATALOGS[svc];
const brandId = dbVehicles.find((v) => v.serviceName === svc)?.brandId ?? null;
await this.db.transaction(async (tx) => {
await tx
.insert(catalogVehicles)
.values(
fetched.map((v) => ({
source: "pl24" as const,
serviceName: svc,
brandName,
brandId,
model: v.model,
year: v.year || null,
engine: v.engine || null,
bodyType: v.bodyType || null,
transmission: v.transmission || null,
market: v.market || null,
serviceVehicleId: v.vehicleId,
catalogPath: v.catalogPath || null,
architecture: config?.architecture || "P5_MODERN",
metadata: v.metadata || null,
categoriesFetched: false,
updatedAt: new Date(),
})),
)
.onConflictDoNothing();
if (staleIds.length > 0) {
await tx.delete(catalogVehicles).where(inArray(catalogVehicles.id, staleIds));
}
});
migrated += staleIds.length;
this.logger.log(
`[pl24-browse-heal] ${svc}: ${staleIds.length} stale row(s) → ${fetched.length} fresh model(s)`,
);
} catch (err) {
this.logger.warn(
`[pl24-browse-heal] ${svc} re-list failed, keeping stale rows: ${(err as Error).message}`,
);
}
}
if (migrated === 0) return null;
return await this.db
.select()
.from(catalogVehicles)
.where(and(...whereConditions));
}
/**
* Get a single catalog vehicle by ID.
*/
@@ -349,6 +471,130 @@ export class CatalogService {
/**
* Get category tree for a catalog vehicle (on-demand fetch from PL24).
*/
/**
* Unified canonical browse for a catalog vehicle (pl24 flow) — the
* catalog_vehicle_id counterpart of CategoriesService.getCanonicalTree.
* Folds the vehicle's part-holding leaf categories into the 20 canonical
* buckets so pl24 brands (VW, Opel, Renault…) navigate through the same
* headings as the emex/pcat vehicle_id flow. Part counts are joined by
* category_id (pl24 parts often carry vehicle_id, not catalog_vehicle_id).
*/
async getCanonicalTree(
catalogVehicleId: string,
userId: string,
body = "_all_",
engine = "_all_",
gearbox = "_all_",
mainGroupsPath?: string,
) {
// Populate the raw source tree (lazy fetch + cache) and enforce brand access.
await this.getCategoryTree(catalogVehicleId, userId, body, engine, gearbox, mainGroupsPath);
const buckets = await this.db
.select({
id: canonicalCategories.id,
slug: canonicalCategories.slug,
name: canonicalCategories.name,
})
.from(canonicalCategories)
.where(isNull(canonicalCategories.parentId))
.orderBy(canonicalCategories.sortOrder);
const subLeaves = await this.db
.select({
id: canonicalCategories.id,
parentId: canonicalCategories.parentId,
slug: canonicalCategories.slug,
name: canonicalCategories.name,
})
.from(canonicalCategories)
.where(isNotNull(canonicalCategories.parentId))
.orderBy(canonicalCategories.sortOrder);
const cats = await this.db
.select({
id: categories.id,
name: categories.name,
source: categories.source,
canonicalId: categories.canonicalCategoryId,
subId: categories.canonicalSubcategoryId,
parentId: categories.parentId,
})
.from(categories)
.where(eq(categories.catalogVehicleId, catalogVehicleId));
const partCounts = new Map<string, number>();
const catIds = cats.map((c) => c.id);
if (catIds.length > 0) {
const rows = await this.db
.select({ categoryId: parts.categoryId, count: sql<number>`count(*)::int` })
.from(parts)
.where(inArray(parts.categoryId, catIds))
.groupBy(parts.categoryId);
for (const r of rows) partCounts.set(r.categoryId, r.count);
}
// Show each bucket's natural sub-category level (see buildCanonicalInput).
const {
input: shown,
counts: shownCounts,
hasChildren,
} = buildCanonicalInput(cats, partCounts, buckets);
const groups = groupCategoriesByCanonical(shown, shownCounts, buckets, {
requireParts: false,
subLeaves,
hasChildren,
});
// Overlay the per-brand structure template (see CategoriesService.getCanonicalTree).
const cv = await this.db
.select({ brand: catalogVehicles.brandName, source: catalogVehicles.source })
.from(catalogVehicles)
.where(eq(catalogVehicles.id, catalogVehicleId))
.limit(1);
const brand = cv[0]?.brand;
const source = cats[0]?.source ?? cv[0]?.source;
if (!brand || !source) return groups;
const template = await this.db
.select({
bucketId: canonicalStructureTemplate.canonicalCategoryId,
subId: canonicalStructureTemplate.canonicalSubcategoryId,
})
.from(canonicalStructureTemplate)
.where(
and(
eq(canonicalStructureTemplate.source, source),
eq(canonicalStructureTemplate.brand, brand),
gte(canonicalStructureTemplate.vehicleCount, 2),
),
);
const templateEntriesRaw = await this.db
.select({
bucketId: canonicalTemplateEntries.canonicalCategoryId,
subId: canonicalTemplateEntries.canonicalSubcategoryId,
name: canonicalTemplateEntries.name,
vehicleCount: canonicalTemplateEntries.vehicleCount,
})
.from(canonicalTemplateEntries)
.where(
and(
eq(canonicalTemplateEntries.source, source),
eq(canonicalTemplateEntries.brand, brand),
gte(canonicalTemplateEntries.vehicleCount, 2),
),
);
// Cap placeholders at the MOST-SHARED 40 per bucket — some brands carry
// hundreds of model-variant names (pl24 BMW elektrik: 500+) that would
// drown the view; the widely-shared ones are the real "brand structure".
templateEntriesRaw.sort((a, z) => z.vehicleCount - a.vehicleCount);
const perBucket = new Map<string, number>();
const templateEntries = templateEntriesRaw.filter((e) => {
const n = (perBucket.get(e.bucketId) ?? 0) + 1;
perBucket.set(e.bucketId, n);
return n <= 40;
});
return overlayTemplate(groups, template, buckets, subLeaves, templateEntries);
}
async getCategoryTree(
catalogVehicleId: string,
userId: string,
@@ -1117,18 +1363,10 @@ export class CatalogService {
return this.pl24Service.fetchFordModelConfig(vehicle.serviceName, familyId, mode, upds);
}
/** Shared PL24 leaf classifier — see integrations/pl24/pl24-tree. */
private isLeafPath(linkPath: string): boolean {
const lower = linkPath.toLowerCase();
return (
lower.includes("/bom/") ||
lower.includes("/bomdetails") ||
lower.includes("/partinfo/") ||
// PL24 P5 leaf items endpoints — chemicals, servicepart, accessories,
// any /extern/<kind>/(vin|mdl)_items combination. These return parts,
// not subgroups, so they must short-circuit drill-down.
/\/extern\/[^/]+\/(vin_items|mdl_items)\b/.test(lower) ||
lower.includes("image-board.action") || // PSA illustration leaf
lower.includes("json-vin-bom-detail.action")
isPl24LeafNode({ linkPath }) || linkPath.toLowerCase().includes("json-vin-bom-detail.action")
);
}
@@ -1235,13 +1473,9 @@ export class CatalogService {
return cats.map((c) => {
const dbChildCount = childCountMap.get(c.id) || 0;
const isLeaf =
c.linkPath?.includes("/bom/") ||
c.linkPath?.includes("/bomdetails") ||
c.linkPath?.includes("/partinfo/") ||
c.linkPath?.includes("/servicepart/vin_items") ||
c.linkPath?.includes("image-board.action") || // PSA illustration leaf
(!c.linkPath && dbChildCount === 0);
const isLeaf = c.linkPath
? isPl24LeafNode({ linkPath: c.linkPath, linkWid: c.linkWid })
: dbChildCount === 0;
return {
...c,
schemaImageUrl: picMap.get(c.id) || null,

View File

@@ -0,0 +1,368 @@
import { classifyNode, foldName, mapToCanonical } from "../jobs/canonical-lexicon";
import { mapToSubcategory } from "../jobs/canonical-sublexicon";
// Shared logic for the unified canonical browse, used by both the vehicle_id
// flow (CategoriesService) and the catalog_vehicle_id flow (CatalogService):
// fold a vehicle's part-holding leaf categories into the 20 canonical buckets.
// Bucketing prefers the materialized categories.canonical_category_id and falls
// back to on-the-fly mapping (same canonical-lexicon the backfill uses) for
// categories decoded since the last backfill; anything unbucketed lands in "Diğer".
export interface CanonicalBucket {
id: string;
slug: string;
name: string;
}
export interface SubLeafRef {
id: string;
parentId: string | null;
slug: string;
name: string;
}
export interface RawCategory {
id: string;
name: string;
source: string;
canonicalId: string | null;
subId?: string | null;
}
interface CanonicalLeaf {
id: string;
name: string;
source: string;
partCount: number;
// Template placeholder: the brand is known to have this entry but THIS
// vehicle's row isn't drilled yet — rendered disabled ("dolduruluyor").
unavailable?: boolean;
}
export interface TemplateEntry {
bucketId: string;
subId: string | null;
name: string;
}
export interface CanonicalSubgroup {
canonical: { id: string | null; slug: string; name: string };
partCount: number;
categories: CanonicalLeaf[];
}
export interface CanonicalGroup {
canonical: { id: string | null; slug: string; name: string };
partCount: number;
categories: CanonicalLeaf[];
// Present only when sub-leaves are supplied: the bucket's leaves split one
// level finer (Fren → Balata/Disk/…). Leaves with no sub-match collect under
// a "genel" subgroup so nothing is hidden.
subgroups?: CanonicalSubgroup[];
}
type LeafWithSub = CanonicalLeaf & { subId: string | null };
// Build the canonical-view input from a vehicle's raw category tree. Shows each
// bucket's NATURAL sub-category level: the children of every bucket "entry"
// container ("Fren sistemi" → Disk frenler / Fren Kaliyeri / El fren sistemi …),
// with SUBTREE part counts, drilled or not — matching the source catalog. So
// clicking a bucket lands on its sub-categories, not a redundant "Fren sistemi"
// hop, and partially-drilled vehicles still show every sub-category (0 parça
// until drilled). Cross-bucket children ("Debriyaj sistemi" under Fren) are
// handled by their own entry → nothing double-shows. Bucketing is materialized
// (canonicalId) with an on-the-fly fallback for freshly-decoded categories.
export function buildCanonicalInput<
T extends { id: string; name: string; canonicalId: string | null; parentId: string | null },
>(
cats: T[],
partCounts: Map<string, number>,
buckets: CanonicalBucket[],
): { input: T[]; counts: Map<string, number>; hasChildren: Set<string> } {
const hasChildren = new Set(cats.map((c) => c.parentId).filter((p): p is string => !!p));
const childrenOf = new Map<string, string[]>();
for (const c of cats)
if (c.parentId) {
const a = childrenOf.get(c.parentId) ?? [];
a.push(c.id);
childrenOf.set(c.parentId, a);
}
const subMemo = new Map<string, number>();
const subtreeParts = (id: string): number => {
const m = subMemo.get(id);
if (m !== undefined) return m;
let s = partCounts.get(id) ?? 0;
for (const ch of childrenOf.get(id) ?? []) s += subtreeParts(ch);
subMemo.set(id, s);
return s;
};
const catById = new Map(cats.map((c) => [c.id, c]));
const bucketSlugToId = new Map(buckets.map((b) => [b.slug, b.id]));
const bucketOf = (c: T): string | null => {
if (c.canonicalId) return c.canonicalId;
const f = foldName(c.name);
if (classifyNode(f) !== "part") return null;
const { slug } = mapToCanonical(f);
return slug ? (bucketSlugToId.get(slug) ?? null) : null;
};
const input: T[] = [];
const counts = new Map<string, number>();
const push = (c: T) => {
if (counts.has(c.id)) return;
counts.set(c.id, subtreeParts(c.id));
input.push(c);
};
for (const c of cats) {
const b = bucketOf(c);
if (!b) continue;
const p = c.parentId ? catById.get(c.parentId) : null;
if (p && bucketOf(p) === b) continue; // not a bucket entry — shown via its parent
const sameKids = (childrenOf.get(c.id) ?? [])
.map((id) => catById.get(id))
.filter((k): k is T => !!k && bucketOf(k) === b);
if (sameKids.length > 0) for (const k of sameKids) push(k);
else push(c); // leaf entry (İç Aydınlatma Sistemi) or no same-bucket child
}
return { input, counts, hasChildren };
}
export interface TemplateRow {
bucketId: string;
subId: string | null;
}
// Overlay the per-brand structure template onto a vehicle's grouped tree so the
// FULL canonical structure shows immediately: any bucket / sub-leaf the brand is
// known to have but this vehicle hasn't deep-drilled yet is added as an empty
// (partCount 0) entry. As the prefetch backfill drills, these fill with real
// source categories + parts. Never removes the vehicle's own data.
export function overlayTemplate(
groups: CanonicalGroup[],
template: TemplateRow[],
buckets: CanonicalBucket[],
subLeaves: SubLeafRef[],
// Entry-level template: the brand's shared source-category NAMES per bucket/
// sub. Names this vehicle doesn't have yet are added as disabled placeholders
// so the FULL browsable structure shows immediately after decode ("yapı aynı,
// sadece parça kodları/şemalar araca özel").
entries: TemplateEntry[] = [],
): CanonicalGroup[] {
if (template.length === 0 && entries.length === 0) return groups;
const bucketById = new Map(buckets.map((b) => [b.id, b]));
const subById = new Map(subLeaves.map((s) => [s.id, s]));
const byBucket = new Map(groups.map((g) => [g.canonical.id, g]));
const templBuckets = new Set<string>();
const templSubsByBucket = new Map<string, Set<string>>();
for (const t of template) {
templBuckets.add(t.bucketId);
if (t.subId) {
const set = templSubsByBucket.get(t.bucketId) ?? new Set<string>();
set.add(t.subId);
templSubsByBucket.set(t.bucketId, set);
}
}
const result = [...groups];
for (const bid of templBuckets) {
let g = byBucket.get(bid);
if (!g) {
const b = bucketById.get(bid);
if (!b) continue;
g = { canonical: { id: b.id, slug: b.slug, name: b.name }, partCount: 0, categories: [] };
result.push(g);
byBucket.set(bid, g);
}
const wantSubs = templSubsByBucket.get(bid);
if (!wantSubs || wantSubs.size === 0) continue;
if (!g.subgroups) g.subgroups = [];
const present = new Set(g.subgroups.map((sg) => sg.canonical.id).filter(Boolean));
for (const sid of wantSubs) {
if (present.has(sid)) continue;
const s = subById.get(sid);
if (!s) continue;
g.subgroups.push({
canonical: { id: s.id, slug: s.slug, name: s.name },
partCount: 0,
categories: [],
});
}
g.subgroups.sort((a, z) => z.partCount - a.partCount);
}
// Merge entry-level template names as placeholders (skip ones the vehicle
// already shows anywhere in the bucket — matched on folded name).
if (entries.length > 0) {
let tplSeq = 0;
for (const e of entries) {
let g = byBucket.get(e.bucketId);
if (!g) {
const b = bucketById.get(e.bucketId);
if (!b) continue;
g = { canonical: { id: b.id, slug: b.slug, name: b.name }, partCount: 0, categories: [] };
result.push(g);
byBucket.set(e.bucketId, g);
}
const have = new Set<string>();
for (const c of g.categories) have.add(foldName(c.name));
for (const sg of g.subgroups ?? []) for (const c of sg.categories) have.add(foldName(c.name));
if (have.has(foldName(e.name))) continue;
const leaf: CanonicalLeaf = {
id: `tpl-${++tplSeq}`,
name: e.name,
source: "template",
partCount: 0,
unavailable: true,
};
const sub = e.subId ? subById.get(e.subId) : undefined;
if (sub) {
if (!g.subgroups) g.subgroups = [];
let sg = g.subgroups.find((x) => x.canonical.id === sub.id);
if (!sg) {
sg = {
canonical: { id: sub.id, slug: sub.slug, name: sub.name },
partCount: 0,
categories: [],
};
g.subgroups.push(sg);
}
sg.categories.push(leaf);
} else {
g.categories.push(leaf);
}
}
}
const order = new Map(buckets.map((b, i) => [b.id, i]));
result.sort(
(a, z) => (order.get(a.canonical.id ?? "") ?? 999) - (order.get(z.canonical.id ?? "") ?? 999),
);
return result;
}
export function groupCategoriesByCanonical(
cats: RawCategory[],
partCounts: Map<string, number>,
buckets: CanonicalBucket[],
// When false, categories with no parts are still included (used for the
// root-level fallback on freshly-decoded vehicles whose leaves/parts aren't
// populated yet). Default true keeps the part-holding-leaf behavior.
// subLeaves: supply the canonical_categories sub-rows (parentId set) to emit
// a second level (subgroups) inside each bucket.
// hasChildren: ids of branch nodes; used to dedupe duplicate categories
// (some decodes emit a root twice — keep the populated/drilled copy).
opts: { requireParts?: boolean; subLeaves?: SubLeafRef[]; hasChildren?: Set<string> } = {},
): CanonicalGroup[] {
const requireParts = opts.requireParts ?? true;
const hasChildren = opts.hasChildren ?? new Set<string>();
// Collapse leaves that share a folded name, keeping the richest copy (most
// parts, then has-children). Fixes "Motor, Motor" from duplicate roots.
const dedupe = (leaves: LeafWithSub[]): LeafWithSub[] => {
const best = new Map<string, LeafWithSub>();
const rank = (l: LeafWithSub) => l.partCount * 2 + (hasChildren.has(l.id) ? 1 : 0);
for (const l of leaves) {
const key = foldName(l.name);
const cur = best.get(key);
if (!cur || rank(l) > rank(cur)) best.set(key, l);
}
return [...best.values()];
};
const slugToBucket = new Map(buckets.map((b) => [b.slug, b]));
const idToBucket = new Map(buckets.map((b) => [b.id, b]));
const subById = new Map((opts.subLeaves ?? []).map((s) => [s.id, s]));
const subSlugToId = new Map((opts.subLeaves ?? []).map((s) => [s.slug, s.id]));
const emitSubs = (opts.subLeaves?.length ?? 0) > 0;
const grouped = new Map<string, { partCount: number; categories: LeafWithSub[] }>();
const other: { partCount: number; categories: LeafWithSub[] } = { partCount: 0, categories: [] };
for (const c of cats) {
const partCount = partCounts.get(c.id) ?? 0;
if (requireParts && partCount === 0) continue; // only clickable, part-holding leaves
let bucket = c.canonicalId ? idToBucket.get(c.canonicalId) : undefined;
const folded = foldName(c.name);
if (!bucket) {
// Skip vehicle-selector / code nodes entirely (Opel model roots like
// "A05 zafira-b 2005-2015") so they don't clutter the "Diğer" bucket;
// only genuine unmapped part-categories fall through to "Diğer".
if (classifyNode(folded) !== "part") continue;
const { slug } = mapToCanonical(folded);
if (slug) bucket = slugToBucket.get(slug);
}
// sub: prefer materialized subId, else map on-the-fly within the bucket.
let subId = c.subId ?? null;
if (!subId && emitSubs && bucket) {
const subSlug = mapToSubcategory(folded, bucket.slug);
if (subSlug) subId = subSlugToId.get(subSlug) ?? null;
}
const leaf: LeafWithSub = { id: c.id, name: c.name, source: c.source, partCount, subId };
const target = bucket ? (grouped.get(bucket.id) ?? { partCount: 0, categories: [] }) : other;
target.categories.push(leaf);
target.partCount += partCount;
if (bucket) grouped.set(bucket.id, target);
}
const strip = (l: LeafWithSub): CanonicalLeaf => ({
id: l.id,
name: l.name,
source: l.source,
partCount: l.partCount,
});
const buildSubgroups = (leaves: LeafWithSub[]): CanonicalSubgroup[] => {
const bySub = new Map<string, { partCount: number; categories: CanonicalLeaf[] }>();
const genel: CanonicalSubgroup = {
canonical: { id: null, slug: "genel", name: "Genel" },
partCount: 0,
categories: [],
};
for (const l of leaves) {
const sub = l.subId ? subById.get(l.subId) : undefined;
if (!sub) {
genel.categories.push(strip(l));
genel.partCount += l.partCount;
continue;
}
const g = bySub.get(sub.id) ?? { partCount: 0, categories: [] };
g.categories.push(strip(l));
g.partCount += l.partCount;
bySub.set(sub.id, g);
}
const subs: CanonicalSubgroup[] = [...bySub.entries()].map(([id, g]) => {
const s = subById.get(id) as SubLeafRef;
g.categories.sort((a, z) => z.partCount - a.partCount);
return { canonical: { id: s.id, slug: s.slug, name: s.name }, ...g };
});
subs.sort((a, z) => z.partCount - a.partCount);
if (genel.categories.length > 0) {
genel.categories.sort((a, z) => z.partCount - a.partCount);
subs.push(genel);
}
return subs;
};
const result: CanonicalGroup[] = buckets
.filter((b) => grouped.has(b.id))
.map((b) => {
const g = grouped.get(b.id) as { partCount: number; categories: LeafWithSub[] };
const deduped = dedupe(g.categories);
const subgroups = emitSubs ? buildSubgroups(deduped) : undefined;
const categories = deduped.map(strip).sort((a, z) => z.partCount - a.partCount);
return {
canonical: { id: b.id, slug: b.slug, name: b.name },
partCount: g.partCount,
categories,
...(subgroups ? { subgroups } : {}),
};
});
if (other.categories.length > 0) {
const categories = dedupe(other.categories)
.map(strip)
.sort((a, z) => z.partCount - a.partCount);
result.push({
canonical: { id: null, slug: "diger", name: "Diğer" },
partCount: other.partCount,
categories,
});
}
return result;
}

View File

@@ -5,9 +5,16 @@ import { CategoriesService } from "./categories.service";
export class CategoriesController {
constructor(private categoriesService: CategoriesService) {}
// Sources this vehicle has (default/eper first) — drives the source switcher.
// Declared before "tree/:vehicleId" so the literal segment wins the match.
@Get("tree/:vehicleId/sources")
async getCatalogSources(@Param("vehicleId") vehicleId: string) {
return this.categoriesService.getCatalogSources(vehicleId);
}
@Get("tree/:vehicleId")
async getCategoryTree(@Param("vehicleId") vehicleId: string) {
return this.categoriesService.getCategoryTree(vehicleId);
async getCategoryTree(@Param("vehicleId") vehicleId: string, @Query("source") source?: string) {
return this.categoriesService.getCategoryTree(vehicleId, source);
}
@Get("search/:vehicleId")
@@ -15,6 +22,27 @@ export class CategoriesController {
return this.categoriesService.searchCatalog(vehicleId, q ?? "");
}
// The 20-bucket unified taxonomy (browse landing / filter chips). Declared
// before ":id" so the literal path isn't captured by the id param route.
@Get("canonical")
async getCanonicalCategories() {
return this.categoriesService.getCanonicalCategories();
}
// Full 2-level canonical taxonomy (buckets + sub-leaves + population counts) —
// the source-independent structure exposed to B2C API consumers. Declared
// before "canonical/:vehicleId" so the literal isn't captured as a vehicleId.
@Get("canonical/taxonomy")
async getCanonicalTaxonomy() {
return this.categoriesService.getCanonicalTaxonomy();
}
// A vehicle's catalog folded into the canonical buckets (unified browse).
@Get("canonical/:vehicleId")
async getCanonicalTree(@Param("vehicleId") vehicleId: string) {
return this.categoriesService.getCanonicalTree(vehicleId);
}
@Get(":id/children")
async getChildren(@Param("id") id: string) {
return this.categoriesService.getChildren(id);

View File

@@ -17,6 +17,8 @@ function createService(db: any) {
};
const pl24Service = {
getCategories: vi.fn().mockResolvedValue([]),
// P4→P5 onarma yolu bunu çağırır (plv2 Faz 2).
decodeVin: vi.fn().mockResolvedValue(null),
};
const emexService = {
isSupported: vi.fn().mockReturnValue(false),
@@ -320,6 +322,84 @@ describe("CategoriesService", () => {
"drill-load-error",
);
});
it("routes a P4 Ford VIN node (json-vin-sub-group.action, positional link_wid) to children, not an empty leaf", async () => {
// link_wid "1" is a positional code (not a *Group* table) so the link_wid
// group-drill branch misses it; the path is …group.action (not /psa/, not
// vin-group.action) so the PSA / Volvo branches miss it too. Before the
// dedicated gate these fell through to the leaf parts path → silent empty
// panel (Ford/Nissan/Opel analog of the PSA-parent-gate gap).
const category = {
id: "ford1",
name: "elektrik sistemi",
nameOriginal: "elektrik sistemi",
parentId: null,
vehicleId: "v1",
source: "pl24",
linkPath: "/ford/fordp_parts/json-vin-sub-group.action?mainGroupId=CAP1&vin=WF0FXX",
linkWid: "1",
hasSubgroups: null,
hasParts: null,
};
let selectCall = 0;
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
const captured = selectCall;
const c: Record<string, any> = {};
c.from = vi.fn().mockReturnValue(c);
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
c.limit = vi.fn().mockReturnValue([category]);
return c;
}),
execute: vi.fn().mockResolvedValue([]),
};
const { service } = createService(db);
const discovered = [{ id: "sg1", name: "kablolar", children: undefined }];
vi.spyOn(service, "getChildren").mockResolvedValue(discovered as any);
const result = await service.getCategoryWithParts("ford1");
expect(service.getChildren).toHaveBeenCalledWith("ford1");
expect(result.parts).toEqual([]);
expect((result as { children?: unknown }).children).toEqual(discovered);
});
it("surfaces loadError when a P4 group.action drill comes back empty (not a silent leaf)", async () => {
const category = {
id: "ford2",
name: "kaporta",
nameOriginal: "kaporta",
parentId: null,
vehicleId: "v1",
source: "pl24",
linkPath: "/ford/fordp_parts/json-vin-sub-group.action?mainGroupId=GDB&vin=WF0FXX",
linkWid: "2",
hasSubgroups: null,
hasParts: null,
};
let selectCall = 0;
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
const captured = selectCall;
const c: Record<string, any> = {};
c.from = vi.fn().mockReturnValue(c);
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
c.limit = vi.fn().mockReturnValue([category]);
return c;
}),
execute: vi.fn().mockResolvedValue([]),
};
const { service } = createService(db);
vi.spyOn(service, "getChildren").mockResolvedValue([] as any);
const result = await service.getCategoryWithParts("ford2");
expect(service.getChildren).toHaveBeenCalledWith("ford2");
expect((result as { loadError?: boolean }).loadError).toBe(true);
expect((result as { children?: unknown }).children).toBeUndefined();
});
});
describe("getAncestors", () => {
@@ -384,3 +464,81 @@ describe("CategoriesService", () => {
});
});
});
// ── P4 → P5 kendi kendini onarma (plv2 Faz 2, bulgu psa-07) ──
// PSA/Volvo upstream'de P5'e taşındı; daha önce decode edilmiş araçlar donmuş
// 2024-02-13 PSA anlık görüntüsüne / ölü P4 Volvo ucuna işaret etmeye devam
// ediyor ve decodeVin'in db_hit kısa devresi kod düzeltmesini onlara ulaştırmıyor.
describe("CategoriesService — bayat PL24 mimarisini onarma", () => {
const makeVehicle = (catalogPath: string) => ({
id: "veh-1",
vin: "VF3MCBHZWHS150390",
rawData: { catalogInfo: { serviceName: "peugeot_parts", catalogPath } },
});
const setup = (catalogPath: string, decodeResult: unknown) => {
const { service, redis, pl24Service } = createService({} as never);
const p = service as unknown as {
healStalePl24Architecture(
id: string,
v: { vin: string | null; rawData: unknown },
n: number,
): Promise<boolean>;
db: unknown;
};
// setNx: ilk denemeye izin ver (günlük tek deneme kilidi)
(redis as unknown as { setNx: unknown }).setNx = vi.fn().mockResolvedValue(true);
(redis as unknown as { del: unknown }).del = vi.fn().mockResolvedValue(undefined);
pl24Service.decodeVin = vi.fn().mockResolvedValue(decodeResult);
return { service, p, redis, pl24Service, vehicle: makeVehicle(catalogPath) };
};
it("P4 PSA yolundaki araç yeniden decode edilir ve ağaç değişir", async () => {
const fresh = {
catalogInfo: { serviceName: "peugeot_parts", catalogPath: "/p5psa" },
categories: [
{
code: "_FCT0001",
nameTr: "Mekanik",
nameEn: "Mechanical",
linkPath: "/p5psa/x",
linkWid: "mainGroupTable",
},
],
};
const { p, pl24Service, vehicle } = setup("/psa/peugeot_parts", fresh);
const inserted: unknown[] = [];
(p as unknown as { db: unknown }).db = {
transaction: async (fn: (tx: unknown) => Promise<void>) => {
await fn({
delete: () => ({ where: async () => undefined }),
insert: () => ({ values: async (rows: unknown[]) => inserted.push(...rows) }),
update: () => ({ set: () => ({ where: async () => undefined }) }),
});
},
};
await expect(p.healStalePl24Architecture("veh-1", vehicle, 120)).resolves.toBe(true);
expect(pl24Service.decodeVin).toHaveBeenCalledWith("VF3MCBHZWHS150390");
expect(inserted).toHaveLength(1);
expect((inserted[0] as { name: string }).name).toBe("Mekanik");
});
it("zaten P5 olan araca dokunulmaz (decode çağrılmaz)", async () => {
const { p, pl24Service, vehicle } = setup("/p5psa", null);
await expect(p.healStalePl24Architecture("veh-1", vehicle, 6)).resolves.toBe(false);
expect(pl24Service.decodeVin).not.toHaveBeenCalled();
});
it("yeniden decode boş dönerse eski ağaç korunur", async () => {
const { p, vehicle } = setup("/psa/peugeot_parts", { categories: [] });
await expect(p.healStalePl24Architecture("veh-1", vehicle, 120)).resolves.toBe(false);
});
it("günde bir denenir (setNx kilidi)", async () => {
const { p, redis, pl24Service, vehicle } = setup("/psa/peugeot_parts", { categories: [] });
(redis as unknown as { setNx: unknown }).setNx = vi.fn().mockResolvedValue(false);
await expect(p.healStalePl24Architecture("veh-1", vehicle, 120)).resolves.toBe(false);
expect(pl24Service.decodeVin).not.toHaveBeenCalled();
});
});

View File

@@ -1,11 +1,19 @@
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { and, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import { and, eq, gte, ilike, inArray, isNotNull, isNull, or, sql } from "drizzle-orm";
import {
type CatalogDegradationKind,
reportCatalogDegradation,
} from "../common/catalog-degradation";
import { DATABASE, type Database } from "../database/database.provider";
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
import {
canonicalCategories,
canonicalStructureTemplate,
canonicalTemplateEntries,
categories,
parts,
schemaPics,
vehicles,
} from "../database/schema/core";
import { EmexSourceDbService } from "../integrations/catalog-source-db/emex-source-db.service";
import { PcatSourceDbService } from "../integrations/catalog-source-db/pcat-source-db.service";
import { EmexService } from "../integrations/emex/emex.service";
@@ -13,10 +21,22 @@ import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catal
import { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
import { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
import { PL24PsaService } from "../integrations/pl24/pl24-psa.service";
import {
isPl24GroupNode,
isPl24LeafNode,
isStalePl24Architecture,
} from "../integrations/pl24/pl24-tree";
import { PL24Service } from "../integrations/pl24/pl24.service";
import { getServiceApiPath } from "../integrations/pl24/pl24.types";
import { classifyNode, foldName, mapToCanonical } from "../jobs/canonical-lexicon";
import { RedisService } from "../redis/redis.service";
import { StorageService } from "../storage/storage.service";
import { TranslationsService } from "../translations/translations.service";
import {
buildCanonicalInput,
groupCategoriesByCanonical,
overlayTemplate,
} from "./canonical-grouping";
@Injectable()
export class CategoriesService {
@@ -36,8 +56,8 @@ export class CategoriesService {
private emexSourceDb: EmexSourceDbService,
) {}
async getCategoryTree(vehicleId: string) {
const cacheKey = `cat:tree:${vehicleId}`;
async getCategoryTree(vehicleId: string, sourceFilter?: string) {
const cacheKey = `cat:tree:${vehicleId}${sourceFilter ? `:${sourceFilter}` : ""}`;
const cached = await this.redis.getJson<any[]>(cacheKey);
if (cached) return cached;
@@ -56,6 +76,20 @@ export class CategoriesService {
.from(categories)
.where(eq(categories.vehicleId, vehicleId));
// ── P4 → P5 self-healing (plv2.md Faz 2, bulgu psa-07) ──
// PSA and Volvo moved to P5 upstream; rows decoded before that still point at
// the frozen 2024-02-13 PSA snapshot or the dead P4 Volvo endpoint, and
// decodeVin's db_hit short-circuit means no code fix ever reaches them. Heal
// one vehicle per view instead of a bulk re-decode storm against the single
// surviving account.
const healed = await this.healStalePl24Architecture(vehicleId, vehicle, dbCategories.length);
if (healed) {
dbCategories = await this.db
.select()
.from(categories)
.where(eq(categories.vehicleId, vehicleId));
}
// If no categories in DB, fetch from PL24
if (dbCategories.length === 0 && vehicle.rawData) {
const rawData = vehicle.rawData as any;
@@ -120,6 +154,10 @@ export class CategoriesService {
externalId: s.code,
linkPath: s.linkPath || null,
linkWid: null as string | null,
// PSA scopes are always parents (they hold main-groups, never direct
// parts) — flag them so the tree UI signposts them as folders and the
// prefetch worker pre-drills them instead of treating them as leaves.
hasSubgroups: true,
source: "pl24" as const,
}));
dbCategories = await this.db
@@ -181,6 +219,10 @@ export class CategoriesService {
externalId: c.code,
linkPath: c.linkPath || null,
linkWid: c.linkWid || null,
// Mark P4 VIN/legacy group-drill nodes (…group.action) as parents so
// the tree UI shows them as folders and prefetch pre-drills them. Leaf
// pages (image-board.action/bom) stay null (unknown→leaf downstream).
hasSubgroups: c.linkPath?.includes("group.action") ? true : null,
source: "pl24" as const,
}));
@@ -435,8 +477,23 @@ export class CategoriesService {
partCountRows.map((r) => [r.categoryId, r.count]),
);
// Default catalog source for a multi-source vehicle: prefer eper-pekidi — the
// native OEM ePER tree Fiat dealers already know, fully populated with parts +
// diagrams — over parts-catalogs' broader-but-lazy (drill-on-demand) folders,
// which is where dealers hit gaps (a motor OEM missing under "Motor"). pcat is
// never dropped: the ?source override + /sources endpoint keep it one tap away.
const presentSources = [...new Set(dbCategories.map((c) => c.source))];
const activeSource =
sourceFilter && presentSources.includes(sourceFilter)
? sourceFilter
: presentSources.includes("eper-pekidi")
? "eper-pekidi"
: null;
const forTree = activeSource
? dbCategories.filter((c) => c.source === activeSource)
: dbCategories;
// Build tree
const tree = this.buildTree(dbCategories, directPartCounts);
const tree = this.buildTree(forTree, directPartCounts);
// Cache a populated tree for an hour; an EMPTY tree (transient decode/proxy
// failure) only for 60s so a blip doesn't poison the catalog for an hour —
// it self-heals on the next request after the source recovers, while still
@@ -458,6 +515,210 @@ export class CategoriesService {
return tree;
}
/**
* The catalog sources a decoded vehicle has categories from, ordered with the
* default first (eper-pekidi — the dealer-familiar native ePER catalog). Drives
* the per-vehicle source switcher (ePER ⇄ Diğer); a single source → no switcher.
*/
async getCatalogSources(vehicleId: string): Promise<string[]> {
const rows = await this.db
.selectDistinct({ source: categories.source })
.from(categories)
.where(eq(categories.vehicleId, vehicleId));
const present = rows.map((r) => r.source);
const order = ["eper-pekidi", "parts-catalogs", "emex", "pl24"];
return present.sort((a, b) => {
const ia = order.indexOf(a);
const ib = order.indexOf(b);
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
});
}
/**
* The unified canonical taxonomy (20 top buckets), source/brand-independent.
* Used for the browse landing + as the fixed set of headings every vehicle's
* catalog folds into.
*/
async getCanonicalCategories() {
return this.db
.select({
id: canonicalCategories.id,
slug: canonicalCategories.slug,
name: canonicalCategories.name,
})
.from(canonicalCategories)
.where(isNull(canonicalCategories.parentId))
.orderBy(canonicalCategories.sortOrder);
}
/**
* Full 2-level canonical taxonomy — the source-independent category structure
* a B2C API consumer browses by: 20 top buckets, each with its curated
* sub-leaves (Fren → Balata/Disk/Kaliper…). `mappedCategories` shows how many
* source categories currently fall in each node (population signal); sub-leaves
* with 0 are still listed so the contract is stable. Sub-mapping is completed
* over time in the super-panel, so counts only grow.
*/
async getCanonicalTaxonomy() {
const nodes = await this.db
.select({
id: canonicalCategories.id,
parentId: canonicalCategories.parentId,
slug: canonicalCategories.slug,
name: canonicalCategories.name,
sortOrder: canonicalCategories.sortOrder,
})
.from(canonicalCategories)
.orderBy(canonicalCategories.sortOrder);
// population per node: top buckets count canonical_category_id, sub-leaves
// count canonical_subcategory_id. One grouped scan each (both columns indexed).
const topCounts = await this.db
.select({ id: categories.canonicalCategoryId, n: sql<number>`count(*)::int` })
.from(categories)
.where(isNotNull(categories.canonicalCategoryId))
.groupBy(categories.canonicalCategoryId);
const subCounts = await this.db
.select({ id: categories.canonicalSubcategoryId, n: sql<number>`count(*)::int` })
.from(categories)
.where(isNotNull(categories.canonicalSubcategoryId))
.groupBy(categories.canonicalSubcategoryId);
const countById = new Map<string, number>();
for (const r of [...topCounts, ...subCounts]) if (r.id) countById.set(r.id, r.n);
const subsByParent = new Map<string, typeof nodes>();
for (const n of nodes) {
if (!n.parentId) continue;
const arr = subsByParent.get(n.parentId) ?? [];
arr.push(n);
subsByParent.set(n.parentId, arr);
}
return nodes
.filter((n) => !n.parentId)
.map((b) => ({
id: b.id,
slug: b.slug,
name: b.name,
mappedCategories: countById.get(b.id) ?? 0,
subcategories: (subsByParent.get(b.id) ?? []).map((s) => ({
id: s.id,
slug: s.slug,
name: s.name,
mappedCategories: countById.get(s.id) ?? 0,
})),
}));
}
/**
* Unified browse: a vehicle's part-holding leaf categories grouped under the
* 20 canonical buckets, so VW and Opel (whose raw OEM trees differ wildly) are
* navigated through one identical set of headings. Each entry lists the source
* leaf categories that fall in that bucket for this vehicle, with part counts;
* the UI drills into a leaf via the existing /categories/:id endpoint.
*
* Bucketing uses the materialized categories.canonical_category_id when set,
* falling back to on-the-fly mapping (shared canonical-lexicon) for categories
* decoded since the last backfill. Categories with no bucket land under "Diğer"
* so nothing is hidden.
*/
async getCanonicalTree(vehicleId: string) {
// Ensure the vehicle's raw source tree is populated (lazy decode + cache).
await this.getCategoryTree(vehicleId);
const buckets = await this.getCanonicalCategories();
const subLeaves = await this.db
.select({
id: canonicalCategories.id,
parentId: canonicalCategories.parentId,
slug: canonicalCategories.slug,
name: canonicalCategories.name,
})
.from(canonicalCategories)
.where(isNotNull(canonicalCategories.parentId))
.orderBy(canonicalCategories.sortOrder);
const cats = await this.db
.select({
id: categories.id,
name: categories.name,
source: categories.source,
canonicalId: categories.canonicalCategoryId,
subId: categories.canonicalSubcategoryId,
parentId: categories.parentId,
})
.from(categories)
.where(eq(categories.vehicleId, vehicleId));
const partCountRows = await this.db
.select({ categoryId: parts.categoryId, count: sql<number>`count(*)::int` })
.from(parts)
.where(eq(parts.vehicleId, vehicleId))
.groupBy(parts.categoryId);
const partCounts = new Map(partCountRows.map((r) => [r.categoryId, r.count]));
const {
input: shown,
counts: shownCounts,
hasChildren,
} = buildCanonicalInput(cats, partCounts, buckets);
const groups = groupCategoriesByCanonical(shown, shownCounts, buckets, {
requireParts: false,
subLeaves,
hasChildren,
});
// Overlay the per-brand structure template: buckets / sub-leaves this brand
// is known to have but this vehicle hasn't deep-drilled yet appear as empty
// entries, so the FULL canonical structure shows immediately after decode.
const veh = await this.db
.select({ brand: vehicles.brandName, source: vehicles.source })
.from(vehicles)
.where(eq(vehicles.id, vehicleId))
.limit(1);
const brand = veh[0]?.brand;
const source = cats[0]?.source ?? veh[0]?.source;
if (!brand || !source) return groups;
const template = await this.db
.select({
bucketId: canonicalStructureTemplate.canonicalCategoryId,
subId: canonicalStructureTemplate.canonicalSubcategoryId,
})
.from(canonicalStructureTemplate)
.where(
and(
eq(canonicalStructureTemplate.source, source),
eq(canonicalStructureTemplate.brand, brand),
gte(canonicalStructureTemplate.vehicleCount, 2),
),
);
const templateEntriesRaw = await this.db
.select({
bucketId: canonicalTemplateEntries.canonicalCategoryId,
subId: canonicalTemplateEntries.canonicalSubcategoryId,
name: canonicalTemplateEntries.name,
vehicleCount: canonicalTemplateEntries.vehicleCount,
})
.from(canonicalTemplateEntries)
.where(
and(
eq(canonicalTemplateEntries.source, source),
eq(canonicalTemplateEntries.brand, brand),
gte(canonicalTemplateEntries.vehicleCount, 2),
),
);
// Cap placeholders at the MOST-SHARED 40 per bucket — some brands carry
// hundreds of model-variant names (pl24 BMW elektrik: 500+) that would
// drown the view; the widely-shared ones are the real "brand structure".
templateEntriesRaw.sort((a, z) => z.vehicleCount - a.vehicleCount);
const perBucket = new Map<string, number>();
const templateEntries = templateEntriesRaw.filter((e) => {
const n = (perBucket.get(e.bucketId) ?? 0) + 1;
perBucket.set(e.bucketId, n);
return n <= 40;
});
return overlayTemplate(groups, template, buckets, subLeaves, templateEntries);
}
/**
* Get sub-categories (sub-groups) for a category.
* Fetches from PL24 on-demand if not cached.
@@ -655,17 +916,13 @@ export class CategoriesService {
// subgroups. Drilling into them used to insert per-part endpoints as
// ghost child categories — keep the regex wide so any /extern/{kind}/
// (vin|mdl)_items endpoint is recognised, not just /servicepart/.
const lp = linkPath.toLowerCase();
// One shared classifier (integrations/pl24/pl24-tree) — the inline lists here,
// in catalog.service and in the prefetch worker used to disagree, which is how
// p5psa/p5volvo camelCase `bomDetails` leaves and `illusTable` group levels
// ended up on the wrong side (silent empty panels / unfetched parts).
if (
lp.includes("/bom/") ||
lp.includes("/bomdetails") ||
lp.includes("/partinfo/") ||
/\/extern\/[^/]+\/(vin_items|mdl_items)\b/.test(lp) ||
// PSA / Hyundai / Opel / Volvo image-board pages and the Ford VIN
// vin-image-board.action equivalent. Drilling into them yields BOM rows,
// not sub-groups — let getCategoryWithParts handle those as parts.
lp.includes("image-board.action") ||
lp.includes("json-vin-bom-detail.action")
isPl24LeafNode({ linkPath }) ||
linkPath.toLowerCase().includes("json-vin-bom-detail.action")
) {
return [];
}
@@ -750,6 +1007,104 @@ export class CategoriesService {
* The per-node trail excludes the node itself, ordered root-first. Used by the
* catalog search so each hit can show where it sits in the tree.
*/
/**
* Re-decode a vehicle whose stored catalogInfo still points at a retired PL24
* architecture (P4 PSA/Volvo) and replace its category tree with the P5 one.
*
* Returns true when the tree was rebuilt. Deliberately conservative:
* - only PSA/Volvo rows whose service is P5 today are touched;
* - a failed or empty re-decode leaves the old tree in place (stale data beats
* no data), and the attempt is remembered for a day so a permanently
* unresolvable VIN cannot re-hit PL24 on every page view;
* - the old rows are deleted only once the new tree is in hand, because the
* unique (vehicle_id, name, source) index would otherwise reject the insert.
*/
private async healStalePl24Architecture(
vehicleId: string,
vehicle: { vin: string | null; rawData: unknown },
existingCategoryCount: number,
): Promise<boolean> {
const rawData = vehicle.rawData as {
catalogInfo?: { serviceName?: string; catalogPath?: string };
} | null;
const catalogInfo = rawData?.catalogInfo;
const serviceName = catalogInfo?.serviceName;
if (!vehicle.vin || !serviceName) return false;
if (
!isStalePl24Architecture({
catalogPath: catalogInfo?.catalogPath,
currentApiPath: getServiceApiPath(serviceName),
})
) {
return false;
}
const attemptKey = `pl24:heal:${vehicleId}`;
if (!(await this.redis.setNx(attemptKey, "1", 86_400))) return false;
this.logger.log(
`[pl24-heal] ${vehicle.vin} (${serviceName}) was decoded on ${catalogInfo?.catalogPath} — re-decoding on P5`,
);
let decoded: Awaited<ReturnType<PL24Service["decodeVin"]>> | null = null;
try {
await this.redis.del(`pl24:vehicle:${vehicle.vin}`);
decoded = await this.pl24Service.decodeVin(vehicle.vin);
} catch (err) {
this.logger.warn(`[pl24-heal] ${vehicle.vin} re-decode failed: ${(err as Error).message}`);
return false;
}
if (!decoded?.categories?.length) {
this.logger.warn(
`[pl24-heal] ${vehicle.vin} re-decode returned no categories — keeping old tree`,
);
return false;
}
await this.db.transaction(async (tx) => {
await tx.delete(categories).where(eq(categories.vehicleId, vehicleId));
const seen = new Set<string>();
const rows = decoded.categories
.filter((c) => {
const name = c.nameTr || c.nameEn;
if (!name || seen.has(name)) return false;
seen.add(name);
return true;
})
.map((c) => ({
vehicleId,
catalogVehicleId: null as string | null,
name: c.nameTr || c.nameEn,
nameOriginal: c.nameEn,
parentId: null as string | null,
externalId: c.code,
linkPath: c.linkPath || null,
linkWid: c.linkWid || null,
source: "pl24" as const,
}));
if (rows.length) await tx.insert(categories).values(rows);
await tx
.update(vehicles)
.set({
rawData: { ...(rawData ?? {}), catalogInfo: decoded.catalogInfo },
fullyFetched: false,
fullyFetchedAt: null,
})
.where(eq(vehicles.id, vehicleId));
});
await Promise.all([
this.redis.del(`cat:tree:${vehicleId}`),
this.redis.del(`prefetch:complete:${vehicleId}`),
this.redis.del(`prefetch:noresult:${vehicleId}`),
]);
this.logger.log(
`[pl24-heal] ${vehicle.vin} migrated to ${decoded.catalogInfo?.catalogPath}: ${existingCategoryCount} stale → ${decoded.categories.length} fresh categories`,
);
return true;
}
private async buildBreadcrumbs(
ids: string[],
): Promise<Map<string, Array<{ id: string; name: string }>>> {
@@ -1075,9 +1430,13 @@ export class CategoriesService {
// AND lowercase (groupReferenceTable, groupTable, groupsTable). The old
// case-sensitive includes("Group") missed the lowercase ones (~1157 nodes),
// so they skipped this group-drill branch and fell to the parts path.
// A PL24 node is a parent when the shared classifier says it is not a parts
// leaf. The old `linkWid.includes("group")` test missed p5psa `illusTable`
// and p5volvo/p5subaru `illustrationsTable`, so those levels were fetched as
// parts, parsed to zero rows and rendered as an empty panel with no error.
if (
category.source === "pl24" &&
category.linkWid?.toLowerCase().includes("group") &&
isPl24GroupNode({ linkPath: category.linkPath, linkWid: category.linkWid }) &&
category.vehicleId
) {
const groupChildren = await this.getChildren(categoryId);
@@ -1123,6 +1482,39 @@ export class CategoriesService {
// No subgroups → leaf; fall through to the parts path below.
}
// PL24 P4 VIN / legacy group node whose link_wid is a positional code
// ("1","2","CAP1"…) rather than a *Group* table, so the link_wid branch above
// misses it, and which is neither PSA- nor Volvo-special. Its linkPath is a
// sub-group / main-group drill action (…group.action — never a parts leaf like
// image-board.action). This is the Ford / Nissan / Opel / Hyundai-Kia analog of
// the PSA-parent-gate gap: without it these VIN scopes ("mekanik", "kaporta",
// "Elektrikli aksam"…) fell through every parent branch to the leaf parts path,
// fetched no parts and rendered a silent empty panel — confirmed the dominant
// current "0 parça" cluster. Volvo's vin-group.action keeps its own branch above
// (it may legitimately bottom out in parts), so exclude it here. Drill on demand:
// subgroups → parent; an empty drill is a transient upstream failure → retryable
// load error (a group node is never legitimately a parts leaf).
if (
category.source === "pl24" &&
category.linkPath?.includes("group.action") &&
!category.linkPath.includes("vin-group.action") &&
category.vehicleId
) {
const p4Children = await this.getChildren(categoryId);
const base = {
id: category.id,
name: category.name,
description: category.nameOriginal || null,
parentId: category.parentId || null,
parts: [],
schemaPics: [],
hotspots: [],
};
return p4Children.length > 0
? { ...base, children: p4Children }
: { ...base, loadError: true };
}
// EMEX Vehicle.aspx group node (linkWid="emex-group") — a parent, never a
// parts leaf. Return its children (seeded sub-groups, or Unit leaves drilled
// on demand by getChildren). Mirrors the pl24 group-node branch above; an
@@ -1899,15 +2291,8 @@ export class CategoriesService {
return !!c.linkPath?.startsWith("pcat:"); // unknown → lazy-leaf heuristic
})()
: (() => {
const lp = c.linkPath?.toLowerCase() ?? "";
return (
lp.includes("/bom/") ||
lp.includes("/bomdetails") ||
lp.includes("/partinfo/") ||
/\/extern\/[^/]+\/(vin_items|mdl_items)\b/.test(lp) ||
lp.includes("image-board.action") ||
(!c.linkPath && dbChildCount === 0)
);
if (!c.linkPath) return dbChildCount === 0;
return isPl24LeafNode({ linkPath: c.linkPath, linkWid: c.linkWid });
})();
return {
...c,

View File

@@ -0,0 +1,96 @@
import {
type CanActivate,
type ExecutionContext,
HttpException,
HttpStatus,
Inject,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { eq } from "drizzle-orm";
import { type ApiKeyRecord, getApiKeyApi } from "../../auth/auth";
import { DATABASE, type Database } from "../../database/database.provider";
import { users } from "../../database/schema/core";
export type VerifiedApiKey = ApiKeyRecord;
/**
* Public API (/api/v1) kimlik doğrulaması. Cookie tabanlı AuthGuard'dan
* tamamen ayrı: anahtar `Authorization: Bearer sase_...` veya `x-api-key`
* header'ında gelir, better-auth apiKey plugin'i ile doğrulanır (hash
* karşılaştırma + enabled/expiry/anahtar-başına rate-limit). Geçerliyse
* anahtarın sahibi kullanıcı yüklenir ve `request.user` + `request.apiKey`
* doldurulur — böylece mevcut servisler (decodeVin'in abonelik/marka
* yetki kontrolü dahil) hiçbir değişiklik olmadan çalışır.
*
* Controller'lar bu guard'ı `@Public()` ile birlikte kullanmalı; @Public
* global AuthGuard'ı atlatır, kimlik işini bu guard devralır.
*/
@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(@Inject(DATABASE) private readonly db: Database) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const rawKey = this.extractKey(request.headers);
if (!rawKey) {
throw new UnauthorizedException(
"API anahtarı gerekli. Anahtarınızı 'Authorization: Bearer <anahtar>' header'ında gönderin.",
);
}
let result: Awaited<ReturnType<ReturnType<typeof getApiKeyApi>["verifyApiKey"]>>;
try {
result = await getApiKeyApi().verifyApiKey({ body: { key: rawKey } });
} catch {
throw new UnauthorizedException("Geçersiz API anahtarı.");
}
if (!result.valid || !result.key) {
const code = result.error?.code ?? "INVALID_API_KEY";
if (code === "RATE_LIMITED" || code === "USAGE_EXCEEDED" || code === "TOO_MANY_REQUESTS") {
throw new HttpException(
"İstek limiti aşıldı. Lütfen bekleyip tekrar deneyin.",
HttpStatus.TOO_MANY_REQUESTS,
);
}
if (code === "KEY_EXPIRED") {
throw new UnauthorizedException("API anahtarının süresi dolmuş.");
}
if (code === "KEY_DISABLED") {
throw new UnauthorizedException("API anahtarı devre dışı bırakılmış.");
}
throw new UnauthorizedException("Geçersiz API anahtarı.");
}
const key = result.key;
const [user] = await this.db.select().from(users).where(eq(users.id, key.userId)).limit(1);
if (!user) {
throw new UnauthorizedException("API anahtarının sahibi bulunamadı.");
}
if (user.status !== "active") {
throw new UnauthorizedException(
"Hesabınız askıya alınmış durumda. Destek ile iletişime geçin.",
);
}
request.user = user;
request.apiKey = key;
return true;
}
/** Authorization: Bearer <key> (tercih edilen) veya x-api-key header'ı. */
private extractKey(headers: Record<string, unknown>): string | null {
const authHeader = headers.authorization;
if (typeof authHeader === "string" && authHeader.length > 0) {
const match = authHeader.match(/^Bearer\s+(.+)$/i);
const value = (match ? match[1] : authHeader).trim();
if (value.length > 0) return value;
}
const apiKeyHeader = headers["x-api-key"];
if (typeof apiKeyHeader === "string" && apiKeyHeader.trim().length > 0) {
return apiKeyHeader.trim();
}
return null;
}
}

View File

@@ -1,10 +1,10 @@
import { timingSafeEqual } from "node:crypto";
import {
type CanActivate,
type ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { timingSafeEqual } from "node:crypto";
@Injectable()
export class InternalTokenGuard implements CanActivate {

View File

@@ -0,0 +1,10 @@
import { Global, Module } from "@nestjs/common";
import { TelegramService } from "./telegram.service";
/** Global so any module can raise an operational alert without extra wiring. */
@Global()
@Module({
providers: [TelegramService],
exports: [TelegramService],
})
export class TelegramModule {}

View File

@@ -0,0 +1,56 @@
import { Injectable, Logger } from "@nestjs/common";
/**
* Minimal Telegram alerting for operational failures that need a human now
* (currently: PL24 auth down for an hour — i.e. the account may be banned
* again). Fire-and-forget: a failed alert must never affect a request.
*
* Uses the same bot as the Süper Panel — set TELEGRAM_BOT_TOKEN and
* TELEGRAM_CHAT_ID. Unconfigured = silently disabled (local/dev).
*/
@Injectable()
export class TelegramService {
private readonly logger = new Logger(TelegramService.name);
private get token(): string {
return process.env.TELEGRAM_BOT_TOKEN ?? "";
}
private get chatId(): string {
return process.env.TELEGRAM_CHAT_ID ?? "";
}
isConfigured(): boolean {
return Boolean(this.token && this.chatId);
}
/** Send a message. Returns false when disabled or the API refused. */
async send(text: string, opts: { silent?: boolean } = {}): Promise<boolean> {
if (!this.isConfigured()) {
this.logger.warn(`Telegram not configured — alert dropped: ${text.slice(0, 120)}`);
return false;
}
try {
const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: this.chatId,
text,
parse_mode: "HTML",
disable_notification: opts.silent ?? false,
disable_web_page_preview: true,
}),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
this.logger.warn(`Telegram sendMessage failed: HTTP ${res.status}`);
return false;
}
return true;
} catch (err) {
this.logger.warn(`Telegram sendMessage error: ${(err as Error).message}`);
return false;
}
}
}

View File

@@ -88,6 +88,22 @@ export default () => ({
.map((s) => s.trim())
.filter(Boolean),
},
p: {
// Read-only lookup against the imported P snapshot (db `td`). When
// enabled + url set, the OEM detail page resolves a part's OEM code to
// P aftermarket equivalents + OE cross-references. Disabled → endpoint
// returns { matched: false } and the UI shows an empty state.
enabled: process.env.P_DB_ENABLED === "true",
url: process.env.P_DB_URL,
},
supplierPrices: {
// Tedarikçi fiyat geçmişi (takip MySQL, Tailscale). Kapalı/boş URL →
// part-prices uçları { matched: false } / boş batch döner; UI fiyat
// bölümünü hiç render etmez. Tedarikçi kimliği API cevabına asla çıkmaz —
// yalnızca stoktaki tekliflerin p50/p95/p99 dağılımı servis edilir.
enabled: process.env.SUPPLIER_PRICE_DB_ENABLED === "true",
url: process.env.SUPPLIER_PRICE_DB_URL,
},
otel: {
enabled: process.env.OTEL_ENABLED === "true",
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,

View File

@@ -1,10 +1,12 @@
import {
boolean,
date,
index,
integer,
jsonb,
numeric,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
@@ -37,6 +39,10 @@ export const users = pgTable(
// subscription yet (referrer had no active/trial sub at grant time).
// Consumed when the user next starts a trial or activates a subscription.
referralCreditDays: integer("referral_credit_days").default(0).notNull(),
// Stripe Customer backing this user's recurring billing. Set on the first
// completed checkout and reused on later checkouts so saved cards and
// invoices stay on one customer record.
stripeCustomerId: text("stripe_customer_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
@@ -110,6 +116,46 @@ export const verifications = pgTable(
(table) => [index("verifications_identifier_idx").on(table.identifier)],
);
// ─── Better Auth: API Keys (public API) ──────────────
// Backing table for the better-auth `apiKey` plugin (model "apikey", plural).
// Column set mirrors the plugin's schema exactly — the drizzle adapter matches
// by property name, so renaming a property here breaks key verification.
// `key` holds the SHA256 hash of the secret; the plaintext is shown once at
// creation. `metadata` is a JSON string (enableMetadata) carrying
// per-key overrides such as dailyDecodeLimit.
export const apikeys = pgTable(
"apikeys",
{
id: text("id").primaryKey(),
name: text("name"),
start: text("start"),
prefix: text("prefix"),
key: text("key").notNull(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"),
lastRefillAt: timestamp("last_refill_at", { withTimezone: true }),
enabled: boolean("enabled").default(true).notNull(),
rateLimitEnabled: boolean("rate_limit_enabled").default(true).notNull(),
rateLimitTimeWindow: integer("rate_limit_time_window"),
rateLimitMax: integer("rate_limit_max"),
requestCount: integer("request_count").default(0).notNull(),
remaining: integer("remaining"),
lastRequest: timestamp("last_request", { withTimezone: true }),
expiresAt: timestamp("expires_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
permissions: text("permissions"),
metadata: text("metadata"),
},
(table) => [
index("apikeys_key_idx").on(table.key),
index("apikeys_user_id_idx").on(table.userId),
],
);
// ─── Brands ─────────────────────────────────────────
export const brands = pgTable(
"brands",
@@ -153,12 +199,23 @@ export const userSubscriptions = pgTable(
startDate: timestamp("start_date", { withTimezone: true }),
endDate: timestamp("end_date", { withTimezone: true }),
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
// Stripe Subscription id for recurring (mode:"subscription") billing.
// NULL for trials and legacy one-time purchases. Renewal invoices resolve
// our row through this.
stripeSubscriptionId: text("stripe_subscription_id"),
// Dunning state — set on the first failed renewal charge, cleared when the
// invoice recovers (invoice.paid) or the sub is finally cancelled. The URL
// is Stripe's hosted invoice page: the ONLY self-serve surface where the
// user can pay the open invoice / enter a new card / pass 3DS.
dunningSince: timestamp("dunning_since", { withTimezone: true }),
dunningInvoiceUrl: text("dunning_invoice_url"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("user_subscriptions_user_id_idx").on(table.userId),
index("user_subscriptions_status_idx").on(table.status),
index("user_subscriptions_stripe_sub_idx").on(table.stripeSubscriptionId),
],
);
@@ -184,7 +241,9 @@ export const userBrands = pgTable(
],
);
// ─── Bank Accounts (EFT/Havale destination accounts, one active at a time) ────
// ─── Bank Accounts ───────────────────────────────────
// @deprecated EFT/Havale was retired (Stripe is the sole payment method). Table
// kept only to preserve historical FK integrity; no rows are created anymore.
export const bankAccounts = pgTable(
"bank_accounts",
{
@@ -225,7 +284,12 @@ export const payments = pgTable(
iyzicoPaymentId: text("iyzico_payment_id"),
stripeSessionId: text("stripe_session_id"),
stripePaymentIntentId: text("stripe_payment_intent_id"),
// Stripe Invoice behind a recurring charge (renewals). Dedupe key against
// invoice.paid webhook retries.
stripeInvoiceId: text("stripe_invoice_id"),
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
bankAccountId: uuid("bank_account_id").references(() => bankAccounts.id),
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
eftReceiptUrl: text("eft_receipt_url"),
adminNote: text("admin_note"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
@@ -235,6 +299,7 @@ export const payments = pgTable(
index("payments_user_id_idx").on(table.userId),
index("payments_status_idx").on(table.status),
index("payments_stripe_session_id_idx").on(table.stripeSessionId),
index("payments_stripe_invoice_id_idx").on(table.stripeInvoiceId),
],
);
@@ -318,6 +383,62 @@ export const catalogVehicles = pgTable(
],
);
// ─── Vinpin decodes (ePER decode-oracle cache — one row per VIN) ──
// Feature-flagged (VINPIN_ENABLED). Populated by the vinpin-decode BullMQ job
// which drives the Vinpin ePER web catalog for Fiat VINs the normal decode chain
// (PL24/pcat/emex) can't identify. On success the decoded model is matched to an
// EXISTING PL24 catalog_vehicle (catalog_vehicle_id) and parts are served from
// there — Vinpin is ONLY a decode oracle, never a parts source.
export const vinpinDecodes = pgTable("vinpin_decodes", {
vin: text("vin").primaryKey(),
// 'pending' | 'decoded' | 'not_found' | 'failed'
status: text("status").notNull().default("pending"),
brandName: text("brand_name"),
model: text("model"),
sincom: text("sincom"),
trim: text("trim"),
modelYear: text("model_year"),
engine: text("engine"),
catalogVehicleId: uuid("catalog_vehicle_id").references(() => catalogVehicles.id, {
onDelete: "set null",
}),
raw: jsonb("raw"),
attempts: integer("attempts").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
decodedAt: timestamp("decoded_at", { withTimezone: true }),
});
// ─── RPartStore decodes (Renault/Dacia decode-oracle cache — one row per VIN) ──
// Feature-flagged (RPARTSTORE_ENABLED). Populated by the rpartstore-decode BullMQ
// job which asks the RPartStore BFF (rpartstore.renault.com, STOMP/WebSocket) for
// Renault/Dacia VINs the normal chain (pcat/PL24/emex) can't identify. On success
// the decoded model is matched to an EXISTING PL24 catalog_vehicle and parts are
// served from there — RPartStore is ONLY a decode oracle. Hard daily cap on
// searches (RPARTSTORE_DAILY_CAP); over-cap VINs are 'capped' and retried after 24 h.
export const rpartstoreDecodes = pgTable("rpartstore_decodes", {
vin: text("vin").primaryKey(),
// 'pending' | 'decoded' | 'not_found' | 'capped' | 'failed'
status: text("status").notNull().default("pending"),
brandName: text("brand_name"),
model: text("model"),
modelCode: text("model_code"),
familyCode: text("family_code"),
modelYear: text("model_year"),
engine: text("engine"),
gearbox: text("gearbox"),
energyType: text("energy_type"),
manufacturingDate: text("manufacturing_date"),
catalogVehicleId: uuid("catalog_vehicle_id").references(() => catalogVehicles.id, {
onDelete: "set null",
}),
raw: jsonb("raw"),
attempts: integer("attempts").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
decodedAt: timestamp("decoded_at", { withTimezone: true }),
});
// ─── Vehicles (shared config — one record per VIN) ──
export const vehicles = pgTable(
"vehicles",
@@ -334,10 +455,20 @@ export const vehicles = pgTable(
market: varchar("market", { length: 100 }),
rawData: jsonb("raw_data"),
source: varchar("source", { length: 20 }).default("pl24").notNull(),
// Durable completeness marker: set true when the prefetch chain finishes with
// parts (see PrefetchWorkerService.incrementCompleted). Unlike the ephemeral
// Redis `prefetch:complete:*` marker (21-day TTL, operational skip logic) this
// never expires — it's the reliable "% fully fetched" measurement. `_at` tracks
// the last time completion was confirmed (re-set on each re-drill completion).
fullyFetched: boolean("fully_fetched").default(false).notNull(),
fullyFetchedAt: timestamp("fully_fetched_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [uniqueIndex("vehicles_vin_unique_idx").on(table.vin)],
(table) => [
uniqueIndex("vehicles_vin_unique_idx").on(table.vin),
index("vehicles_fully_fetched_idx").on(table.fullyFetched),
],
);
// ─── User Vehicles (junction — user ↔ shared vehicle) ─
@@ -386,12 +517,27 @@ export const categories = pgTable(
hasParts: boolean("has_parts"),
unavailable: boolean("unavailable").default(false).notNull(),
source: varchar("source", { length: 20 }).default("pl24").notNull(),
// Materialized result of the name-level canonical mapping (see
// category_canonical_map). Nullable: null = not yet mapped / unmappable.
// Lets callers filter/browse this raw node under the unified taxonomy
// without walking the map table per row.
canonicalCategoryId: uuid("canonical_category_id"),
// Second canonical level: a canonical_categories row whose parentId ==
// canonicalCategoryId (e.g. Fren → Balata). Nullable: null = mapped to the
// top bucket only / no sub-leaf matched → SP manual review completes these.
canonicalSubcategoryId: uuid("canonical_subcategory_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("categories_vehicle_id_idx").on(table.vehicleId),
index("categories_catalog_vehicle_id_idx").on(table.catalogVehicleId),
index("categories_parent_id_idx").on(table.parentId),
index("categories_canonical_category_id_idx").on(table.canonicalCategoryId),
index("categories_canonical_subcategory_id_idx").on(table.canonicalSubcategoryId),
// NULLS NOT DISTINCT since migration 0030 (raw SQL — drizzle's uniqueIndex
// can't express it): emex/pcat rows have NULL catalog_vehicle_id (pl24 rows
// NULL vehicle_id), and with default NULLs-distinct semantics decode retries
// slipped past onConflictDoNothing and duplicated whole category trees.
uniqueIndex("categories_vehicle_name_source_idx").on(
table.vehicleId,
table.catalogVehicleId,
@@ -613,3 +759,339 @@ export const emexCategoryTranslations = pgTable(
},
(table) => [uniqueIndex("emex_translations_original_name_idx").on(table.originalName)],
);
// ─── Proxy Logs ──────────────────────────────────────
// One row per proxied upstream attempt (pcat call/capture/validate, emex http).
// Written fire-and-forget by ProxyTelemetryService so the Süper Panel can grade
// proxy providers (success/ban/latency per service) and track banned exit IPs.
// `banned` = HTTP 403/429 from the upstream (IP-level block signal); `session_key`
// identifies a sticky Floxy session or a DataImpulse port so bans are attributable
// even when the exact exit IP is unknown (rotating call legs).
export const proxyLogs = pgTable(
"proxy_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
// Which integration leg made the request: pcat_call | pcat_capture |
// pcat_validate | emex_http
service: varchar("service", { length: 30 }).notNull(),
// floxy | dataimpulse | none (direct)
provider: varchar("provider", { length: 20 }).notNull(),
// Sticky Floxy session id ("s-ab12cd34") or DataImpulse port ("di:10042");
// null on per-request rotating legs (exit IP changes every call).
sessionKey: varchar("session_key", { length: 60 }),
// Resolved residential exit IP (sticky sessions only — one cheap ipify probe
// per session). Null when rotation makes the IP unknowable.
exitIp: varchar("exit_ip", { length: 45 }),
targetHost: varchar("target_host", { length: 120 }),
statusCode: integer("status_code"),
// Transport failure class when no HTTP answer arrived:
// timeout | connect | reset | dns | aborted | transport
errorKind: varchar("error_kind", { length: 30 }),
success: boolean("success").default(false).notNull(),
banned: boolean("banned").default(false).notNull(),
durationMs: integer("duration_ms"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("proxy_logs_created_at_idx").on(table.createdAt),
index("proxy_logs_banned_created_at_idx").on(table.banned, table.createdAt),
],
);
// ─── OEM Votes (community compatibility verdicts) ─────
// One row per (user, oemCode): a parts seller's uyumlu/uyumsuz verdict on an
// OEM code, cast from the catalog part rows. Votes pool globally per code —
// the part/vehicle/category columns are analytics breadcrumbs only (mirrors
// oem_code_copies: catalog routes pass catalog_vehicles ids here, so no FK).
// Re-voting updates `vote` in place; points are only ever granted on the
// first insert (see oem_vote_points).
export const oemVotes = pgTable(
"oem_votes",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
oemCode: varchar("oem_code", { length: 100 }).notNull(),
// compatible | incompatible
vote: varchar("vote", { length: 12 }).notNull(),
partId: uuid("part_id"),
vehicleId: uuid("vehicle_id"),
categoryId: uuid("category_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("oem_votes_user_oem_idx").on(table.userId, table.oemCode),
index("oem_votes_oem_code_idx").on(table.oemCode),
],
);
// ─── OEM Vote Points (gamification ledger) ────────────
// Append-only: exactly one row per oem_votes row (vote_id unique), written in
// the same transaction as the vote insert. `points` = 1 for voting +2 when
// the vote agreed with the strict majority at cast time (first voter on a
// code always agrees with themselves → 3). Awards are never retro-adjusted
// when the majority later flips. Leaderboard = SUM(points) per user.
export const oemVotePoints = pgTable(
"oem_vote_points",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
voteId: uuid("vote_id")
.notNull()
.references(() => oemVotes.id, { onDelete: "cascade" }),
oemCode: varchar("oem_code", { length: 100 }).notNull(),
points: integer("points").notNull(),
correct: boolean("correct").default(false).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("oem_vote_points_vote_id_idx").on(table.voteId),
index("oem_vote_points_user_id_idx").on(table.userId),
],
);
// ─── OEM Suggestions (community cross-references) ─────
// Crowd-sourced equivalents for an OEM code: "this code's compatible part is
// <brand> <suggestedCode>", collected on the OEM detail page — primarily for
// codes the P snapshot can't match. suggestedCodeNorm (upper, alphanumerics
// only) powers grouping and the per-user dedup constraint; display keeps the
// submitted casing. `status` is a moderation hook (active|hidden) — no rows
// are hidden automatically today.
export const oemSuggestions = pgTable(
"oem_suggestions",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
oemCode: varchar("oem_code", { length: 100 }).notNull(),
brand: varchar("brand", { length: 80 }).notNull(),
suggestedCode: varchar("suggested_code", { length: 100 }).notNull(),
suggestedCodeNorm: varchar("suggested_code_norm", { length: 100 }).notNull(),
status: varchar("status", { length: 16 }).default("active").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("oem_suggestions_user_oem_code_idx").on(
table.userId,
table.oemCode,
table.suggestedCodeNorm,
),
index("oem_suggestions_oem_code_idx").on(table.oemCode),
],
);
// ─── OEM Expert Rewards (monthly leaderboard prizes) ──
// One row per (TR-month, rank): the expert-rewards cron (1st of month 00:00
// Europe/Istanbul) closes the finished season, e-mails the top 3 voters to
// admin@sase.tr (name/e-mail/points + prize 30/15/7 days — EXPERT_REWARD_
// LADDER) and records them here. Subscription days are NOT granted
// automatically — the admin applies them manually from the mail. The unique
// index doubles as the run-once guard: a second run for the same period
// inserts nothing and sends no second mail.
export const oemExpertRewards = pgTable(
"oem_expert_rewards",
{
id: uuid("id").primaryKey().defaultRandom(),
// TR-ayının başlangıcı (UTC timestamptz) — ödüllendirilen sezon.
periodStart: timestamp("period_start", { withTimezone: true }).notNull(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
rank: integer("rank").notNull(),
points: integer("points").notNull(),
rewardDays: integer("reward_days").notNull(),
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("oem_expert_rewards_period_rank_idx").on(table.periodStart, table.rank),
index("oem_expert_rewards_user_id_idx").on(table.userId),
],
);
// ─── Part Price History (tedarikçi fiyat verisi) ──────
// 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 }),
p95: numeric("p95", { precision: 15, scale: 4 }),
p99: numeric("p99", { precision: 15, scale: 4 }),
offerCount: integer("offer_count").default(0).notNull(),
},
(table) => [
primaryKey({ columns: [table.codeNorm, table.brandNorm, table.source, table.date] }),
index("part_price_daily_code_date_idx").on(table.codeNorm, table.brandNorm, table.date),
],
);
// ─── Discount Codes (Stripe-backed checkout promotions) ───────────────────
// Admin-generated promo codes. Each row MIRRORS a Stripe Coupon + Promotion
// Code pair: Stripe is the source of truth for redemption (it enforces the cap
// + expiry at checkout and owns the canonical times_redeemed), while this row
// lets the admin panel list/track codes and lets us preview/validate a code at
// checkout without a per-render Stripe call. Amounts are in kuruş (TRY smallest
// unit), matching plans.price* / payments.amount.
export const discountCodes = pgTable(
"discount_codes",
{
id: uuid("id").primaryKey().defaultRandom(),
// Customer-facing code, stored UPPERCASE; matched case-insensitively.
code: varchar("code", { length: 50 }).notNull(),
// Stripe linkage — both set together at creation. Checkout attaches the
// promotion code id; deactivation archives it on Stripe too.
stripeCouponId: text("stripe_coupon_id"),
stripePromotionCodeId: text("stripe_promotion_code_id"),
// 'percentage' (percentOff 1..100) | 'fixed' (amountOff in kuruş, TRY).
discountType: varchar("discount_type", { length: 12 }).notNull(),
percentOff: integer("percent_off"),
amountOff: integer("amount_off"),
// How long the discount rides a subscription, mirroring Stripe Coupon.duration:
// 'once' (first invoice only), 'repeating' (durationInMonths invoices),
// 'forever' (every renewal).
duration: varchar("duration", { length: 12 }).default("once").notNull(),
durationInMonths: integer("duration_in_months"),
// Total redemption cap across all users (null = unlimited). Stripe enforces.
maxRedemptions: integer("max_redemptions"),
// Mirror of Stripe's times_redeemed; bumped from the checkout webhook and
// reconcilable from Stripe.
timesRedeemed: integer("times_redeemed").default(0).notNull(),
// Code stops working after this instant (null = no expiry). Stripe enforces.
expiresAt: timestamp("expires_at", { withTimezone: true }),
// Admin soft-toggle. Deactivating also archives the Stripe promotion code.
isActive: boolean("is_active").default(true).notNull(),
// Free-text admin note (campaign name, who it's for…).
note: text("note"),
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("discount_codes_code_idx").on(table.code),
index("discount_codes_active_idx").on(table.isActive),
],
);
// ─── Canonical Categories (unified cross-source taxonomy) ──────────────────
// Single manufacturer-independent parts taxonomy that every source's OEM
// microfiche tree maps INTO. The raw per-source trees (`categories.parent_id`
// chains, pl24/emex/pcat mirrors) stay untouched; this is an orthogonal spine
// so VW, Opel, Ford etc. can be browsed/searched under one set of headings.
// Seeded from the Turkish retail taxonomy (aloparca 17 tops) extended with
// OEM-only tops the retail sites don't sell (interior trim/koltuk, body).
// Two levels: parentId NULL = top bucket, else a sub-category under a top.
export const canonicalCategories = pgTable(
"canonical_categories",
{
id: uuid("id").primaryKey().defaultRandom(),
parentId: uuid("parent_id"),
slug: varchar("slug", { length: 100 }).notNull(),
name: varchar("name", { length: 200 }).notNull(),
sortOrder: integer("sort_order").default(0).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [uniqueIndex("canonical_categories_slug_idx").on(table.slug)],
);
// ─── Category → Canonical map (NAME-level, not row-level) ──────────────────
// Mapping key is (source, normalized_name), NOT category.id — because the same
// category name repeats across thousands of vehicles (704k rows collapse to
// ~111k distinct names), so mapping at the name level is ~6× cheaper and a
// single human decision fans out to every matching row. `method` records how
// the row was produced (keyword | fuzzy | embedding | manual) and `confidence`
// gates auto-apply vs review queue; `isManual` protects human-approved rows
// from being overwritten by re-runs (same pattern as emex_category_translations).
export const categoryCanonicalMap = pgTable(
"category_canonical_map",
{
id: uuid("id").primaryKey().defaultRandom(),
source: varchar("source", { length: 20 }).notNull(),
normalizedName: varchar("normalized_name", { length: 500 }).notNull(),
canonicalCategoryId: uuid("canonical_category_id")
.notNull()
.references(() => canonicalCategories.id, { onDelete: "cascade" }),
// Second level materialized alongside the top bucket (name→bucket→sub is
// deterministic). Nullable: no sub-leaf matched → served at top level.
canonicalSubcategoryId: uuid("canonical_subcategory_id"),
method: varchar("method", { length: 20 }).default("keyword").notNull(),
confidence: numeric("confidence", { precision: 4, scale: 3 }),
isManual: boolean("is_manual").default(false).notNull(),
ambiguous: boolean("ambiguous").default(false).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("category_canonical_map_source_name_idx").on(table.source, table.normalizedName),
index("category_canonical_map_canonical_idx").on(table.canonicalCategoryId),
],
);
// ─── Canonical structure template (per source+brand) ──────────────────────
// The canonical STRUCTURE (which buckets/sub-leaves a platform has) is shared
// across vehicles of the same brand — only the parts differ. This table records,
// per (source, brand), each (bucket, sub-leaf) seen across already-drilled
// vehicles with a vehicle_count (support). On decode the serving overlays the
// brand template so the user sees the FULL canonical structure instantly, before
// this vehicle's own tree is deep-drilled by the prefetch backfill. Rebuilt at
// the end of each global canonical-backfill run (derived, so full-refresh).
// Entry-level companion of canonicalStructureTemplate: the browsable source-
// category NAMES every vehicle of (source, brand) shares under a bucket/sub.
// A freshly-decoded vehicle renders these immediately (placeholder until its
// own row exists) — only parts/schemas are vehicle-specific.
export const canonicalTemplateEntries = pgTable(
"canonical_template_entries",
{
id: uuid("id").primaryKey().defaultRandom(),
source: varchar("source", { length: 20 }).notNull(),
brand: varchar("brand", { length: 120 }).notNull(),
canonicalCategoryId: uuid("canonical_category_id").notNull(),
canonicalSubcategoryId: uuid("canonical_subcategory_id"),
name: varchar("name", { length: 500 }).notNull(),
vehicleCount: integer("vehicle_count").default(0).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index("canonical_template_entries_sb_idx").on(table.source, table.brand)],
);
export const canonicalStructureTemplate = pgTable(
"canonical_structure_template",
{
id: uuid("id").primaryKey().defaultRandom(),
source: varchar("source", { length: 20 }).notNull(),
brand: varchar("brand", { length: 120 }).notNull(),
canonicalCategoryId: uuid("canonical_category_id").notNull(),
canonicalSubcategoryId: uuid("canonical_subcategory_id"),
vehicleCount: integer("vehicle_count").default(0).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index("canonical_structure_template_source_brand_idx").on(table.source, table.brand)],
);

View File

@@ -0,0 +1,58 @@
import { BadRequestException, Body, Controller, Get, Param, Patch, Post } from "@nestjs/common";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Roles } from "../common/decorators/roles.decorator";
import { type CreateDiscountInput, DiscountCodesService } from "./discount-codes.service";
interface AuthedUser {
id: string;
email: string;
role: string;
}
@Controller("discount-codes")
export class DiscountCodesController {
constructor(private discountCodes: DiscountCodesService) {}
// ── Any logged-in user: validate + price a code on the checkout page ──
// No @Roles → only the global AuthGuard applies (must be signed in).
@Post("validate")
async validate(
@Body() body: { code: string; planKey: string; billingPeriod: "monthly" | "yearly" },
) {
if (!body?.code || !body?.planKey || !body?.billingPeriod) {
throw new BadRequestException("code, planKey ve billingPeriod zorunlu");
}
return this.discountCodes.preview(body.code, body.planKey, body.billingPeriod);
}
// ── Admin-only management ──
@Roles("admin")
@Get()
async list() {
return this.discountCodes.list();
}
@Roles("admin")
@Get(":id")
async getOne(@Param("id") id: string) {
return this.discountCodes.getOne(id);
}
@Roles("admin")
@Post()
async create(@CurrentUser() user: AuthedUser, @Body() body: CreateDiscountInput) {
return this.discountCodes.create(body, user.id);
}
@Roles("admin")
@Patch(":id/deactivate")
async deactivate(@Param("id") id: string) {
return this.discountCodes.deactivate(id);
}
@Roles("admin")
@Post("sync")
async sync() {
return this.discountCodes.syncRedemptions();
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { StripeModule } from "../payments/stripe/stripe.module";
import { DiscountCodesController } from "./discount-codes.controller";
import { DiscountCodesService } from "./discount-codes.service";
// One-way dependency on StripeModule (StripeService creates the Stripe
// Coupon/Promotion objects + owns checkout-time preview). StripeModule does NOT
// import us back, so there is no cycle.
@Module({
imports: [StripeModule],
controllers: [DiscountCodesController],
providers: [DiscountCodesService],
})
export class DiscountCodesModule {}

View File

@@ -0,0 +1,237 @@
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { desc, eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { discountCodes } from "../database/schema/core";
import { StripeService } from "../payments/stripe/stripe.service";
export type DiscountType = "percentage" | "fixed";
export type DiscountDuration = "once" | "repeating" | "forever";
export interface CreateDiscountInput {
/** Optional custom code; auto-generated when omitted. */
code?: string;
discountType: DiscountType;
/** 1..100, required for `percentage`. */
percentOff?: number;
/** kuruş (TRY smallest unit), required for `fixed`. */
amountOff?: number;
duration?: DiscountDuration;
/** required when duration === "repeating" */
durationInMonths?: number;
/** total redemption cap (null/omitted = unlimited) */
maxRedemptions?: number;
/** ISO date string (null/omitted = no expiry) */
expiresAt?: string;
note?: string;
}
type DiscountRow = typeof discountCodes.$inferSelect;
// 3..50 chars, starts alphanumeric, then alphanumeric / dash / underscore.
const CODE_RE = /^[A-Z0-9][A-Z0-9_-]{2,49}$/;
// Crockford-ish alphabet — no 0/O/1/I confusion in shared codes.
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@Injectable()
export class DiscountCodesService {
private readonly logger = new Logger(DiscountCodesService.name);
constructor(
@Inject(DATABASE) private db: Database,
private stripe: StripeService,
) {}
private generateCode(): string {
let s = "";
for (let i = 0; i < 8; i++) {
s += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)];
}
return `SASE${s}`;
}
private computeStatus(row: DiscountRow): "active" | "inactive" | "expired" | "exhausted" {
if (!row.isActive) return "inactive";
if (row.expiresAt && row.expiresAt.getTime() < Date.now()) return "expired";
if (row.maxRedemptions != null && row.timesRedeemed >= row.maxRedemptions) return "exhausted";
return "active";
}
async create(input: CreateDiscountInput, createdBy: string | null) {
const discountType = input.discountType;
if (discountType !== "percentage" && discountType !== "fixed") {
throw new BadRequestException("discountType 'percentage' veya 'fixed' olmalı");
}
let percentOff: number | null = null;
let amountOff: number | null = null;
if (discountType === "percentage") {
percentOff = Number(input.percentOff);
if (!Number.isInteger(percentOff) || percentOff < 1 || percentOff > 100) {
throw new BadRequestException("Yüzde indirim 1-100 arası tam sayı olmalı");
}
} else {
amountOff = Number(input.amountOff);
if (!Number.isInteger(amountOff) || amountOff < 1) {
throw new BadRequestException("Tutar indirimi (kuruş) 1'den büyük tam sayı olmalı");
}
}
const duration: DiscountDuration = input.duration ?? "once";
if (duration !== "once" && duration !== "repeating" && duration !== "forever") {
throw new BadRequestException("Geçersiz süre türü");
}
let durationInMonths: number | null = null;
if (duration === "repeating") {
durationInMonths = Number(input.durationInMonths);
if (!Number.isInteger(durationInMonths) || durationInMonths < 1 || durationInMonths > 36) {
throw new BadRequestException("Tekrar eden indirimde ay sayısı 1-36 arası olmalı");
}
}
let maxRedemptions: number | null = null;
if (
input.maxRedemptions !== undefined &&
input.maxRedemptions !== null &&
String(input.maxRedemptions) !== ""
) {
maxRedemptions = Number(input.maxRedemptions);
if (!Number.isInteger(maxRedemptions) || maxRedemptions < 1) {
throw new BadRequestException("Kullanım limiti 1'den büyük tam sayı olmalı");
}
}
let expiresAt: Date | null = null;
if (input.expiresAt) {
const d = new Date(input.expiresAt);
if (Number.isNaN(d.getTime())) {
throw new BadRequestException("Geçersiz son kullanma tarihi");
}
if (d.getTime() < Date.now()) {
throw new BadRequestException("Son kullanma tarihi gelecekte olmalı");
}
expiresAt = d;
}
let code = (input.code ?? "").trim().toUpperCase();
if (code) {
if (!CODE_RE.test(code)) {
throw new BadRequestException("Kod 3-50 karakter olmalı; harf, rakam, - ve _ içerebilir");
}
} else {
code = this.generateCode();
}
// Local uniqueness check BEFORE creating Stripe objects, so a duplicate code
// doesn't leave an orphan Stripe coupon behind a failing row insert.
const [existing] = await this.db
.select({ id: discountCodes.id })
.from(discountCodes)
.where(eq(discountCodes.code, code))
.limit(1);
if (existing) throw new BadRequestException(`'${code}' kodu zaten mevcut`);
let couponId: string;
let promotionCodeId: string;
try {
const created = await this.stripe.createPromotion({
code,
discountType,
percentOff: percentOff ?? undefined,
amountOff: amountOff ?? undefined,
duration,
durationInMonths: durationInMonths ?? undefined,
maxRedemptions: maxRedemptions ?? undefined,
expiresAt: expiresAt ?? undefined,
});
couponId = created.couponId;
promotionCodeId = created.promotionCodeId;
} catch (err) {
const msg = (err as Error).message || String(err);
if (msg.toLowerCase().includes("already exists")) {
throw new BadRequestException(`'${code}' kodu Stripe'da zaten kullanımda`);
}
this.logger.error(`Stripe promotion create failed for ${code}: ${msg}`);
throw new BadRequestException(`İndirim kodu oluşturulamadı: ${msg}`);
}
const [row] = await this.db
.insert(discountCodes)
.values({
code,
stripeCouponId: couponId,
stripePromotionCodeId: promotionCodeId,
discountType,
percentOff,
amountOff,
duration,
durationInMonths,
maxRedemptions,
expiresAt,
note: input.note?.slice(0, 500) ?? null,
createdBy,
})
.returning();
this.logger.log(`Discount code created: ${code} (${discountType}) by ${createdBy ?? "?"}`);
return { ...row, status: this.computeStatus(row) };
}
async list() {
const rows = await this.db.select().from(discountCodes).orderBy(desc(discountCodes.createdAt));
return rows.map((r) => ({ ...r, status: this.computeStatus(r) }));
}
async getOne(id: string) {
const [row] = await this.db
.select()
.from(discountCodes)
.where(eq(discountCodes.id, id))
.limit(1);
if (!row) throw new NotFoundException("İndirim kodu bulunamadı");
return { ...row, status: this.computeStatus(row) };
}
async deactivate(id: string) {
const [row] = await this.db
.select()
.from(discountCodes)
.where(eq(discountCodes.id, id))
.limit(1);
if (!row) throw new NotFoundException("İndirim kodu bulunamadı");
// Must archive on Stripe too: checkout resolves the live promotion code, so
// a code left active there would keep working after a local-only deactivate.
if (row.stripePromotionCodeId) {
await this.stripe.deactivatePromotion(row.stripePromotionCodeId);
}
await this.db
.update(discountCodes)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(discountCodes.id, id));
this.logger.log(`Discount code deactivated: ${row.code}`);
return { success: true };
}
/** Reconcile mirrored redemption counts from Stripe (panel "sync" button). */
async syncRedemptions() {
const rows = await this.db.select().from(discountCodes);
let updated = 0;
for (const row of rows) {
if (!row.stripePromotionCodeId) continue;
const count = await this.stripe.getPromotionRedemptions(row.stripePromotionCodeId);
if (count != null && count !== row.timesRedeemed) {
await this.db
.update(discountCodes)
.set({ timesRedeemed: count, updatedAt: new Date() })
.where(eq(discountCodes.id, row.id));
updated++;
}
}
return { synced: rows.length, updated };
}
/** Validate + price a code for the checkout page. */
preview(code: string, planKey: string, billingPeriod: "monthly" | "yearly") {
return this.stripe.previewDiscount(code, planKey, billingPeriod);
}
}

View File

@@ -33,7 +33,11 @@ export class CorgiService {
}
private extractYear(vin: string): number | null {
return extractModelYear(vin);
// Corgi is a WMI-only decoder — it resolves brand + year but never a model.
// Signalling modelResolved:false lets extractModelYear roll a 30-year-ambiguous
// position-10 code (e.g. "T" = 1996-or-2026) back a cycle for an old-shaped
// Renault VIN, so a ~1996 R19 isn't mislabelled as a 2026 car (see vin-validator).
return extractModelYear(vin, undefined, { modelResolved: false });
}
getBrandFromWmi(wmi: string): string | null {

View File

@@ -14,6 +14,11 @@
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Browser, BrowserContext, Page } from "playwright";
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
import {
classifyTransportError,
isProxyConnectFailure,
} from "../proxy-telemetry/proxy-telemetry.service";
const SESSION_TTL_MS = 25 * 60 * 1000; // 25 minutes
const MAX_CONCURRENT_PAGES = 3;
@@ -61,22 +66,54 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
private launching: Promise<void> | null = null;
private readonly useProxy: boolean;
// "floxy" residential (default) | "dataimpulse" legacy | "none".
private readonly proxyProvider: "floxy" | "dataimpulse" | "none";
private readonly proxyHost: string;
private readonly proxyPortStart: number;
private readonly proxyPortEnd: number;
private readonly proxyUsername: string;
private readonly proxyPassword: string;
// Floxy residential — a sticky session pins ONE exit IP for the browser's whole
// lifetime (relaunch mints a fresh session id), which suits the chained scrape.
private readonly floxyHost: string;
private readonly floxyPort: number;
private readonly floxyUser: string;
private readonly floxyPass: string;
private readonly floxyLifetime: number;
private startedAt = 0;
// Provider the live browser was actually launched with. Tracks failover: when
// the configured provider is "floxy" but the shared health gate is tripped,
// (re)launch picks "dataimpulse" instead. Whole-browser scope because Playwright
// binds the proxy at launch — a relaunch is what swaps the exit.
private activeProvider: "floxy" | "dataimpulse" | "none" = "none";
constructor(private configService: ConfigService) {
constructor(
private configService: ConfigService,
private readonly proxyHealth: ProxyHealthService,
) {
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
this.useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
// Default dataimpulse: Floxy retired 2026-07 (permanently down). Set
// EMEX_PROXY_PROVIDER=floxy to re-enable it.
const rawProvider = this.configService
.get<string>("EMEX_PROXY_PROVIDER", "dataimpulse")
.toLowerCase();
this.proxyProvider = !this.useProxy
? "none"
: rawProvider === "floxy" || rawProvider === "none"
? (rawProvider as "floxy" | "none")
: "dataimpulse";
this.proxyHost = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81");
this.proxyPortStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001);
this.proxyPortEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099);
this.proxyUsername = this.configService.get<string>("EMEX_PROXY_USER", "1726bbe361918676d44e");
this.proxyPassword = this.configService.get<string>("EMEX_PROXY_PASS", "f11c7b6128cc86c6");
this.floxyHost = this.configService.get<string>("EMEX_FLOXY_HOST", "residential.floxy.io");
this.floxyPort = Number(this.configService.get("EMEX_FLOXY_PORT", 12321)) || 12321;
this.floxyUser = this.configService.get<string>("EMEX_FLOXY_USER", "cac4b0d96a80");
this.floxyPass = this.configService.get<string>("EMEX_FLOXY_PASS", "dfc38fe467a3");
this.floxyLifetime = Number(this.configService.get("EMEX_FLOXY_LIFETIME", 300)) || 300;
}
async onModuleInit(): Promise<void> {
@@ -184,14 +221,38 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
if (executablePath) launchOptions.executablePath = executablePath;
if (this.useProxy) {
// Pick the provider for THIS launch: honour the configured one, but while the
// shared Floxy-health gate is tripped, a "floxy" config launches on DataImpulse.
this.activeProvider = this.pickLaunchProvider();
if (this.activeProvider === "floxy") {
// Sticky Floxy session for the browser's lifetime → one residential exit IP
// for the whole chained scrape; a fresh id is minted on each (re)launch.
const sid = Math.random().toString(36).slice(2, 10);
const pass =
this.floxyLifetime > 0
? `${this.floxyPass}_session-${sid}_lifetime-${this.floxyLifetime}`
: this.floxyPass;
launchOptions.proxy = {
server: `http://${this.floxyHost}:${this.floxyPort}`,
username: this.floxyUser,
password: pass,
};
this.logger.log(
`Using Floxy residential proxy: ${this.floxyHost}:${this.floxyPort} (sticky ${this.floxyLifetime}s)`,
);
} else if (this.activeProvider === "dataimpulse") {
const port = this.randomProxyPort();
launchOptions.proxy = {
server: `http://${this.proxyHost}:${port}`,
username: this.proxyUsername,
password: this.proxyPassword,
};
this.logger.log(`Using proxy: ${this.proxyHost}:${port}`);
this.logger.log(
`Using DataImpulse proxy: ${this.proxyHost}:${port}${
this.proxyProvider === "floxy" ? " (Floxy failover)" : ""
}`,
);
}
this.browser = await chromium.launch(launchOptions);
@@ -201,6 +262,42 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
});
// Block heavy resources to save proxy bandwidth. Scraping reads HTML/DOM
// only — schema-image URLs are parsed from the markup and their dims come
// from a separate Range GET, so the browser never needs images/fonts/CSS.
// Context-level route covers every tab (session establish + acquirePage).
await this.context.route("**/*", (route) => {
const url = route.request().url();
const type = route.request().resourceType();
if (["image", "font", "stylesheet", "media"].includes(type)) {
return route.abort();
}
// Third-party junk seen in proxy usage: analytics, ad networks, chat
// widgets, Google/Yandex assets, and our own ipify egress probe.
if (
url.includes("google-analytics.com") ||
url.includes("googletagmanager.com") ||
url.includes("mc.yandex.ru") ||
url.includes("yastatic.net") ||
url.includes("fonts.googleapis.com") ||
url.includes("fonts.gstatic.com") ||
url.includes("content-autofill.googleapis.com") ||
url.includes("facebook.net") ||
url.includes("doubleclick.net") ||
url.includes("hotjar.com") ||
url.includes("adsco.re") ||
url.includes("displayvertising.com") ||
url.includes("tidio.co") ||
url.includes("api.ipify.org")
) {
return route.abort();
}
return route.continue();
});
this.startedAt = Date.now();
this.sessionExpiry = 0; // force session establish on first acquirePage
@@ -232,34 +329,94 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
await this.launchBrowser();
}
/** Provider for the next launch: DataImpulse while the Floxy gate is tripped. */
private pickLaunchProvider(): "floxy" | "dataimpulse" | "none" {
if (this.proxyProvider === "floxy" && this.proxyHealth.isFloxyDown()) {
return "dataimpulse"; // DI creds are always present (env defaults) when proxy on
}
return this.proxyProvider;
}
private async relaunchBrowser(): Promise<void> {
await this.closeBrowser();
await this.launchBrowser();
}
/**
* Visit baseUrl to establish/refresh ASP.NET session cookie
* Trip Floxy failover from a scrape-level transport failure (a connect error
* that surfaced AFTER the session was established, so ensureSession's own gate
* didn't catch it). Opens the shared cooldown and forces the next acquirePage
* to re-establish the session — which switches the browser to DataImpulse.
*/
async tripFloxyFailover(reason?: string): Promise<void> {
this.proxyHealth.reportFloxyFailure(reason);
if (this.activeProvider === "floxy") {
this.sessionExpiry = 0; // force ensureSession → provider switch on next acquire
}
}
/**
* Visit baseUrl to establish/refresh ASP.NET session cookie. Doubles as the
* Floxy health gate for the browser path: only at a session boundary, switch
* the browser's proxy to match pickLaunchProvider() (fail over to DataImpulse
* when Floxy is down, probe Floxy again once the gate clears). A connect
* failure during the probe trips the gate and retries once on DataImpulse.
*/
private async ensureSession(): Promise<void> {
// Only re-evaluate the provider when establishing a fresh session, so an
// in-flight scrape on a working browser is never interrupted mid-flow.
if (Date.now() >= this.sessionExpiry && this.proxyProvider !== "none") {
const desired = this.pickLaunchProvider();
if (this.browser?.isConnected() && desired !== this.activeProvider) {
this.logger.log(`EMEX switching browser proxy: ${this.activeProvider} → ${desired}`);
await this.relaunchBrowser();
}
}
if (Date.now() < this.sessionExpiry) return;
this.logger.log("Establishing EMEX session...");
if (!this.context) throw new Error("EMEX browser context not initialized");
const ctx = this.context;
const page = await ctx.newPage();
try {
await page.goto(EMEX_BASE_URL, {
waitUntil: "networkidle",
timeout: 30000,
});
// Up to 2 attempts: a Floxy tunnel failure trips the gate, relaunches on
// DataImpulse, and tries once more so the caller still gets a live session.
for (let attempt = 1; attempt <= 2; attempt++) {
if (!this.context) throw new Error("EMEX browser context not initialized");
const ctx = this.context;
const page = await ctx.newPage();
try {
this.logger.log(`Establishing EMEX session (${this.activeProvider})...`);
await page.goto(EMEX_BASE_URL, {
waitUntil: "networkidle",
timeout: 30000,
});
const cookies = await ctx.cookies();
const session = cookies.find((c) => c.name === "ASP.NET_SessionId");
if (session) {
this.sessionExpiry = Date.now() + SESSION_TTL_MS;
this.logger.log("Session established, TTL 25 min");
} else {
this.logger.warn("No session cookie found after visiting baseUrl");
// Still set a short TTL to avoid hammering
this.sessionExpiry = Date.now() + 60_000;
if (this.activeProvider === "floxy") this.proxyHealth.reportFloxySuccess();
const cookies = await ctx.cookies();
const session = cookies.find((c) => c.name === "ASP.NET_SessionId");
if (session) {
this.sessionExpiry = Date.now() + SESSION_TTL_MS;
this.logger.log("Session established, TTL 25 min");
} else {
this.logger.warn("No session cookie found after visiting baseUrl");
// Still set a short TTL to avoid hammering
this.sessionExpiry = Date.now() + 60_000;
}
return;
} catch (err) {
await page.close().catch(() => {});
const canFailover =
attempt < 2 &&
this.proxyProvider === "floxy" &&
this.activeProvider === "floxy" &&
isProxyConnectFailure(err);
if (canFailover) {
this.proxyHealth.reportFloxyFailure(`emex session: ${classifyTransportError(err)}`);
await this.relaunchBrowser(); // gate now tripped → relaunch picks DataImpulse
continue;
}
throw err;
} finally {
await page.close().catch(() => {});
}
} finally {
await page.close();
}
}

View File

@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
import { ProxyTelemetryModule } from "../proxy-telemetry/proxy-telemetry.module";
import { EmexBrowserService } from "./emex.browser";
import { EmexService } from "./emex.service";
@Module({
imports: [ProxyTelemetryModule],
providers: [EmexBrowserService, EmexService],
exports: [EmexService],
})

View File

@@ -18,6 +18,12 @@ function makeConfig(overrides: Record<string, string>): ConfigService {
const browser = {} as never;
const redis = { set: vi.fn() } as never;
const posthog = {} as never;
const proxyTelemetry = { record: vi.fn() } as never;
const proxyHealth = {
isFloxyDown: vi.fn(() => false),
reportFloxyFailure: vi.fn(),
reportFloxySuccess: vi.fn(),
} as never;
describe("EmexService proxy-port coercion (regression)", () => {
it("does not throw 'Invalid URL' when ports arrive as strings over a real range", () => {
@@ -28,7 +34,9 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_END: "10099",
});
// Pre-fix: threw "Invalid URL" here (port "4510001" > 65535).
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
expect(
() => new EmexService(config, browser, redis, posthog, proxyTelemetry, proxyHealth),
).not.toThrow();
});
it("constructs with a single-port range (string env)", () => {
@@ -37,7 +45,9 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_START: "823",
EMEX_PROXY_PORT_END: "823",
});
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
expect(
() => new EmexService(config, browser, redis, posthog, proxyTelemetry, proxyHealth),
).not.toThrow();
});
it("falls back to a valid default when the port env is garbage", () => {
@@ -46,11 +56,15 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_START: "not-a-number",
EMEX_PROXY_PORT_END: "999999",
});
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
expect(
() => new EmexService(config, browser, redis, posthog, proxyTelemetry, proxyHealth),
).not.toThrow();
});
it("constructs cleanly with the proxy disabled", () => {
const config = makeConfig({ EMEX_USE_PROXY: "false" });
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
expect(
() => new EmexService(config, browser, redis, posthog, proxyTelemetry, proxyHealth),
).not.toThrow();
});
});

View File

@@ -23,6 +23,12 @@ import { ProxyAgent } from "undici";
import { isBackfillContext } from "../../jobs/prefetch-context";
import { PostHogService } from "../../posthog/posthog.service";
import { RedisService } from "../../redis/redis.service";
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
import {
ProxyTelemetryService,
classifyTransportError,
isProxyConnectFailure,
} from "../proxy-telemetry/proxy-telemetry.service";
import { parseUnitLeaves, parseVehicleTree } from "./emex-tree.parser";
import { EmexBrowserService } from "./emex.browser";
import { createEmptyDecodedVehicle, mapEmexResponse } from "./emex.mapper";
@@ -132,12 +138,18 @@ export class EmexService {
private floxySessionId = Math.random().toString(36).slice(2, 10);
private floxySessionBornMs = Date.now();
private readonly emexDirectFallback: boolean;
// Transport provider for EMEX egress (HTTP + image-dims). "floxy" residential
// is primary by default (sticky session pins one exit IP across a chained flow),
// DataImpulse is the last-ditch fallback. See constructor.
private readonly emexProxyProvider: "floxy" | "dataimpulse" | "none";
constructor(
private configService: ConfigService,
private browserService: EmexBrowserService,
private redis: RedisService,
private posthog: PostHogService,
private proxyTelemetry: ProxyTelemetryService,
private proxyHealth: ProxyHealthService,
) {
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
@@ -169,15 +181,11 @@ export class EmexService {
portStart: toPort("EMEX_PROXY_PORT_START", 10001),
portEnd: toPort("EMEX_PROXY_PORT_END", 10099),
};
// Default agent for the low-stakes image-dims path; fetchEmexHtml builds a
// fresh agent per request so a flaky port can't pin every call.
this.proxyAgent = this.newProxyAgent();
this.logger.log(
`EMEX HTTP proxy enabled: ${this.emexProxy.host}:${this.emexProxy.portStart}-${this.emexProxy.portEnd}`,
`EMEX DataImpulse fallback enabled: ${this.emexProxy.host}:${this.emexProxy.portStart}-${this.emexProxy.portEnd}`,
);
} else {
this.emexProxy = null;
this.proxyAgent = null;
}
// Floxy residential fallback for when the DataImpulse pool flakes (connect
@@ -189,8 +197,8 @@ export class EmexService {
this.emexFloxy = {
host: this.configService.get<string>("EMEX_FLOXY_HOST", "residential.floxy.io"),
port: Number.isInteger(fport) && fport >= 1 && fport <= 65535 ? fport : 12321,
user: this.configService.get<string>("EMEX_FLOXY_USER", "d739255e819b"),
pass: this.configService.get<string>("EMEX_FLOXY_PASS", "9092873ba4e0"),
user: this.configService.get<string>("EMEX_FLOXY_USER", "cac4b0d96a80"),
pass: this.configService.get<string>("EMEX_FLOXY_PASS", "dfc38fe467a3"),
lifetime: Number.isInteger(flife) && flife >= 0 ? flife : 300,
};
this.logger.log(
@@ -200,6 +208,32 @@ export class EmexService {
this.emexFloxy = null;
}
// Transport provider. EMEX_PROXY_PROVIDER: "floxy" (default) → Floxy residential
// sticky primary + a single DataImpulse last-ditch fallback; "dataimpulse" →
// legacy DataImpulse-first order; "none" → direct. ssd tokens are replayed from
// emex's own HTML and aren't strictly IP-bound on the .aspx endpoints (the old
// random-port-per-call primary proved that), but a sticky Floxy IP across the
// chained flow is still the safest choice — and avoids DataImpulse's ~50% dead
// ports that were eating the decode budget before reaching the Floxy fallback.
// Default dataimpulse: Floxy retired 2026-07 (permanently down). Set
// EMEX_PROXY_PROVIDER=floxy to re-enable it.
this.emexProxyProvider = !useProxy
? "none"
: (() => {
const p = this.configService
.get<string>("EMEX_PROXY_PROVIDER", "dataimpulse")
.toLowerCase();
return p === "floxy" || p === "none" ? p : "dataimpulse";
})();
// Default agent for the low-stakes image-dims path; fetchEmexHtml builds a
// fresh agent per request so a flaky exit can't pin every call.
this.proxyAgent =
this.emexProxyProvider === "none"
? null
: (this.newProxyAgent(this.emexProxyProvider === "dataimpulse" ? "dataimpulse" : "floxy") ??
this.newProxyAgent("dataimpulse"));
this.logger.log(`EMEX transport provider: ${this.emexProxyProvider}`);
this.logger.log(`EMEX Service initialized with scraper path: ${this.scraperPath}`);
}
@@ -347,29 +381,56 @@ export class EmexService {
}
private async fetchEmexHtml(url: string): Promise<string> {
// Attempt schedule: DataImpulse (rotating port, fresh agent each try to dodge
// a flaky sticky port — ~42% blip rate, undecoded-vin-rca.md EMEX #1) first,
// then the Floxy residential fallback when the DataImpulse pool throws
// transport errors. A definitive HTTP answer (e.g. 404) stops the schedule —
// it's a real result, and a different proxy IP must not "retry" it away.
// Attempt schedule by provider. floxy (default): Floxy residential sticky
// primary (one exit IP across the flow; rolls to a fresh IP on transport
// failure), then a single DataImpulse last-ditch try — this avoids DataImpulse's
// ~50% dead ports that used to burn the decode budget before reaching Floxy.
// dataimpulse (legacy): DataImpulse-first (rotating port each try), Floxy after.
// A definitive HTTP answer (e.g. 404) stops the schedule — it's a real result,
// and a different proxy IP must not "retry" it away.
const schedule: Array<"dataimpulse" | "floxy"> = [];
if (this.emexProxy) schedule.push("dataimpulse", "dataimpulse", "dataimpulse");
if (this.emexFloxy) schedule.push("floxy", "floxy");
if (this.emexProxyProvider === "floxy") {
if (this.emexFloxy && this.proxyHealth.isFloxyDown() && this.emexProxy) {
// Floxy gate tripped → DataImpulse-first (rotating ports cover its ~50%
// dead ports), with one Floxy probe last so a recovery is still detected.
schedule.push("dataimpulse", "dataimpulse", "floxy");
} else {
if (this.emexFloxy) schedule.push("floxy", "floxy");
if (this.emexProxy) schedule.push("dataimpulse");
}
} else if (this.emexProxyProvider === "dataimpulse") {
if (this.emexProxy) schedule.push("dataimpulse", "dataimpulse", "dataimpulse");
if (this.emexFloxy) schedule.push("floxy", "floxy");
}
const maxAttempts = schedule.length || 1; // 0 → single proxy-less attempt
let lastErr: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const provider = schedule[attempt - 1]; // undefined when no proxy → direct
const agent = provider ? this.newProxyAgent(provider) : null;
const attemptStart = Date.now();
// Sticky Floxy sessions are the attributable identity here; DataImpulse
// rotates a random port per agent, so there is nothing stable to pin.
const sessionKey = provider === "floxy" ? `s-${this.floxySessionId}` : null;
try {
const res = await fetch(url, {
headers: { "User-Agent": EMEX_UA, Accept: "text/html,application/xhtml+xml" },
signal: AbortSignal.timeout(this.timeout),
...(agent ? { dispatcher: agent } : {}),
} as RequestInit);
this.proxyTelemetry.record({
service: "emex_http",
provider: provider ?? "none",
sessionKey,
targetHost: new URL(url).hostname,
statusCode: res.status,
success: res.ok,
durationMs: Date.now() - attemptStart,
});
if (!res.ok) {
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
}
if (provider === "floxy") {
this.proxyHealth.reportFloxySuccess(); // Floxy answered — clear cooldown
this.logger.log(`EMEX fetch via Floxy fallback succeeded for ${url}`);
}
return await res.text();
@@ -378,6 +439,19 @@ export class EmexService {
const e = err as Error & { cause?: unknown };
// A real HTTP response ("EMEX HTTP 404") is a definitive answer — never retry.
const httpAnswer = /^EMEX HTTP \d/.test(e.message);
// HTTP answers were already recorded right after fetch; only transport
// failures (no answer at all) still need a row.
if (!httpAnswer) {
this.proxyTelemetry.record({
service: "emex_http",
provider: provider ?? "none",
sessionKey,
targetHost: new URL(url).hostname,
errorKind: classifyTransportError(e),
success: false,
durationMs: Date.now() - attemptStart,
});
}
const transient =
!httpAnswer &&
(e.name === "TimeoutError" ||
@@ -386,6 +460,11 @@ export class EmexService {
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|other side closed|terminated|UND_ERR/i.test(
`${e.message} ${String(e.cause ?? "")}`,
));
// A Floxy connect failure (dead exit / tunnel refused) trips the shared
// gate so EMEX + pcat fail over to DataImpulse for the cooldown window.
if (provider === "floxy" && isProxyConnectFailure(e)) {
this.proxyHealth.reportFloxyFailure(`emex http: ${classifyTransportError(e)}`);
}
if (!transient || attempt === maxAttempts) break;
// A Floxy transport failure means the current sticky exit IP is dead —
// roll to a fresh session so the next Floxy attempt gets a new IP.
@@ -849,6 +928,12 @@ export class EmexService {
} catch (error) {
const err = error as Error;
// Floxy tunnel dead mid-scrape → trip failover so the next acquirePage
// re-establishes the session on DataImpulse.
if (isProxyConnectFailure(err)) {
void this.browserService.tripFloxyFailover(`emex decode: ${classifyTransportError(err)}`);
}
if (
err instanceof BadRequestException ||
err instanceof ServiceUnavailableException ||
@@ -992,6 +1077,10 @@ export class EmexService {
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
} catch (error) {
const err = error as Error;
// Floxy tunnel dead mid-scrape → trip failover for the next acquirePage.
if (isProxyConnectFailure(err)) {
void this.browserService.tripFloxyFailover(`emex parts: ${classifyTransportError(err)}`);
}
this.logger.error(`Failed to fetch category parts: ${err.message}`);
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
} finally {

View File

@@ -348,6 +348,7 @@ export const CATALOG_MAP: Record<string, CatalogEntry> = {
// Opel
W0L: { code: "GM_OP201809", brand: "Opel" },
W0V: { code: "GM_OP201809", brand: "Opel" }, // newer Stellantis-era WMI
VXK: { code: "GM_OP201809", brand: "Opel" }, // PSA/Stellantis-platform Opel (Corsa F, Mokka B)
// Chevrolet
KL1: { code: "GM_C201809", brand: "Chevrolet" },
// SsangYong

View File

@@ -0,0 +1,262 @@
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import postgres, { type Sql } from "postgres";
import type { PArticle, PCompatible, POeNumber, POemResult } from "./p.types";
/**
* Read-only lookup against the imported P snapshot (db `td` — a selective
* copy of articles + OE numbers + aftermarket compatibility + images/eans; the
* 29 GB vehicle-fitment table is intentionally excluded). Given an OEM code from
* the sase catalog, returns the P articles that carry it as an OE number,
* with their aftermarket equivalents and OE cross-references.
*
* Matching is normalisation-based, not exact: P stores OE codes with
* spaces/dashes (`1J0 973 702`) while the catalog gives `1J0973702`, so both
* sides are reduced to `[A-Z0-9]` uppercase before comparison (a precomputed
* `code_norm` column, indexed, holds the P side). Exact matching recovers
* almost nothing — verified ~1/10 vs normalised ~5/10 on real catalog codes.
*
* Never throws: disabled feature, too-short code, connection blip or no match
* all collapse to `matched: false` so the UI has a single empty-state path.
*/
@Injectable()
export class PSourceDbService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PSourceDbService.name);
private sql: Sql | null = null;
private enabled = false;
// Short normalised codes (e.g. "NA" from "N/A", single digits) collide across
// unrelated parts — refuse to match below this length. Real OE numbers are 5+.
private static readonly MIN_NORM_LEN = 5;
private static readonly MAX_ARTICLES = 60;
private static readonly MAX_AGG = 300;
constructor(private readonly config: ConfigService) {}
onModuleInit() {
const enabled = this.config.get<boolean>("p.enabled");
const url = this.config.get<string>("p.url");
if (!enabled || !url) {
this.logger.log(`[p] disabled (enabled=${enabled}, urlSet=${Boolean(url)})`);
return;
}
this.sql = postgres(url, {
max: 5,
idle_timeout: 30,
connect_timeout: 10,
prepare: false,
});
this.enabled = true;
this.logger.log("[p] connected, OEM cross-reference lookup enabled");
}
async onModuleDestroy() {
if (this.sql) {
await this.sql.end({ timeout: 5 });
this.sql = null;
}
}
/** `1J0 973 702` / `1j0-973-702` → `1J0973702`. Used for both the input code
* and JS-side dedupe; the P side is matched against the stored
* `code_norm` (built with the identical rule at import time). */
private static norm(code: string): string {
return code.toUpperCase().replace(/[^A-Z0-9]/g, "");
}
/**
* Batch membership test for the schema page: given the OEM codes in a parts
* list, return the subset that has at least one OE cross-reference in the
* snapshot. The panel links only these (and renders the rest as plain text),
* so a click never lands on an empty "no equivalents" detail page. One indexed
* query (`code_norm` btree). Fail-open to [] (→ no links) when disabled / on
* error, echoing back the caller's original spelling for the matched codes.
*/
async matchedCodes(rawCodes: string[]): Promise<string[]> {
if (!this.enabled || !this.sql || !rawCodes?.length) return [];
const rawByNorm = new Map<string, string>();
for (const raw of rawCodes) {
const n = PSourceDbService.norm(raw ?? "");
if (n.length >= PSourceDbService.MIN_NORM_LEN && !rawByNorm.has(n)) rawByNorm.set(n, raw);
}
if (rawByNorm.size === 0) return [];
try {
const norms = [...rawByNorm.keys()];
const rows = await this.sql<Array<{ code_norm: string }>>`
SELECT DISTINCT code_norm FROM article_oe_numbers
WHERE code_norm IN ${this.sql(norms)}
`;
return rows
.map((r) => rawByNorm.get(r.code_norm))
.filter((c): c is string => c !== undefined);
} catch (err) {
this.logger.warn(
`[p] matchedCodes failed (${rawByNorm.size} codes): ${(err as Error).message}`,
);
return [];
}
}
async lookupByOem(rawCode: string): Promise<POemResult | null> {
const query = (rawCode ?? "").trim();
const queryNorm = PSourceDbService.norm(query);
const miss: POemResult = {
query,
queryNorm,
matched: false,
articles: [],
aftermarketParts: [],
oeCrossReferences: [],
truncated: false,
};
if (!this.enabled || !this.sql) return miss;
if (queryNorm.length < PSourceDbService.MIN_NORM_LEN) return miss;
try {
const rows = await this.sql<
Array<{
id: string;
brand: string;
article_number: string;
name: string | null;
spare_info: string | null;
oe_numbers: POeNumber[];
compatible: PCompatible[];
images: Array<{ url: string; thumb: string | null }>;
}>
>`
WITH hit AS (
SELECT DISTINCT article_id
FROM article_oe_numbers
WHERE code_norm = ${queryNorm}
LIMIT ${PSourceDbService.MAX_ARTICLES}
)
SELECT
a.id::text AS id,
b.name AS brand,
-- Snapshot'ta kolonlar şaşırtmacalı: articles.article_number %96
-- upstream'in sayısal scrape-ID'si, GERÇEK üretici parça kodu ise
-- articles.name kolonunda ("0 092 S40 300", "JTE2311"; 1,1M satırda
-- yalnızca 176 istisna). Kullanıcıya kopyalanabilir gerçek kodu servis
-- et; name'i ayrıca döndürme (UI'daki ikincil satır zaten kod ile
-- aynıysa gizleniyordu).
COALESCE(NULLIF(a.name, ''), a.article_number) AS article_number,
NULL AS name,
a.spare_info AS spare_info,
COALESCE((
SELECT json_agg(json_build_object('brand', o.brand, 'code', o.code))
FROM (
SELECT DISTINCT brand, code FROM article_oe_numbers
WHERE article_id = a.id ORDER BY brand LIMIT 200
) o
), '[]') AS oe_numbers,
COALESCE((
SELECT json_agg(json_build_object('brand', c.compatible_brand, 'article', c.compatible_article))
FROM (
SELECT DISTINCT compatible_brand, compatible_article FROM article_compatibility
WHERE article_id = a.id ORDER BY compatible_brand LIMIT 200
) c
), '[]') AS compatible,
COALESCE((
SELECT json_agg(json_build_object('url', i.image_url, 'thumb', i.thumb_url) ORDER BY i.sort_order)
FROM (
-- Only publicly-resolvable URLs. The current snapshot stores
-- scrape-local '/_debug/...' paths (404 off-host) — filtering them
-- here keeps the API contract honest so the UI shows no broken
-- thumbnails; real CDN URLs surface automatically once present.
SELECT image_url, thumb_url, sort_order FROM article_images
WHERE article_id = a.id AND image_url LIKE 'http%'
ORDER BY sort_order LIMIT 8
) i
), '[]') AS images
FROM hit
JOIN articles a ON a.id = hit.article_id
JOIN article_brands b ON b.id = a.brand_id
ORDER BY b.name, COALESCE(NULLIF(a.name, ''), a.article_number)
`;
if (rows.length === 0) return miss;
let truncated = rows.length >= PSourceDbService.MAX_ARTICLES;
const articles: PArticle[] = rows.map((r) => {
if (r.oe_numbers.length >= 200 || r.compatible.length >= 200) truncated = true;
return {
id: r.id,
brand: r.brand,
articleNumber: r.article_number,
name: r.name,
spareInfo: r.spare_info,
images: r.images,
oeNumbers: r.oe_numbers,
compatible: r.compatible,
};
});
// ── Aggregate: buyable aftermarket part numbers ──────────────────────
// The matched articles are themselves aftermarket parts; their
// compatibility rows add equivalent numbers from other supplier brands.
const afterSeen = new Set<string>();
const aftermarketParts: POemResult["aftermarketParts"] = [];
const pushAfter = (brand: string, articleNumber: string, thumb: string | null) => {
const key = `${brand.toUpperCase().trim()}␟${PSourceDbService.norm(articleNumber)}`;
if (afterSeen.has(key) || !articleNumber.trim()) return;
afterSeen.add(key);
if (aftermarketParts.length < PSourceDbService.MAX_AGG) {
aftermarketParts.push({ brand, articleNumber, thumb });
} else {
truncated = true;
}
};
for (const a of articles) {
pushAfter(a.brand, a.articleNumber, a.images[0]?.thumb ?? a.images[0]?.url ?? null);
}
for (const a of articles) {
for (const c of a.compatible) pushAfter(c.brand, c.article, null);
}
// ── Aggregate: OE cross-references (same part, other makes) ───────────
// Exclude restatements of the queried code itself (same normalised code).
const oeSeen = new Set<string>();
const oeCrossReferences: POeNumber[] = [];
for (const a of articles) {
for (const oe of a.oeNumbers) {
const codeNorm = PSourceDbService.norm(oe.code);
if (codeNorm === queryNorm) continue;
const key = `${oe.brand.toUpperCase().trim()}␟${codeNorm}`;
if (oeSeen.has(key)) continue;
oeSeen.add(key);
if (oeCrossReferences.length < PSourceDbService.MAX_AGG) {
oeCrossReferences.push(oe);
} else {
truncated = true;
}
}
}
return {
query,
queryNorm,
matched: true,
// Ship lean articles — the per-article oeNumbers/compatible lists were
// only needed above to build the aggregates; sending them too bloated
// heavy responses ~30x (370 KB → ~12 KB for a 60-article code).
articles: articles.map((a) => ({
id: a.id,
brand: a.brand,
articleNumber: a.articleNumber,
name: a.name,
spareInfo: a.spareInfo,
images: a.images,
})),
aftermarketParts,
oeCrossReferences,
truncated,
};
} catch (err) {
this.logger.warn(`[p] lookup failed (oem=${query}): ${(err as Error).message}`);
return miss;
}
}
}

View File

@@ -0,0 +1,28 @@
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
import { PSourceDbService } from "./p-source-db.service";
@Controller("p")
export class PController {
constructor(private readonly p: PSourceDbService) {}
/**
* Resolve an OEM code from the catalog to its P equivalents.
* `GET /p/oem?code=1J0973702` → { matched, articles, aftermarketParts,
* oeCrossReferences }. Always 200 with `matched: false` on any miss.
*/
@Get("oem")
async oem(@Query("code") code: string) {
return this.p.lookupByOem(code ?? "");
}
/**
* Batch: which of these OEM codes have a P cross-reference. The schema page
* sends the codes it's about to render and links only the returned ones, so
* unmatched codes stay plain text and never open an empty detail page.
* `POST /p/matched { codes: [...] }` → { matched: [...] }.
*/
@Post("matched")
async matched(@Body("codes") codes: string[]) {
return { matched: await this.p.matchedCodes(Array.isArray(codes) ? codes : []) };
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from "@nestjs/common";
import { PSourceDbService } from "./p-source-db.service";
import { PController } from "./p.controller";
/**
* OEM cross-reference lookup against the imported P snapshot (db `td`).
* Raw read-only queries — intentionally no Drizzle schema modelling, mirroring
* CatalogSourceDbModule. Self-disables when P_DB_* env is unset.
*/
@Module({
controllers: [PController],
providers: [PSourceDbService],
exports: [PSourceDbService],
})
export class PModule {}

View File

@@ -0,0 +1,57 @@
/** An OE (original-equipment) number cross-reference: the same physical part as
* catalogued by a vehicle manufacturer (e.g. VAG `1J0 973 702`). */
export interface POeNumber {
brand: string;
code: string;
}
/** An aftermarket equivalent: a buyable part number from a supplier brand
* (e.g. FEBI BILSTEIN `171903`). */
export interface PCompatible {
brand: string;
article: string;
}
export interface PImage {
url: string;
thumb: string | null;
}
/** One P article whose OE number list contains the queried OEM code. Internal
* shape — `oeNumbers`/`compatible` feed the aggregates and are NOT shipped to
* the client (they're large and unused there; see PArticleSummary). */
export interface PArticle {
id: string;
brand: string;
articleNumber: string;
name: string | null;
spareInfo: string | null;
images: PImage[];
oeNumbers: POeNumber[];
compatible: PCompatible[];
}
/** What the OEM detail page actually renders per matched article — without the
* heavy per-article OE/compat lists (those are deduped into the top-level
* aggregates). Trimming them cut a 60-article payload from ~370 KB to ~12 KB. */
export type PArticleSummary = Omit<PArticle, "oeNumbers" | "compatible">;
/** Response of the OEM detail lookup. `matched: false` covers every miss —
* feature disabled, code too short, or no P article carries that OE
* number — so the UI has a single empty-state path. */
export interface POemResult {
query: string;
queryNorm: string;
matched: boolean;
/** Distinct articles whose OE list contains the queried code (lean — per-article
* OE/compat lists are aggregated below, not duplicated here). */
articles: PArticleSummary[];
/** Deduped buyable aftermarket part numbers across all matched articles
* (the matched articles themselves + their compatibility entries). */
aftermarketParts: Array<{ brand: string; articleNumber: string; thumb: string | null }>;
/** Deduped OE cross-references across all matched articles, excluding the
* queried code itself — i.e. the same part's numbers under other makes. */
oeCrossReferences: POeNumber[];
/** True when any per-article list or the article set hit its cap. */
truncated: boolean;
}

View File

@@ -0,0 +1,98 @@
/**
* Parts-Catalogs capture asset cache.
*
* Every JWT capture opens a fresh browser context (empty HTTP cache) and
* re-downloads the SAME static widget assets through the billed residential
* proxy — measured ~2.2 MB/capture, ~93% of it the parts-catalogs widget
* bundle (`.../v3/bundle_<hash>.js`, identical across all partner sites) plus
* the sites' own versioned JS/CSS. At ~3–4k captures/day that is the single
* largest avoidable proxy cost (~20 GB/15d) with zero ban risk: the widget
* still runs (served from here) and still fires its token XHR live.
*
* This is a URL-keyed byte cache with a hot in-memory layer and a best-effort
* disk layer (survives redeploys). Only versioned static assets are cached —
* their URLs carry a content hash / `?_=<ver>` so a bundle bump is a new URL
* (cache miss → refetched once), i.e. invalidation is automatic. The token XHR
* (`/v3/api/proxy/*`) and HTML documents are never cached; they stay live.
*/
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
export interface CachedAsset {
contentType: string;
body: Buffer;
}
export class PcatAssetCache {
private readonly dir: string;
private readonly mem = new Map<string, CachedAsset>();
private readonly maxMemEntries: number;
private readonly ready: Promise<void>;
constructor(opts: { dir?: string; maxMemEntries?: number } = {}) {
this.dir = opts.dir ?? join(tmpdir(), "pcat-asset-cache");
this.maxMemEntries = opts.maxMemEntries ?? 300;
this.ready = mkdir(this.dir, { recursive: true }).then(
() => undefined,
() => undefined,
);
}
/**
* Only versioned static JS/CSS is cacheable. Never the token/API path, never
* HTML documents (may carry a per-session nonce the token call needs).
*/
static isCacheable(resourceType: string, url: string): boolean {
if (resourceType !== "script" && resourceType !== "stylesheet") return false;
if (url.includes("/v3/api/proxy/")) return false;
return true;
}
private fileKey(url: string): string {
return createHash("sha1").update(url).digest("hex");
}
async get(url: string): Promise<CachedAsset | null> {
const hot = this.mem.get(url);
if (hot) return hot;
await this.ready;
const key = this.fileKey(url);
try {
const [meta, body] = await Promise.all([
readFile(join(this.dir, `${key}.json`), "utf8"),
readFile(join(this.dir, `${key}.bin`)),
]);
const asset: CachedAsset = { contentType: JSON.parse(meta).contentType, body };
this.remember(url, asset);
return asset;
} catch {
return null;
}
}
async put(url: string, contentType: string, body: Buffer): Promise<void> {
if (!body.length) return;
this.remember(url, { contentType, body });
await this.ready;
const key = this.fileKey(url);
try {
await Promise.all([
writeFile(join(this.dir, `${key}.bin`), body),
writeFile(join(this.dir, `${key}.json`), JSON.stringify({ contentType, url })),
]);
} catch {
// Disk is best-effort; the in-memory layer still serves this process.
}
}
private remember(url: string, asset: CachedAsset): void {
// Cheap FIFO bound — versioned URLs keep the working set small anyway.
if (this.mem.size >= this.maxMemEntries) {
const oldest = this.mem.keys().next().value;
if (oldest !== undefined) this.mem.delete(oldest);
}
this.mem.set(url, asset);
}
}

View File

@@ -0,0 +1,87 @@
import { ConfigService } from "@nestjs/config";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
import type { JwtSlot } from "./parts-catalogs.types";
function makeService(env: Record<string, string> = {}) {
const config = {
get: <T>(key: string, def?: T) => (key in env ? (env[key] as unknown as T) : (def as T)),
} as unknown as ConfigService;
const redis = {} as never;
const telemetry = { record: vi.fn() } as never;
const health = {
isFloxyDown: () => false,
reportFloxyFailure: vi.fn(),
reportFloxySuccess: vi.fn(),
} as never;
return new PartsCatalogsAuthService(config, redis, telemetry, health);
}
function slot(capturedAt: number): JwtSlot {
return {
jwt: {
raw: `TWS-${capturedAt}`,
exp: 0,
apiPath: "",
guiVersion: "3",
userId: "",
origin: "",
referer: "",
},
siteUsed: "https://x.test/#/catalogs",
capturedAt,
refreshTimer: null,
failCount: 0,
} as JwtSlot;
}
describe("PartsCatalogsAuthService", () => {
let svc: PartsCatalogsAuthService;
beforeEach(() => {
svc = makeService();
});
describe("captureAllowDirect (env)", () => {
it("defaults on when a proxy provider is active", () => {
expect(makeService()["captureAllowDirect"]).toBe(true);
expect(makeService({ PCAT_PROXY_PROVIDER: "dataimpulse" })["captureAllowDirect"]).toBe(true);
});
it("off when explicitly disabled", () => {
expect(makeService({ PCAT_CAPTURE_ALLOW_DIRECT: "false" })["captureAllowDirect"]).toBe(false);
});
it("off when there is no proxy to fall back from (provider none)", () => {
expect(makeService({ PCAT_USE_PROXY: "false" })["captureAllowDirect"]).toBe(false);
});
});
describe("enforcePoolCap", () => {
it("trims to MAX_POOL_SIZE keeping the freshest slots", () => {
// 9 slots, capturedAt 1..9 (9 newest). Cap is 5.
svc["pool"] = [3, 1, 8, 2, 9, 5, 4, 7, 6].map(slot);
svc["enforcePoolCap"]();
const kept = svc["pool"].map((s) => s.capturedAt).sort((a, b) => a - b);
expect(svc["pool"].length).toBe(5);
expect(kept).toEqual([5, 6, 7, 8, 9]); // 5 newest survive
});
it("is a no-op at or under the cap", () => {
svc["pool"] = [1, 2, 3].map(slot);
svc["enforcePoolCap"]();
expect(svc["pool"].length).toBe(3);
});
it("clears refresh timers on evicted slots", () => {
const all = [1, 2, 3, 4, 5, 6, 7].map((c) => {
const s = slot(c);
s.refreshTimer = setTimeout(() => {}, 60_000);
return s;
});
const [oldest1, oldest2] = all; // capturedAt 1 and 2 — the two evicted
svc["pool"] = [...all];
svc["enforcePoolCap"]();
expect(oldest1.refreshTimer).toBeNull();
expect(oldest2.refreshTimer).toBeNull();
// Survivors keep their timers.
expect(svc["pool"].every((s) => s.refreshTimer !== null)).toBe(true);
for (const s of svc["pool"]) if (s.refreshTimer) clearTimeout(s.refreshTimer);
});
});
});

View File

@@ -1,12 +1,16 @@
/**
* Parts-Catalogs Auth Service — v3 token warm pool via Playwright + DataImpulse proxy
* Parts-Catalogs Auth Service — v3 token warm pool via Playwright + rotating proxy
*
* Tokens are captured by navigating to partner sites that embed the v3 widget.
* The widget calls /v3/api/proxy/* with `x-api-key: TWS-{UUID}` and four other
* X-* headers (api-path, gui-version, user-id, origin, referer); we intercept
* all of them so backend requests can replay the exact header set.
*
* Tokens are IP-bound — same proxy port must be reused for the API calls.
* Transport (PCAT_PROXY_PROVIDER): "floxy" residential (default) rotates a fresh
* exit IP per call (the token is NOT IP-bound — see parts-catalogs.types.ts — so
* max IP diversity = min per-IP ban), with a sticky IP only per Playwright
* capture; "dataimpulse" is the legacy port-bound datacenter proxy; "none" calls
* direct from the server IP.
*
* Warm pool behavior:
* 24/7 → proactive: maintain >= 1 slot, auto-refresh ~9.5min before expiry.
@@ -19,29 +23,40 @@ import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@ne
import { ConfigService } from "@nestjs/config";
import { Browser, BrowserContext } from "playwright";
import { RedisService } from "../../redis/redis.service";
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
import {
ProxyTelemetryService,
classifyTransportError,
isProxyConnectFailure,
resolveExitIp,
} from "../proxy-telemetry/proxy-telemetry.service";
import { PcatAssetCache } from "./parts-catalogs-asset-cache";
import { JwtSlot, PcatJwtToken, PcatSession } from "./parts-catalogs.types";
// Shared single-slot cache so dev + prod (and any restarted container) can
// inherit a freshly-captured warm slot instead of paying the Playwright
// capture cost from cold. Token is still IP-bound to its proxyPort; if a
// reader on a different IP gets 401/403, invalidateSession's existing
// fallback re-captures locally. So worst case = today's behavior.
// inherit a freshly-captured warm token instead of paying the Playwright
// capture cost from cold. The token is portable (not IP-bound), so any
// container can serve it through its own rotating call-leg IPs; if it's dead
// at the source, invalidateSession drops it and the next read re-captures.
const REDIS_SLOT_KEY = "pcat:jwt:slot";
const REDIS_SAFETY_BUFFER_S = 60; // don't serve from Redis if <60s of life left
const TOKEN_TTL = 600; // seconds — TWS- has no built-in expiry, refresh aggressively
const REFRESH_BUFFER = 90; // Refresh 90s before expiry
const CAPTURE_POLL_INTERVAL = 500; // ms
// Two-armed post-goto poll: a healthy goto means the widget JS already loaded
// and the API call typically fires within ~1-2s — short cap is enough. A
// failed goto means we lost the page, but the widget request may still have
// gone through before the navigation timeout — give a brief grace window
// and bail. Old single-armed 20s cap was the dominant cost on failed-site
// attempts (e.g. 10s page timeout + 20s blind poll = 30s wasted).
const CAPTURE_POLL_AFTER_OK = 10; // 10 × 500ms = 5s after a healthy goto
const CAPTURE_POLL_AFTER_FAIL = 4; // 4 × 500ms = 2s grace after goto failure
const PAGE_TIMEOUT = 10_000; // 10s — healthy partner sites load in <5s through
// the proxy; a longer wait only prolongs dead-port connects in capture retries.
// Post-goto poll. We now navigate with `domcontentloaded` (reliable, doesn't
// depend on networkidle settling on JS-heavy catalog sites), then poll for the
// widget's token call. Measured through Floxy residential 2026-06-11: DOM ready
// ~7s, the /v3/api/proxy token call fires ~11.8s (≈5s after DOM). The poll must
// comfortably cover that gap, so 12s — too short and every capture misses the
// token (seen at the old 5s cap: "No token after 12s", whole pool starved).
const CAPTURE_POLL_AFTER_OK = Number(process.env.PCAT_CAPTURE_POLL_OK) || 24; // 24 × 500ms = 12s
const CAPTURE_POLL_AFTER_FAIL = 8; // 8 × 500ms = 4s grace after goto failure
// 25s — residential exit IPs are slow; the token call fired at ~11.8s in the
// 2026-06-11 measurement, so 10s timed out before it. Capture is background
// (warm pool, never blocks a user with cold-pool fast-fail), so a longer cap is
// cheap insurance against residential variance. Env-tunable for future rotation.
const PAGE_TIMEOUT = Number(process.env.PCAT_PAGE_TIMEOUT_MS) || 25_000;
const CONTEXT_CLOSE_TIMEOUT = 5_000;
const SITE_COOLDOWN = 10 * 60 * 1000; // 10 min per site
const MAX_POOL_SIZE = 5;
@@ -62,30 +77,48 @@ const RPM_PER_SLOT = 6; // 1 token per 6 req/min
* Each site uses a different proxy port (IP) to avoid rate limiting.
*/
// Order matters: round-robin starts from index 0, so the first site is the one
// a cold pool waits on. Sites that consistently load in <10s through the proxy
// stay at the top so initial JWT capture finishes in seconds.
// (e-acca.com was previously included last but removed — the current DataImpulse
// rotating proxy 74.81.81.81:10000-10999 cannot reach it; page.goto always
// blocked until PAGE_TIMEOUT instead of failing fast like the other sites.)
const JWT_SITES = [
// a cold pool waits on. Sites that consistently load fast stay at the top so
// initial JWT capture finishes in seconds.
// Pruned 2026-06-10 to the 6 sites that actually yielded captures on prod —
// knkauto.ru / autodo.kz / flynestauto.com produced 0 captures yet consumed
// ~80% of capture attempts (the per-site cooldown kept forcing the retry loop
// onto them). Override the list at runtime with PCAT_JWT_SITES (comma-separated)
// without a deploy, e.g. to give dev a disjoint subset.
const DEFAULT_JWT_SITES = [
"https://www.alkatalog.com/cats/#/catalogs",
"https://auto-komplekt.ru/goodvin#/catalogs",
"https://www.autotrade.md/cats/#/catalogs",
"https://www.e-trak.ru/cats/#/catalogs",
"https://www.autopolyus.ru/cats/#/catalogs",
"https://knkauto.ru/goodvin#/catalogs",
"https://www.autodo.kz/#/catalogs",
"https://avtoman124.ru/goodvin#/catalogs",
"https://flynestauto.com/auto-parts-oem-catalog",
];
// DataImpulse proxy defaults (port-based IP rotation)
// DataImpulse proxy defaults (port-based IP rotation) — legacy. Half its ports
// were dead at any moment (measured 50% connect-timeout), which drove the bulk of
// pcat decode latency + failures. Kept selectable via PCAT_PROXY_PROVIDER for
// instant rollback, but Floxy is the default.
const DI_HOST = "gw.dataimpulse.com";
const DI_PORT_MIN = 10000;
const DI_PORT_MAX = 10999;
const DI_DEFAULT_USER = "1726bbe361918676d44e";
const DI_DEFAULT_PASS = "78ebc3d881de6ec0";
// Floxy residential defaults (session-based IP rotation; same account EMEX uses).
// Call leg → plain creds = a fresh residential exit IP per request (max IP
// diversity → minimal per-IP ban exposure, since each IP sees ~1 request).
// Capture leg → a sticky session id pins one IP for the duration of a single
// Playwright capture so the partner site sees a coherent session.
const FLOXY_HOST = "residential.floxy.io";
const FLOXY_PORT = 12321;
// Floxy account creds (overridable via PCAT_FLOXY_USER/PASS). Rotated 2026-06-11
// after the prior account (d739255e819b) hit HTTP 402 (balance exhausted).
const FLOXY_DEFAULT_USER = "cac4b0d96a80";
const FLOXY_DEFAULT_PASS = "dfc38fe467a3";
const FLOXY_CAPTURE_LIFETIME_S = 60; // sticky window for one capture attempt
type ProxyProvider = "floxy" | "dataimpulse" | "none";
type ProxyLeg = "call" | "capture";
/** Simple counting semaphore (same pattern as EmexBrowserService) */
class Semaphore {
private current = 0;
@@ -119,18 +152,20 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
private browser: Browser | null = null;
private launching: Promise<void> | null = null;
// Concurrent captures capped at JWT_SITES.length — the real bottleneck is
// how many distinct partner sites we can drive in parallel (one capture per
// site, since each site = a different IP / cookie origin). Ports are
// ~unlimited (10000-10999) and Playwright contexts are isolated; the
// synchronous siteIndex round-robin hands each concurrent capture a
// different site so they don't collide.
private readonly semaphore = new Semaphore(JWT_SITES.length);
// Active JWT capture sites (PCAT_JWT_SITES override or DEFAULT_JWT_SITES),
// resolved in the constructor.
private readonly jwtSites: string[];
// Concurrent captures capped at jwtSites.length — the real bottleneck is how
// many distinct partner sites we can drive in parallel (one capture per site,
// since each site = a different cookie origin). Playwright contexts are
// isolated; the synchronous siteIndex round-robin hands each concurrent
// capture a different site so they don't collide. Initialized in constructor.
private readonly semaphore: Semaphore;
// Pool state
private pool: JwtSlot[] = [];
private siteLastUsedAt = new Map<string, number>();
private siteIndex = 0; // round-robin across JWT_SITES
private siteIndex = 0; // round-robin across jwtSites
private requestRoundRobin = 0; // round-robin across pool slots
// Business hours scheduling
@@ -139,20 +174,156 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
// RPM tracking
private requestTimestamps: number[] = [];
// Config
private readonly useProxy: boolean;
private readonly proxyHost: string;
private readonly proxyUser: string;
private readonly proxyPass: string;
// Transport config — see ProxyProvider. The TWS- token is NOT request-time
// IP-bound (parts-catalogs.types.ts), so the call leg rotates exit IPs per
// request and only the capture leg holds a sticky IP per attempt.
private readonly proxyProvider: ProxyProvider;
// Floxy residential
private readonly floxyHost: string;
private readonly floxyPort: number;
private readonly floxyUser: string;
private readonly floxyPass: string;
private readonly floxyCaptureLifetime: number;
// DataImpulse legacy (port-rotated)
private readonly diHost: string;
private readonly diUser: string;
private readonly diPass: string;
// When a proxied capture dies on a tunnel/connect failure (residential balance
// exhausted, DataImpulse ports dead, both providers down), retry that capture
// DIRECT from the server IP instead of starving the warm pool. Safe because
// the TWS- token is not IP-bound and capture is low-frequency (≤ MAX_POOL_SIZE
// refreshes / ~8.5min). This is the survival path for a full proxy outage —
// the exact failure seen 2026-06-11 (Floxy 402) and 2026-07-03 (Floxy tunnel
// down). Disable with PCAT_CAPTURE_ALLOW_DIRECT=false.
private readonly captureAllowDirect: boolean;
// Exit-IP telemetry probe (one ipify call per capture). Off by default: it
// added ~54k billed proxy requests over 15 days for banned-IP telemetry only.
private readonly exitIpProbe: boolean;
// Cross-capture cache for the widget's static JS/CSS so each capture stops
// re-downloading ~2 MB of identical assets through the residential proxy.
// Null when disabled via PCAT_ASSET_CACHE=false (kill switch, no redeploy).
private readonly assetCache: PcatAssetCache | null;
constructor(
private configService: ConfigService,
private readonly redis: RedisService,
private readonly proxyTelemetry: ProxyTelemetryService,
private readonly proxyHealth: ProxyHealthService,
) {
this.useProxy = this.configService.get<string>("PCAT_USE_PROXY", "true") === "true";
this.proxyHost = this.configService.get<string>("PCAT_PROXY_HOST", DI_HOST);
this.proxyUser = this.configService.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
this.proxyPass = this.configService.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS);
const cfg = this.configService;
// Active capture sites: PCAT_JWT_SITES override (comma-separated) or the
// pruned default. Empty/blank entries are dropped; falls back to default if
// the override resolves to nothing.
const sitesRaw = cfg.get<string>("PCAT_JWT_SITES", "");
const parsedSites = sitesRaw
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
this.jwtSites = parsedSites.length > 0 ? parsedSites : DEFAULT_JWT_SITES;
this.semaphore = new Semaphore(this.jwtSites.length);
// Provider selection. Back-compat: PCAT_USE_PROXY=false still forces direct.
// Default is dataimpulse: Floxy was retired 2026-07 (permanently down —
// balance/tunnel dead), so floxy-primary just burned a failed attempt per
// call before failing over. Set PCAT_PROXY_PROVIDER=floxy to re-enable it.
const rawProvider = cfg.get<string>("PCAT_PROXY_PROVIDER", "dataimpulse").toLowerCase();
const useProxy = cfg.get<string>("PCAT_USE_PROXY", "true") === "true";
this.proxyProvider = !useProxy
? "none"
: rawProvider === "floxy" || rawProvider === "none"
? (rawProvider as ProxyProvider)
: "dataimpulse";
const toInt = (key: string, def: number): number => {
const n = Number(cfg.get(key, def));
return Number.isInteger(n) && n >= 0 ? n : def;
};
const toPort = (key: string, def: number): number => {
const n = Number(cfg.get(key, def));
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : def;
};
this.floxyHost = cfg.get<string>("PCAT_FLOXY_HOST", FLOXY_HOST);
this.floxyPort = toPort("PCAT_FLOXY_PORT", FLOXY_PORT);
this.floxyUser = cfg.get<string>("PCAT_FLOXY_USER", FLOXY_DEFAULT_USER);
this.floxyPass = cfg.get<string>("PCAT_FLOXY_PASS", FLOXY_DEFAULT_PASS);
this.floxyCaptureLifetime = toInt("PCAT_FLOXY_CAPTURE_LIFETIME", FLOXY_CAPTURE_LIFETIME_S);
this.diHost = cfg.get<string>("PCAT_PROXY_HOST", DI_HOST);
this.diUser = cfg.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
this.diPass = cfg.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS);
this.exitIpProbe = cfg.get<string>("PCAT_EXIT_IP_PROBE", "false") === "true";
this.assetCache =
cfg.get<string>("PCAT_ASSET_CACHE", "true") === "true" ? new PcatAssetCache() : null;
this.captureAllowDirect =
cfg.get<string>("PCAT_CAPTURE_ALLOW_DIRECT", "true") !== "false" &&
this.proxyProvider !== "none";
this.logger.log(
this.proxyProvider === "floxy"
? `PCAT transport: floxy residential ${this.floxyHost}:${this.floxyPort} (call=rotating, capture=sticky ${this.floxyCaptureLifetime}s)`
: this.proxyProvider === "dataimpulse"
? `PCAT transport: dataimpulse ${this.diHost}:${DI_PORT_MIN}-${DI_PORT_MAX}`
: "PCAT transport: direct (no proxy)",
);
}
/**
* Build a proxy for one leg. "call" rotates the exit IP per invocation (the
* token is not IP-bound, so each decode/scrape/validate request gets a fresh
* residential IP → max IP diversity, min ban). "capture" pins a sticky IP for
* the lifetime of a single Playwright capture so the partner site sees a
* coherent session. Returns nulls when provider is "none" (direct).
*/
private buildProxy(leg: ProxyLeg): {
proxyUrl: string | null;
proxyConfig: { server: string; username: string; password: string } | null;
// Telemetry identity: sticky Floxy session id or DataImpulse port. Null on
// rotating legs (exit IP changes per request — nothing stable to attribute).
sessionKey: string | null;
// The provider actually used for this leg (may differ from the configured
// provider when Floxy is in failover). Drives accurate telemetry + which
// provider's health to report success/failure against.
provider: ProxyProvider;
} {
if (this.proxyProvider === "none")
return { proxyUrl: null, proxyConfig: null, sessionKey: null, provider: "none" };
// Floxy-primary but the shared health gate is tripped (Floxy down — balance
// or tunnel) → fail over to the DataImpulse datacenter pool for this leg so
// captures/calls keep landing instead of dying on a dead residential proxy.
const useDataImpulse =
this.proxyProvider === "dataimpulse" ||
(this.proxyProvider === "floxy" && this.proxyHealth.isFloxyDown());
if (useDataImpulse) {
const port = this.allocatePort();
const server = `http://${this.diHost}:${port}`;
return {
proxyUrl: `http://${this.diUser}:${this.diPass}@${this.diHost}:${port}`,
proxyConfig: { server, username: this.diUser, password: this.diPass },
sessionKey: `di:${port}`,
provider: "dataimpulse",
};
}
// floxy
let password = this.floxyPass;
let sessionKey: string | null = null;
if (leg === "capture" && this.floxyCaptureLifetime > 0) {
const sid = Math.random().toString(36).slice(2, 10);
password = `${this.floxyPass}_session-${sid}_lifetime-${this.floxyCaptureLifetime}`;
sessionKey = `s-${sid}`;
}
const server = `http://${this.floxyHost}:${this.floxyPort}`;
return {
proxyUrl: `http://${this.floxyUser}:${password}@${this.floxyHost}:${this.floxyPort}`,
proxyConfig: { server, username: this.floxyUser, password },
sessionKey,
provider: "floxy",
};
}
async onModuleInit(): Promise<void> {
@@ -193,10 +364,14 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
// ─── Public API ───────────────────────────────────────────
/**
* Acquire a matched JWT + proxy session from the pool.
* Returns a valid slot (round-robin) or captures on-demand if pool is empty.
* Acquire a JWT session from the pool.
* Returns a valid slot (round-robin) or, if the pool is empty, captures
* on-demand. With `blockOnEmpty: false` it does NOT block on a cold pool —
* it kicks off a background warm-up and returns null so a latency-sensitive
* caller (the decode race) can let a faster source answer instead of eating
* the multi-second capture cost inline.
*/
async acquireSession(): Promise<PcatSession> {
async acquireSession(opts?: { blockOnEmpty?: boolean }): Promise<PcatSession | null> {
this.trackRequest();
// Try to find a valid slot in the pool
@@ -207,14 +382,26 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
return this.slotToSession(slot);
}
// No valid slot — capture on-demand
// No valid slot. Latency-sensitive callers fast-fail and warm in background.
if (opts?.blockOnEmpty === false) {
this.logger.log("JWT pool empty — fast-fail (warming in background)");
this.captureToPool().catch((err) => {
this.logger.warn(`Background warm capture failed: ${(err as Error).message}`);
});
return null;
}
// Capture on-demand (blocks the caller).
this.logger.log("JWT pool empty — capturing on-demand...");
const newSlot = await this.captureToPool();
return this.slotToSession(newSlot);
}
/**
* Invalidate a session after 401/403, remove the slot, and capture a replacement.
* Invalidate a session (401/403, or N consecutive transport failures on
* rotating IPs = a token that's dead at the source), remove the slot, and
* capture a replacement only if the pool is now empty — a surviving sibling
* plus the scheduled refresh/scale-up covers the rest without a capture storm.
*/
async invalidateSession(session: PcatSession): Promise<void> {
const idx = this.pool.indexOf(session._slot);
@@ -222,17 +409,18 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
this.clearSlotTimer(this.pool[idx]);
this.pool.splice(idx, 1);
this.logger.log(
`JWT pool: slot invalidated (port ${session._slot.proxyPort}), ${this.pool.length} remaining`,
`JWT pool: slot invalidated (${new URL(session._slot.siteUsed).hostname}), ${this.pool.length} remaining`,
);
}
// The Redis-shared slot may point at a now-401'd JWT (e.g. IP rotated
// away on the proxy). Drop it so other containers re-capture instead of
// inheriting the same dead slot.
// The Redis-shared slot may point at this same now-dead token. Drop it so
// a cold-booting container re-captures instead of hydrating the dead token.
this.deleteFromRedis().catch(() => {});
// Capture replacement in background (don't block the caller's retry)
if (this.isBusinessHours() || this.pool.length === 0) {
// Only force an immediate replacement when the pool emptied; otherwise let
// the surviving slot serve and the refresh/scale-up timers backfill, so a
// burst of evictions can't stampede the capture pipeline.
if (this.pool.length === 0) {
this.captureToPool().catch((err) => {
this.logger.error(`Replacement capture failed: ${(err as Error).message}`);
});
@@ -241,6 +429,20 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
// ─── Pool management ──────────────────────────────────────
/**
* Trim the pool to MAX_POOL_SIZE, evicting the oldest slots (by capturedAt) and
* clearing their refresh timers. Idempotent; a no-op when at/under the cap.
*/
private enforcePoolCap(): void {
if (this.pool.length <= MAX_POOL_SIZE) return;
this.pool.sort((a, b) => a.capturedAt - b.capturedAt); // oldest first
const evicted = this.pool.splice(0, this.pool.length - MAX_POOL_SIZE);
for (const s of evicted) this.clearSlotTimer(s);
this.logger.warn(
`JWT pool: over cap, evicted ${evicted.length} oldest slot(s), size now ${this.pool.length}`,
);
}
private pickValidSlot(): JwtSlot | null {
const now = Math.floor(Date.now() / 1000);
// Remove expired slots
@@ -262,16 +464,10 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
}
private slotToSession(slot: JwtSlot): PcatSession {
const proxyUrl = this.useProxy
? `http://${this.proxyUser}:${this.proxyPass}@${this.proxyHost}:${slot.proxyPort}`
: null;
const proxyConfig = this.useProxy
? {
server: `http://${this.proxyHost}:${slot.proxyPort}`,
username: this.proxyUser,
password: this.proxyPass,
}
: null;
// Fresh rotating call-leg proxy per request — and per retry, since
// fetchWithAuth re-acquires the session each attempt → a new exit IP each
// time, so a slow/blocked residential IP is shed on the next try.
const { proxyUrl, proxyConfig } = this.buildProxy("call");
return {
apiKey: slot.jwt.raw,
apiPath: slot.jwt.apiPath,
@@ -294,17 +490,16 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
const maxRetries = 4;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const port = this.allocatePort();
const siteUrl = this.getAvailableSite();
const jwt = await this.attemptCapture(siteUrl, port);
const jwt = await this.attemptCapture(siteUrl);
if (jwt) {
// Validate the JWT+proxy combo against the real upstream before
// publishing the slot — see VALIDATION_VIN comment above.
const valid = await this.validateSlot(jwt, port);
// Validate the token against the real upstream (on a fresh rotating
// call-leg IP) before publishing the slot — see VALIDATION_VIN.
const valid = await this.validateSlot(jwt);
if (!valid) {
this.logger.warn(
`JWT capture attempt ${attempt + 1}/${maxRetries} validation failed (port ${port}, ${new URL(siteUrl).hostname}), trying next site...`,
`JWT capture attempt ${attempt + 1}/${maxRetries} validation failed (${new URL(siteUrl).hostname}), trying next site...`,
);
// Mark site as used anyway so we rotate away rather than retry it.
this.siteLastUsedAt.set(siteUrl, Date.now());
@@ -313,17 +508,24 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
const slot: JwtSlot = {
jwt,
proxyPort: port,
siteUsed: siteUrl,
capturedAt: Date.now(),
refreshTimer: null,
failCount: 0,
};
// Mark site as used
this.siteLastUsedAt.set(siteUrl, Date.now());
// Add to pool
// Add to pool, then enforce the hard cap. A slot-accounting race in the
// refresh path (concurrent captures via the semaphore, or a refresh
// whose old slot was already removed) could otherwise let the pool grow
// past MAX_POOL_SIZE — observed at 32-39 on prod, which multiplies
// capture load and partner-site ban exposure for no benefit. Evicting
// the oldest slots keeps the freshest MAX_POOL_SIZE and makes the
// documented invariant hold regardless of the race.
this.pool.push(slot);
this.enforcePoolCap();
// Publish to Redis so other containers can hydrate from this slot.
this.persistToRedis(slot).catch(() => {});
@@ -352,13 +554,14 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
}
/**
* Probe the just-captured token + proxy port with a single cheap upstream
* call (/car/info with a public demo VIN). Returns true iff the upstream
* accepts the combo with 2xx within VALIDATION_TIMEOUT_MS. A failure here
* (proxy dead, IP blocked, token 401/403) is far cheaper to absorb at
* capture time than to inherit when a real user clicks a category.
* Probe the just-captured token with a single cheap upstream call (/car/info
* with a public demo VIN) on a fresh rotating call-leg IP — the SAME transport
* a real request will use. Returns true iff upstream accepts it with 2xx within
* VALIDATION_TIMEOUT_MS. Validating on the call-leg transport (not the capture
* IP) is what makes the check meaningful: it rejects a token only if it can't
* be served the way it will actually be served.
*/
private async validateSlot(jwt: PcatJwtToken, port: number): Promise<boolean> {
private async validateSlot(jwt: PcatJwtToken): Promise<boolean> {
const url = `${PCAT_API_BASE}/car/info?q=${VALIDATION_VIN}`;
const fetchOptions: RequestInit & { dispatcher?: any } = {
method: "GET",
@@ -375,17 +578,35 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
},
signal: AbortSignal.timeout(VALIDATION_TIMEOUT_MS),
};
if (this.useProxy) {
const { proxyUrl } = this.buildProxy("call");
if (proxyUrl) {
const { ProxyAgent } = await import("undici");
fetchOptions.dispatcher = new ProxyAgent({
uri: `http://${this.proxyUser}:${this.proxyPass}@${this.proxyHost}:${port}`,
uri: proxyUrl,
connect: { timeout: 6_000 },
});
}
const start = Date.now();
try {
const r = await fetch(url, fetchOptions);
this.proxyTelemetry.record({
service: "pcat_validate",
provider: this.proxyProvider,
targetHost: "gui.parts-catalogs.com",
statusCode: r.status,
success: r.ok,
durationMs: Date.now() - start,
});
return r.ok;
} catch (err) {
this.proxyTelemetry.record({
service: "pcat_validate",
provider: this.proxyProvider,
targetHost: "gui.parts-catalogs.com",
errorKind: classifyTransportError(err),
success: false,
durationMs: Date.now() - start,
});
this.logger.debug(`Slot validation threw: ${(err as Error).message}`);
return false;
}
@@ -539,15 +760,16 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
private getAvailableSite(): string {
const now = Date.now();
const sites = this.jwtSites;
// Try round-robin, preferring sites not on cooldown
for (let i = 0; i < JWT_SITES.length; i++) {
const idx = (this.siteIndex + i) % JWT_SITES.length;
const site = JWT_SITES[idx];
for (let i = 0; i < sites.length; i++) {
const idx = (this.siteIndex + i) % sites.length;
const site = sites[idx];
const lastUsed = this.siteLastUsedAt.get(site) || 0;
if (now - lastUsed >= SITE_COOLDOWN) {
this.siteIndex = (idx + 1) % JWT_SITES.length;
this.siteIndex = (idx + 1) % sites.length;
return site;
}
}
@@ -555,15 +777,15 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
// All on cooldown — pick the one with oldest usage
let oldestIdx = 0;
let oldestTime = Number.POSITIVE_INFINITY;
for (let i = 0; i < JWT_SITES.length; i++) {
const lastUsed = this.siteLastUsedAt.get(JWT_SITES[i]) || 0;
for (let i = 0; i < sites.length; i++) {
const lastUsed = this.siteLastUsedAt.get(sites[i]) || 0;
if (lastUsed < oldestTime) {
oldestTime = lastUsed;
oldestIdx = i;
}
}
this.siteIndex = (oldestIdx + 1) % JWT_SITES.length;
return JWT_SITES[oldestIdx];
this.siteIndex = (oldestIdx + 1) % sites.length;
return sites[oldestIdx];
}
private allocatePort(): number {
@@ -572,19 +794,55 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
// ─── JWT capture via Playwright ───────────────────────────
private async attemptCapture(siteUrl: string, port: number): Promise<PcatJwtToken | null> {
private async attemptCapture(siteUrl: string, forceDirect = false): Promise<PcatJwtToken | null> {
let context: BrowserContext | null = null;
const startTime = Date.now();
// Sticky capture proxy: one residential exit IP pinned for the lifetime of
// this capture so the partner site + widget see a coherent session.
// forceDirect (set on retry after a proxy tunnel failure) skips the proxy —
// see captureAllowDirect: the token is not IP-bound so a direct capture is a
// safe last resort that keeps the pool alive through a full proxy outage.
const { proxyConfig, proxyUrl, sessionKey, provider } = forceDirect
? {
proxyConfig: null,
proxyUrl: null as string | null,
sessionKey: null as string | null,
provider: "none" as ProxyProvider,
}
: this.buildProxy("capture");
// Set when a proxied navigation dies on a tunnel/connect failure and direct
// capture is allowed — we fall through to a direct retry instead of counting
// this as a dead site.
let retryDirect = false;
// The session is sticky, so a parallel ipify probe exits from the SAME IP
// the capture will use — that's what makes banned-IP tracking concrete.
// Resolves in ~1-2s while the capture itself takes 7s+; never throws.
// Gated off by default (PCAT_EXIT_IP_PROBE): the probe is billed proxy
// traffic and only feeds exit-IP telemetry, not the capture itself.
const exitIpPromise =
this.exitIpProbe && proxyUrl ? resolveExitIp(proxyUrl) : Promise.resolve(null);
const logCapture = (ok: boolean, errorKind?: string): void => {
void exitIpPromise.then((exitIp) =>
this.proxyTelemetry.record({
service: "pcat_capture",
provider,
sessionKey,
exitIp,
targetHost: new URL(siteUrl).hostname,
errorKind: errorKind ?? null,
success: ok,
durationMs: Date.now() - startTime,
}),
);
};
try {
// Build context options with proxy
const contextOptions: Record<string, unknown> = {};
if (this.useProxy) {
contextOptions.proxy = {
server: `http://${this.proxyHost}:${port}`,
username: this.proxyUser,
password: this.proxyPass,
};
if (proxyConfig) {
contextOptions.proxy = proxyConfig;
}
if (!this.browser) throw new Error("PCAT browser not initialized");
@@ -615,8 +873,15 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
this.logger.debug(`Token intercepted (key=${apiKey.slice(0, 16)}...)`);
});
// Block heavy resources to save proxy bandwidth
await page.route("**/*", (route) => {
// Block heavy resources to save proxy bandwidth. Kept intentionally
// minimal: an aggressive block (stylesheet + yastatic/font/ad domains)
// shipped 2026-07-16 killed token capture on prod (dataimpulse 425→0
// captures/h at deploy time — the RU catalog widget needs its CSS and
// Yandex-hosted runtime to init and fire the /v3/api/proxy XHR). Only
// block what the widget provably never needs: images, fonts, media, and
// a few pure analytics/ad domains.
const assetCache = this.assetCache;
await page.route("**/*", async (route) => {
const url = route.request().url();
const type = route.request().resourceType();
@@ -637,15 +902,42 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
return route.abort();
}
// Serve versioned static JS/CSS from the cross-capture cache; a hit
// costs zero proxy bytes. On miss, fetch once through the context proxy,
// store the decoded body, and serve it. Everything else (HTML document,
// the /v3/api/proxy token XHR, dynamic fetches) always goes live.
if (assetCache && PcatAssetCache.isCacheable(type, url)) {
const hit = await assetCache.get(url);
if (hit) {
return route.fulfill({ status: 200, contentType: hit.contentType, body: hit.body });
}
try {
const resp = await route.fetch({ timeout: 15_000 });
const body = await resp.body();
const contentType =
resp.headers()["content-type"] ??
(type === "script" ? "application/javascript" : "text/css");
if (resp.ok() && body.length) void assetCache.put(url, contentType, body);
return route.fulfill({ status: resp.status(), contentType, body });
} catch {
// Fetch failed (slow/dead proxy) — let the browser attempt it itself.
return route.continue();
}
}
return route.continue();
});
// Navigate — networkidle waits for widget JS to load + make API calls
// Navigate with domcontentloaded (not networkidle): on JS-heavy catalog
// sites through a slow residential proxy networkidle often never settles
// and the goto times out before the widget fires its token call. DOM-ready
// is reliable (~7s measured); the request listener + post-goto poll then
// catch the /v3/api/proxy token call (~11.8s measured).
let gotoOk = true;
try {
await page.goto(siteUrl, {
timeout: PAGE_TIMEOUT,
waitUntil: "networkidle",
waitUntil: "domcontentloaded",
});
} catch (navErr) {
// Navigation may timeout but JWT could still be captured if the
@@ -653,27 +945,58 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
// constants above.
gotoOk = false;
this.logger.debug(`Navigation ended: ${(navErr as Error).message?.slice(0, 80)}`);
const connectFailed = isProxyConnectFailure(navErr);
// A tunnel/connect failure here (not a plain slow-site timeout) means the
// Floxy exit is dead — trip the shared gate so this + EMEX fail over to
// DataImpulse for the cooldown window.
if (provider === "floxy" && connectFailed) {
this.proxyHealth.reportFloxyFailure(`pcat capture: ${classifyTransportError(navErr)}`);
}
// Proxy tunnel is dead (all providers). Rather than return an empty
// capture and starve the pool, retry this same site direct.
if (connectFailed && proxyConfig && this.captureAllowDirect && !forceDirect) {
retryDirect = true;
}
}
// Poll for token — short cap after healthy goto, brief grace after fail
const pollMax = gotoOk ? CAPTURE_POLL_AFTER_OK : CAPTURE_POLL_AFTER_FAIL;
for (let i = 0; i < pollMax; i++) {
if (capturedToken) break;
await new Promise((r) => setTimeout(r, CAPTURE_POLL_INTERVAL));
if (!retryDirect) {
// Poll for token — short cap after healthy goto, brief grace after fail
const pollMax = gotoOk ? CAPTURE_POLL_AFTER_OK : CAPTURE_POLL_AFTER_FAIL;
for (let i = 0; i < pollMax; i++) {
if (capturedToken) break;
await new Promise((r) => setTimeout(r, CAPTURE_POLL_INTERVAL));
}
const elapsed = Date.now() - startTime;
if (capturedToken) {
logCapture(true);
// Floxy answered — clear any failover cooldown so we resume residential.
if (provider === "floxy") this.proxyHealth.reportFloxySuccess();
this.logger.log(
`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}${forceDirect ? " (direct)" : ""}`,
);
return capturedToken;
}
// Widget never fired its token call — on a sticky residential IP this is
// the classic "partner site blocked/ratelimited this exit IP" signature.
logCapture(false, "capture_timeout");
this.logger.debug(`No token after ${elapsed}ms from ${siteUrl}`);
return null;
}
const elapsed = Date.now() - startTime;
if (capturedToken) {
this.logger.log(`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`);
return capturedToken;
}
this.logger.debug(`No token after ${elapsed}ms from ${siteUrl}`);
return null;
} catch (err) {
this.logger.warn(`Token capture error: ${(err as Error).message}`);
return null;
logCapture(false, classifyTransportError(err));
const connectFailed = isProxyConnectFailure(err);
if (provider === "floxy" && connectFailed) {
this.proxyHealth.reportFloxyFailure(`pcat capture: ${classifyTransportError(err)}`);
}
if (connectFailed && proxyConfig && this.captureAllowDirect && !forceDirect) {
retryDirect = true;
} else {
this.logger.warn(`Token capture error: ${(err as Error).message}`);
return null;
}
} finally {
if (context) {
try {
@@ -686,6 +1009,13 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
}
}
}
// Reached only when a proxied attempt hit a dead tunnel and direct is
// allowed — retry the same site straight from the server IP.
this.logger.warn(
`Capture proxy tunnel dead (${provider}) for ${new URL(siteUrl).hostname} — retrying direct`,
);
return this.attemptCapture(siteUrl, true);
}
// ─── Browser lifecycle ───────────────────────────────────

View File

@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
import { ProxyTelemetryModule } from "../proxy-telemetry/proxy-telemetry.module";
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
import { PartsCatalogsService } from "./parts-catalogs.service";
@Module({
imports: [ProxyTelemetryModule],
providers: [PartsCatalogsAuthService, PartsCatalogsService],
exports: [PartsCatalogsService],
})

View File

@@ -2,14 +2,22 @@
* Parts-Catalogs API Service — HTTP client for parts-catalogs.com v3
*
* Calls the v3 widget proxy (gui.parts-catalogs.com/v3/api/proxy/*) with the
* captured TWS- token + supporting X-* headers. Requests must go through the
* same DataImpulse proxy port that captured the token (IP-bound).
* captured TWS- token + supporting X-* headers. The token is portable (NOT
* IP-bound), so each request rotates a fresh exit IP via the auth service's
* call-leg proxy (PartsCatalogsAuthService.buildProxy).
*/
import { Injectable, Logger } from "@nestjs/common";
import { isBackfillContext } from "../../jobs/prefetch-context";
import { PostHogService } from "../../posthog/posthog.service";
import { RedisService } from "../../redis/redis.service";
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
import {
ProxyTelemetryService,
classifyTransportError,
describeProxyUrl,
isProxyConnectFailure,
} from "../proxy-telemetry/proxy-telemetry.service";
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
import {
PcatCar,
@@ -20,19 +28,38 @@ import {
} from "./parts-catalogs.types";
const API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy";
const REQUEST_TIMEOUT = Number(process.env.PCAT_REQUEST_TIMEOUT_MS) || 30_000;
// Catalog scrape (groups2/parts2). Lowered 30s→10s (2026-06-10): the old 30s
// default could blow the caller's 25s decode budget on a single slow attempt,
// and through a rotating residential proxy a healthy call answers in 1-4s. A
// slow/blocked exit IP now aborts at 10s and the retry re-acquires a fresh IP.
const REQUEST_TIMEOUT = Number(process.env.PCAT_REQUEST_TIMEOUT_MS) || 10_000;
const MAX_RETRIES = Number(process.env.PCAT_MAX_RETRIES) || 2;
// A dead DataImpulse proxy port otherwise stalls for undici's 10s default connect
// timeout before the retry rotates to a fresh port — three of those blow the
// caller's 25s decode budget. Fail fast so retries reach a live port in time.
// undici's ProxyAgent connect.timeout bounds the connect to the proxy entry.
const PROXY_CONNECT_TIMEOUT = Number(process.env.PCAT_PROXY_CONNECT_TIMEOUT_MS) || 6_000;
// VIN decode (/car/info) answers in <1s on a healthy proxy, so a long timeout only
// prolongs dead-port connects. undici's ProxyAgent connect.timeout does NOT bound the
// connection to the proxy itself (it stays at undici's 10s default), so we bound it at
// the fetch level: a short per-call timeout + extra retries makes a stuck DataImpulse
// port abort fast and rotate to a live one within the caller's 25s decode budget.
// VIN decode (/car/info) answers in 1-4s through the rotating residential proxy.
// A short per-call timeout + retries means a slow exit IP aborts fast and the
// next attempt re-acquires the session → a fresh IP, all within the 25s budget.
const DECODE_REQUEST_TIMEOUT = Number(process.env.PCAT_DECODE_TIMEOUT_MS) || 6_000;
const DECODE_MAX_RETRIES = Number(process.env.PCAT_DECODE_MAX_RETRIES) || 4;
// Evict a pooled token after this many consecutive transport failures (reset on
// any 2xx). Through the rotating residential proxy each attempt hits a different
// exit IP, so N failures in a row on N different IPs ⇒ the token is dead at the
// source, not a flaky IP. Pre-rotation this was the "dead slot never evicted"
// bug: invalidateSession only fired on 401/403, so a bad token was served until TTL.
const TRANSPORT_FAIL_EVICT_THRESHOLD = Number(process.env.PCAT_TRANSPORT_FAIL_EVICT) || 2;
/**
* Thrown when a fast-fail (blockOnEmpty:false) caller hits a cold pool. NOT a
* "VIN not found" — pcat was skipped while the pool warmed, so the caller must
* treat it as a transient miss (don't poison the negative cache) and let
* another source answer.
*/
class PcatColdPoolError extends Error {
constructor() {
super("pcat cold pool — skipped to avoid inline capture");
this.name = "PcatColdPoolError";
}
}
@Injectable()
export class PartsCatalogsService {
@@ -42,6 +69,8 @@ export class PartsCatalogsService {
private authService: PartsCatalogsAuthService,
private redis: RedisService,
private posthog: PostHogService,
private proxyTelemetry: ProxyTelemetryService,
private proxyHealth: ProxyHealthService,
) {}
/** Mark parts-catalogs as actively used (5min TTL) to defer prefetch worker */
@@ -62,7 +91,8 @@ export class PartsCatalogsService {
async decodeVin(
vin: string,
signal?: AbortSignal,
outcome?: { transient: boolean },
outcome?: { transient: boolean; coldSkip?: boolean },
opts?: { blockOnEmpty?: boolean },
): Promise<PcatVinResult | null> {
if (!(await this.posthog.isSourceLive("parts-catalogs"))) {
this.logger.warn("parts-catalogs disabled by kill switch (kill-source-parts-catalogs)");
@@ -72,6 +102,7 @@ export class PartsCatalogsService {
const data = await this.fetchWithAuth("/car/info", { q: vin }, signal, {
timeoutMs: DECODE_REQUEST_TIMEOUT,
maxRetries: DECODE_MAX_RETRIES,
blockOnEmpty: opts?.blockOnEmpty,
});
if (!data || typeof data !== "object") {
@@ -105,9 +136,23 @@ export class PartsCatalogsService {
return { cars };
} catch (err) {
const e = err as Error & { cause?: unknown };
// Distinguish a transient transport blip (proxy timeout / dropped DataImpulse
// connection) from a genuine "not found". The caller uses `outcome.transient`
// to decide whether to poison the negative cache — a blip must not.
// Cold-pool fast-fail: pcat was skipped (pool warming), NOT a definitive
// miss. Mark transient so the orchestrator doesn't negative-cache a VIN
// pcat hasn't actually evaluated, and stay quiet (this is expected).
if (e instanceof PcatColdPoolError) {
if (outcome) {
outcome.transient = true;
// A deliberate cold-pool skip is not a transport fault — flag it so the
// circuit breaker doesn't count it (the pool is already warming).
outcome.coldSkip = true;
}
this.logger.debug(`pcat decode skipped for ${vin}: cold pool (warming)`);
return null;
}
// Distinguish a transient transport blip (proxy timeout / dropped
// connection) from a genuine "not found". The caller uses
// `outcome.transient` to decide whether to poison the negative cache — a
// blip must not.
if (
outcome &&
(e.name === "TimeoutError" ||
@@ -212,7 +257,7 @@ export class PartsCatalogsService {
endpoint: string,
params?: Record<string, string>,
externalSignal?: AbortSignal,
opts?: { timeoutMs?: number; maxRetries?: number },
opts?: { timeoutMs?: number; maxRetries?: number; blockOnEmpty?: boolean },
): Promise<any> {
const maxRetries = opts?.maxRetries ?? MAX_RETRIES;
const timeoutMs = opts?.timeoutMs ?? REQUEST_TIMEOUT;
@@ -223,7 +268,12 @@ export class PartsCatalogsService {
if (externalSignal?.aborted) {
throw new Error("Request aborted by caller");
}
session = await this.authService.acquireSession();
session = await this.authService.acquireSession({ blockOnEmpty: opts?.blockOnEmpty });
if (!session) {
// Cold pool + fast-fail: warming in the background; don't retry, let the
// caller fall through to another source.
throw new PcatColdPoolError();
}
const url = new URL(`${API_BASE}${endpoint}`);
if (params) {
@@ -232,6 +282,7 @@ export class PartsCatalogsService {
}
}
const attemptStart = Date.now();
try {
const signals = [AbortSignal.timeout(timeoutMs)];
if (externalSignal) signals.push(externalSignal);
@@ -262,7 +313,24 @@ export class PartsCatalogsService {
const response = await fetch(url.toString(), fetchOptions);
// An HTTP answer arrived — the proxy leg worked at transport level;
// log the upstream's verdict (403/429 are the IP-ban signals).
this.proxyTelemetry.record({
service: "pcat_call",
...describeProxyUrl(session.proxyUrl),
targetHost: url.hostname,
statusCode: response.status,
success: response.ok,
durationMs: Date.now() - attemptStart,
});
if (response.ok) {
// Token proven good on this IP — clear any accrued failure streak.
session._slot.failCount = 0;
// Floxy answered at transport level — clear any failover cooldown.
if (describeProxyUrl(session.proxyUrl).provider === "floxy") {
this.proxyHealth.reportFloxySuccess();
}
return await response.json();
}
@@ -280,6 +348,23 @@ export class PartsCatalogsService {
throw new Error(`HTTP ${response.status} from ${endpoint}: ${text.slice(0, 200)}`);
} catch (err) {
const e = err as Error & { cause?: unknown };
// Transport failure (no HTTP answer) — the try block already logged any
// real HTTP response before rethrowing it as `Error: HTTP <code> …`.
if (!/^HTTP \d/.test(e.message ?? "")) {
this.proxyTelemetry.record({
service: "pcat_call",
...describeProxyUrl(session.proxyUrl),
targetHost: url.hostname,
errorKind: classifyTransportError(e),
success: false,
durationMs: Date.now() - attemptStart,
});
// Floxy exit refused/closed the CONNECT → trip the shared gate so the
// next capture/call (and EMEX) fails over to DataImpulse.
if (describeProxyUrl(session.proxyUrl).provider === "floxy" && isProxyConnectFailure(e)) {
this.proxyHealth.reportFloxyFailure(`pcat call: ${classifyTransportError(e)}`);
}
}
// Retry transient TRANSPORT failures only: request timeouts and undici
// network errors ("TypeError: fetch failed" — a dropped/reset DataImpulse
// proxy connection). A definitive HTTP response (e.g. 400 "list of parts
@@ -291,16 +376,28 @@ export class PartsCatalogsService {
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|other side closed|terminated|UND_ERR/i.test(
`${e.message} ${String(e.cause ?? "")}`,
);
if (transient && attempt < maxRetries) {
this.logger.warn(
`Transient transport error on ${endpoint} (attempt ${attempt + 1}/${maxRetries + 1}): ${e.message}${
e.cause ? ` [cause: ${String(e.cause)}]` : ""
}`,
);
// Brief backoff so a momentarily-flaky proxy port can recover; the next
// loop iteration re-acquires a session (round-robin across the pool).
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
continue;
if (transient) {
// A token that keeps failing transport across rotating IPs is dead at
// the source — evict it so it stops being round-robin-served (the old
// 401/403-only eviction never caught this). Only when a proxy is in use;
// direct-mode transport errors are upstream, not token/IP problems.
if (session.proxyUrl) {
session._slot.failCount = (session._slot.failCount ?? 0) + 1;
if (session._slot.failCount >= TRANSPORT_FAIL_EVICT_THRESHOLD) {
await this.authService.invalidateSession(session);
}
}
if (attempt < maxRetries) {
this.logger.warn(
`Transient transport error on ${endpoint} (attempt ${attempt + 1}/${maxRetries + 1}): ${e.message}${
e.cause ? ` [cause: ${String(e.cause)}]` : ""
}`,
);
// Brief backoff so a momentarily-flaky exit IP can recover; the next
// loop iteration re-acquires a session (a fresh rotating IP).
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
continue;
}
}
throw err;
}

View File

@@ -1,7 +1,12 @@
/**
* Captured from a parts-catalogs.com v3 widget request.
* Token + the supporting X-* headers the widget sends with every API call.
* IP-bound (must be reused with the same proxy port that captured it).
*
* NOT request-time IP-bound. Verified on prod 2026-06-10: 0 auth 401/403 across
* thousands of proxied calls, and a token captured on one exit IP replays with a
* 200 through arbitrary other IPs (incl. fresh residential ones). Upstream issues
* a stable shared token per origin. So the call leg rotates exit IPs freely; only
* the capture (Playwright) leg holds a sticky IP for the duration of one capture.
*/
export interface PcatJwtToken {
raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..."
@@ -15,10 +20,14 @@ export interface PcatJwtToken {
export interface JwtSlot {
jwt: PcatJwtToken;
proxyPort: number;
siteUsed: string;
capturedAt: number;
refreshTimer: ReturnType<typeof setTimeout> | null;
// Consecutive transport failures across requests (reset on any 2xx). The slot
// is evicted once this crosses a threshold — a token that keeps failing on
// fresh rotating IPs is dead at the source, not a flaky exit IP. Optional so
// slots persisted to Redis by older builds still hydrate.
failCount?: number;
}
export interface PcatSession {

View File

@@ -0,0 +1,17 @@
<html><head>
<title>Ford WF0RXXGCDRAP80417: Kuga - CBV (2008, 2012) - partslink24</title>
<script>
</script></head><body>
<table>
<tr id="_nav-scope-table0" class="tc-row tc-data-row tc-selected" jsonurl="json-vin-main-group.action?lang=en&amp;openVinDialog=false&amp;startup=false&amp;vin=WF0RXXGCDRAP80417&amp;mode=A0LW0DEDE&amp;upds=2026.09.08+15%3A14%3A06+CEST" caption="Kuga - CBV (2008, 2012)"><td class="caption tc-lcell tc-rcell dynaheight"><div class="dynaheightWrapper"><div class="dblWrap">Kuga - CBV (2008, 2012)</div></div></td></tr>
<tr id="_nav-scope-table1" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?lang=en&amp;openVinDialog=false&amp;sharedCatCode=ZE&amp;startup=false&amp;vin=WF0RXXGCDRAP80417&amp;mode=A0LW0DEDE&amp;upds=2026.09.08+15%3A14%3A06+CEST" caption="ZE - Accessories (ZE)&lt;div class=&quot;restrictionBlockWithMargin&quot;&gt;&lt;/div&gt;"><td class="caption tc-lcell tc-rcell dynaheight"><div class="dynaheightWrapper"><div class="dblWrap">ZE - Accessories (ZE)<div class="restrictionBlockWithMargin"></div></div></div></td></tr>
<tr id="_nav-scope-table2" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?lang=en&amp;openVinDialog=false&amp;sharedCatCode=ZF&amp;startup=false&amp;vin=WF0RXXGCDRAP80417&amp;mode=A0LW0DEDE&amp;upds=2026.09.08+15%3A14%3A06+CEST" caption="ZF - Fluids &amp; Maintenance Products (ZF)&lt;div class=&quot;restrictionBlockWithMargin&quot;&gt;&lt;/div&gt;"><td class="caption tc-lcell tc-rcell dynaheight"><div class="dynaheightWrapper"><div class="dblWrap">ZF - Fluids &amp; Maintenance Products (ZF)<div class="restrictionBlockWithMargin"></div></div></div></td></tr>
<tr id="_nav-scope-table4" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?lang=en&amp;openVinDialog=false&amp;sharedCatCode=C20DD0X&amp;startup=false&amp;vin=WF0RXXGCDRAP80417&amp;mode=A0LW0DEDE&amp;upds=2026.09.08+15%3A14%3A06+CEST" caption="C20DD0X - 2.0 DI Diesel&lt;div class=&quot;restrictionBlockWithMargin&quot;&gt;&lt;span class=&quot;vinHit&quot; title=&quot;Attribute matches&quot;&gt;Engine Type = All 2.0L Duratorq Engines&amp;nbsp;&lt;/span&gt;&lt;/div&gt;"><td class="caption tc-lcell tc-rcell dynaheight"><div class="dynaheightWrapper"><div class="dblWrap">C20DD0X - 2.0 DI Diesel<div class="restrictionBlockWithMargin"><span class="vinHit" title="Attribute matches">Engine Type = All 2.0L Duratorq Engines&nbsp;</span></div></div></div></td></tr>
<tr id="_nav-scope-table5" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?lang=en&amp;openVinDialog=false&amp;sharedCatCode=CMPS6&amp;startup=false&amp;vin=WF0RXXGCDRAP80417&amp;mode=A0LW0DEDE&amp;upds=2026.09.08+15%3A14%3A06+CEST" caption="CMPS6 - 6 Speed Trans Powershift&lt;div class=&quot;restrictionBlockWithMargin&quot;&gt;&lt;span class=&quot;vinHit&quot; title=&quot;Attribute matches&quot;&gt;Transmission = 6 Speed Powershift 6DCT450 - MPS6&amp;nbsp;&lt;/span&gt;&lt;/div&gt;"><td class="caption tc-lcell tc-rcell dynaheight"><div class="dynaheightWrapper"><div class="dblWrap">CMPS6 - 6 Speed Trans Powershift<div class="restrictionBlockWithMargin"><span class="vinHit" title="Attribute matches">Transmission = 6 Speed Powershift 6DCT450 - MPS6&nbsp;</span></div></div></div></td></tr>
<tr id="_nav-scope-table6" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?lang=en&amp;openVinDialog=false&amp;sharedCatCode=CVPTO&amp;startup=false&amp;vin=WF0RXXGCDRAP80417&amp;mode=A0LW0DEDE&amp;upds=2026.09.08+15%3A14%3A06+CEST" caption="CVPTO - Transfer Case&lt;div class=&quot;restrictionBlockWithMargin&quot;&gt;&lt;span class=&quot;vinHit&quot; title=&quot;Attribute matches&quot;&gt;Transmission = 6 Speed Powershift 6DCT450 - MPS6&amp;nbsp;&lt;/span&gt;&lt;/div&gt;"><td class="caption tc-lcell tc-rcell dynaheight"><div class="dynaheightWrapper"><div class="dblWrap">CVPTO - Transfer Case<div class="restrictionBlockWithMargin"><span class="vinHit" title="Attribute matches">Transmission = 6 Speed Powershift 6DCT450 - MPS6&nbsp;</span></div></div></div></td></tr>
<tr class="tc-row tc-data-row " id="_nav-maingroup-table0"><td class="tc-lcell identifier">1</td><td class="tc-rcell caption">Information And Customisation</td></tr>
<tr class="tc-row tc-data-row " id="_nav-maingroup-table1"><td class="tc-lcell identifier">2</td><td class="tc-rcell caption">Chassis</td></tr>
<tr class="tc-row tc-data-row " id="_nav-maingroup-table2"><td class="tc-lcell identifier">4</td><td class="tc-rcell caption">Electrical</td></tr>
<tr class="tc-row tc-data-row " id="_nav-maingroup-table3"><td class="tc-lcell identifier">5</td><td class="tc-rcell caption">Body And Paint</td></tr>
</table></body></html>

View File

@@ -0,0 +1,13 @@
<html><head>
<title>Hyundai KMHCT41BAGU283791: ACCENT 15 - partslink24</title>
<script>
</script></head><body>
<table>
<tr id="_nav-mainGroup-table0" class="tc-row tc-data-row " url="vin-group.action?lang=en&amp;mainGroup=BO&amp;openVinDialog=false&amp;startup=false&amp;vin=KMHCT41BAGU283791&amp;mode=A0LW0DEDE&amp;upds=2026.09.05+11%3A12%3A58+CEST"><td class="mainGroup identifier tc-lcell"><a class="unlink">BO</a></td><td class="mainGroup caption tc-rcell"><a class="unlink">BODY</a></td></tr>
<tr id="_nav-mainGroup-table1" class="tc-row tc-data-row " url="vin-group.action?lang=en&amp;mainGroup=CH&amp;openVinDialog=false&amp;startup=false&amp;vin=KMHCT41BAGU283791&amp;mode=A0LW0DEDE&amp;upds=2026.09.05+11%3A12%3A58+CEST"><td class="mainGroup identifier tc-lcell"><a class="unlink">CH</a></td><td class="mainGroup caption tc-rcell"><a class="unlink">CHASSIS</a></td></tr>
<tr id="_nav-mainGroup-table2" class="tc-row tc-data-row " url="vin-group.action?lang=en&amp;mainGroup=EL&amp;openVinDialog=false&amp;startup=false&amp;vin=KMHCT41BAGU283791&amp;mode=A0LW0DEDE&amp;upds=2026.09.05+11%3A12%3A58+CEST"><td class="mainGroup identifier tc-lcell"><a class="unlink">EL</a></td><td class="mainGroup caption tc-rcell"><a class="unlink">ELECTRIC</a></td></tr>
<tr id="_nav-mainGroup-table3" class="tc-row tc-data-row " url="vin-group.action?lang=en&amp;mainGroup=EN&amp;openVinDialog=false&amp;startup=false&amp;vin=KMHCT41BAGU283791&amp;mode=A0LW0DEDE&amp;upds=2026.09.05+11%3A12%3A58+CEST"><td class="mainGroup identifier tc-lcell"><a class="unlink">EN</a></td><td class="mainGroup caption tc-rcell"><a class="unlink">ENGINE</a></td></tr>
<tr id="_nav-mainGroup-table4" class="tc-row tc-data-row " url="vin-group.action?lang=en&amp;mainGroup=MI&amp;openVinDialog=false&amp;startup=false&amp;vin=KMHCT41BAGU283791&amp;mode=A0LW0DEDE&amp;upds=2026.09.05+11%3A12%3A58+CEST"><td class="mainGroup identifier tc-lcell"><a class="unlink">MI</a></td><td class="mainGroup caption tc-rcell"><a class="unlink">TRANSMISSION</a></td></tr>
<tr id="_nav-mainGroup-table5" class="tc-row tc-data-row " url="vin-group.action?lang=en&amp;mainGroup=TR&amp;openVinDialog=false&amp;startup=false&amp;vin=KMHCT41BAGU283791&amp;mode=A0LW0DEDE&amp;upds=2026.09.05+11%3A12%3A58+CEST"><td class="mainGroup identifier tc-lcell"><a class="unlink">TR</a></td><td class="mainGroup caption tc-rcell"><a class="unlink">TRIM</a></td></tr>
</table></body></html>

View File

@@ -0,0 +1,109 @@
<html><head>
<title>Nissan - partslink24</title>
<script>
</script></head><body>
<table>
<tr id="_nav-model-table1" class="tc-row tc-data-row " caption="Repair &amp; Maintenance Information" ident="" urltype="EXTERN" url="https://eu.nissan.biz/" jsonurl="https://eu.nissan.biz/"><td colspan="2" class="model caption tc-rcell"><a class="unlink">Repair &amp; Maintenance Information</a></td></tr>
<tr id="_nav-model-table3" class="tc-row tc-data-row " caption="200SX (EL)" ident="S14_097_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=S14_097_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=S14_097_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">S14</td><td class="model caption tc-rcell"><a class="unlink">200SX</a></td></tr>
<tr id="_nav-model-table4" class="tc-row tc-data-row " caption="350Z (EL)" ident="Z33_007_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=Z33_007_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=Z33_007_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Z33</td><td class="model caption tc-rcell"><a class="unlink">350Z</a></td></tr>
<tr id="_nav-model-table5" class="tc-row tc-data-row " caption="370Z (EL)" ident="Z34_042_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=Z34_042_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=Z34_042_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Z34</td><td class="model caption tc-rcell"><a class="unlink">370Z</a></td></tr>
<tr id="_nav-model-table6" class="tc-row tc-data-row " caption="ALMERA (EL)" ident="B10RS_016_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=B10RS_016_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=B10RS_016_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">B10RS</td><td class="model caption tc-rcell"><a class="unlink">ALMERA</a></td></tr>
<tr id="_nav-model-table7" class="tc-row tc-data-row " caption="ALMERA (EL)" ident="G15RA_110_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=G15RA_110_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=G15RA_110_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">G15RA</td><td class="model caption tc-rcell"><a class="unlink">ALMERA</a></td></tr>
<tr id="_nav-model-table8" class="tc-row tc-data-row " caption="ALMERA (EL)" ident="N15_088_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=N15_088_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=N15_088_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">N15</td><td class="model caption tc-rcell"><a class="unlink">ALMERA</a></td></tr>
<tr id="_nav-model-table9" class="tc-row tc-data-row " caption="ALMERA JPN MAKE (EL)" ident="N16_298_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=N16_298_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=N16_298_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">N16</td><td class="model caption tc-rcell"><a class="unlink">ALMERA JPN MAKE</a></td></tr>
<tr id="_nav-model-table10" class="tc-row tc-data-row " caption="ALMERA TINO (EL)" ident="V10M_302_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=V10M_302_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=V10M_302_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">V10M</td><td class="model caption tc-rcell"><a class="unlink">ALMERA TINO</a></td></tr>
<tr id="_nav-model-table11" class="tc-row tc-data-row " caption="ALMERA UK MAKE (EL)" ident="N16E_307_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=N16E_307_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=N16E_307_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">N16E</td><td class="model caption tc-rcell"><a class="unlink">ALMERA UK MAKE</a></td></tr>
<tr id="_nav-model-table12" class="tc-row tc-data-row " caption="ALTIMA (EL)" ident="L33_138_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=L33_138_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=L33_138_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">L33</td><td class="model caption tc-rcell"><a class="unlink">ALTIMA</a></td></tr>
<tr id="_nav-model-table13" class="tc-row tc-data-row " caption="ALTIMA (EL)" ident="L34_155_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=L34_155_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=L34_155_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">L34</td><td class="model caption tc-rcell"><a class="unlink">ALTIMA</a></td></tr>
<tr id="_nav-model-table14" class="tc-row tc-data-row " caption="ARIYA (EL)" ident="FE0_158_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=FE0_158_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=FE0_158_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">FE0</td><td class="model caption tc-rcell"><a class="unlink">ARIYA</a></td></tr>
<tr id="_nav-model-table15" class="tc-row tc-data-row " caption="ATLEON (EL)" ident="TK3_059_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=TK3_059_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=TK3_059_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">TK3</td><td class="model caption tc-rcell"><a class="unlink">ATLEON</a></td></tr>
<tr id="_nav-model-table16" class="tc-row tc-data-row " caption="CABSTAR (EL)" ident="F24M_021_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=F24M_021_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=F24M_021_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">F24M</td><td class="model caption tc-rcell"><a class="unlink">CABSTAR</a></td></tr>
<tr id="_nav-model-table17" class="tc-row tc-data-row " caption="CUBE (EL)" ident="Z12_047_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=Z12_047_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=Z12_047_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Z12</td><td class="model caption tc-rcell"><a class="unlink">CUBE</a></td></tr>
<tr id="_nav-model-table18" class="tc-row tc-data-row " caption="DATSUN MI-DO (EL)" ident="HBD0R_149_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=HBD0R_149_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=HBD0R_149_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">HBD0R</td><td class="model caption tc-rcell"><a class="unlink">DATSUN MI-DO</a></td></tr>
<tr id="_nav-model-table19" class="tc-row tc-data-row " caption="DATSUN MI-DO (EL)" ident="HBD0_129_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=HBD0_129_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=HBD0_129_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">HBD0</td><td class="model caption tc-rcell"><a class="unlink">DATSUN MI-DO</a></td></tr>
<tr id="_nav-model-table20" class="tc-row tc-data-row " caption="DATSUN ON-DO (EL)" ident="BD0R_148_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=BD0R_148_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=BD0R_148_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">BD0R</td><td class="model caption tc-rcell"><a class="unlink">DATSUN ON-DO</a></td></tr>
<tr id="_nav-model-table21" class="tc-row tc-data-row " caption="DATSUN ON-DO (EL)" ident="BD0_121_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=BD0_121_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=BD0_121_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">BD0</td><td class="model caption tc-rcell"><a class="unlink">DATSUN ON-DO</a></td></tr>
<tr id="_nav-model-table22" class="tc-row tc-data-row " caption="E-NV200 SPAIN (EL)" ident="ME0M_122_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=ME0M_122_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=ME0M_122_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">ME0M</td><td class="model caption tc-rcell"><a class="unlink">E-NV200 SPAIN</a></td></tr>
<tr id="_nav-model-table23" class="tc-row tc-data-row " caption="GT-R (EL)" ident="R35_040_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=R35_040_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=R35_040_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">R35</td><td class="model caption tc-rcell"><a class="unlink">GT-R</a></td></tr>
<tr id="_nav-model-table24" class="tc-row tc-data-row " caption="HARDBODY (EL)" ident="D22S_014_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=D22S_014_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=D22S_014_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">D22S</td><td class="model caption tc-rcell"><a class="unlink">HARDBODY</a></td></tr>
<tr id="_nav-model-table25" class="tc-row tc-data-row " caption="INTERSTAR (EL)" ident="X70_EL" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=X70_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=X70_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">X70</td><td class="model caption tc-rcell"><a class="unlink">INTERSTAR</a></td></tr>
<tr id="_nav-model-table26" class="tc-row tc-data-row " caption="INTERSTAR (EL)" ident="XDD_EL" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=XDD_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=XDD_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">XDD</td><td class="model caption tc-rcell"><a class="unlink">INTERSTAR</a></td></tr>
<tr id="_nav-model-table27" class="tc-row tc-data-row " caption="INTERSTAR (EL)" ident="XDE_EL" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=XDE_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=XDE_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">XDE</td><td class="model caption tc-rcell"><a class="unlink">INTERSTAR</a></td></tr>
<tr id="_nav-model-table28" class="tc-row tc-data-row " caption="JUKE (EL)" ident="F16E_157_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=F16E_157_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=F16E_157_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">F16E</td><td class="model caption tc-rcell"><a class="unlink">JUKE</a></td></tr>
<tr id="_nav-model-table29" class="tc-row tc-data-row " caption="JUKE JPN MAKE (EL)" ident="F15_052_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=F15_052_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=F15_052_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">F15</td><td class="model caption tc-rcell"><a class="unlink">JUKE JPN MAKE</a></td></tr>
<tr id="_nav-model-table30" class="tc-row tc-data-row " caption="JUKE UK MAKE (EL)" ident="F15E_056_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=F15E_056_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=F15E_056_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">F15E</td><td class="model caption tc-rcell"><a class="unlink">JUKE UK MAKE</a></td></tr>
<tr id="_nav-model-table31" class="tc-row tc-data-row " caption="KING CAB (EL)" ident="D22_073_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=D22_073_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=D22_073_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">D22</td><td class="model caption tc-rcell"><a class="unlink">KING CAB</a></td></tr>
<tr id="_nav-model-table32" class="tc-row tc-data-row " caption="KING CAB (EL)" ident="LCD22_030_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=LCD22_030_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=LCD22_030_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">LCD22</td><td class="model caption tc-rcell"><a class="unlink">KING CAB</a></td></tr>
<tr id="_nav-model-table33" class="tc-row tc-data-row " caption="KUBISTAR (EL)" ident="X76_EL" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=X76_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=X76_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">X76</td><td class="model caption tc-rcell"><a class="unlink">KUBISTAR</a></td></tr>
<tr id="_nav-model-table34" class="tc-row tc-data-row " caption="LEAF (EL)" ident="ZE0_054_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=ZE0_054_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=ZE0_054_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">ZE0</td><td class="model caption tc-rcell"><a class="unlink">LEAF</a></td></tr>
<tr id="_nav-model-table35" class="tc-row tc-data-row " caption="LEAF UK MAKE (EL)" ident="ZE0E_111_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=ZE0E_111_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=ZE0E_111_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">ZE0E</td><td class="model caption tc-rcell"><a class="unlink">LEAF UK MAKE</a></td></tr>
<tr id="_nav-model-table36" class="tc-row tc-data-row " caption="LEAF UK MAKE (EL)" ident="ZE1E_152_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=ZE1E_152_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=ZE1E_152_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">ZE1E</td><td class="model caption tc-rcell"><a class="unlink">LEAF UK MAKE</a></td></tr>
<tr id="_nav-model-table37" class="tc-row tc-data-row " caption="LEAF UK MAKE (EL)" ident="ZE2E_166_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=ZE2E_166_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=ZE2E_166_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">ZE2E</td><td class="model caption tc-rcell"><a class="unlink">LEAF UK MAKE</a></td></tr>
<tr id="_nav-model-table38" class="tc-row tc-data-row " caption="MAXIMA (EL)" ident="A32_065_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=A32_065_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=A32_065_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">A32</td><td class="model caption tc-rcell"><a class="unlink">MAXIMA</a></td></tr>
<tr id="_nav-model-table39" class="tc-row tc-data-row " caption="MAXIMA (EL)" ident="A36_131_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=A36_131_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=A36_131_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">A36</td><td class="model caption tc-rcell"><a class="unlink">MAXIMA</a></td></tr>
<tr id="_nav-model-table40" class="tc-row tc-data-row " caption="MAXIMA (EL)" ident="CA33_305_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=CA33_305_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=CA33_305_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">CA33</td><td class="model caption tc-rcell"><a class="unlink">MAXIMA</a></td></tr>
<tr id="_nav-model-table41" class="tc-row tc-data-row " caption="MICRA (EL)" ident="K11E_081_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=K11E_081_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=K11E_081_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">K11E</td><td class="model caption tc-rcell"><a class="unlink">MICRA</a></td></tr>
<tr id="_nav-model-table42" class="tc-row tc-data-row " caption="MICRA (EL)" ident="K12E_005_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=K12E_005_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=K12E_005_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">K12E</td><td class="model caption tc-rcell"><a class="unlink">MICRA</a></td></tr>
<tr id="_nav-model-table43" class="tc-row tc-data-row " caption="MICRA (EL)" ident="K15_EL" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=K15_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=K15_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">K15</td><td class="model caption tc-rcell"><a class="unlink">MICRA</a></td></tr>
<tr id="_nav-model-table44" class="tc-row tc-data-row " caption="MICRA C+C (EL)" ident="CK12E_012_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=CK12E_012_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=CK12E_012_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">CK12E</td><td class="model caption tc-rcell"><a class="unlink">MICRA C+C</a></td></tr>
<tr id="_nav-model-table45" class="tc-row tc-data-row " caption="MICRA FLIN MAKE (EL)" ident="K14FR_144_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=K14FR_144_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=K14FR_144_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">K14FR</td><td class="model caption tc-rcell"><a class="unlink">MICRA FLIN MAKE</a></td></tr>
<tr id="_nav-model-table46" class="tc-row tc-data-row " caption="MICRA IND MAKE (EL)" ident="K13KK_136_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=K13KK_136_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=K13KK_136_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">K13KK</td><td class="model caption tc-rcell"><a class="unlink">MICRA IND MAKE</a></td></tr>
<tr id="_nav-model-table47" class="tc-row tc-data-row " caption="MICRA IND MAKE (EL)" ident="K13K_051_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=K13K_051_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=K13K_051_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">K13K</td><td class="model caption tc-rcell"><a class="unlink">MICRA IND MAKE</a></td></tr>
<tr id="_nav-model-table48" class="tc-row tc-data-row " caption="MURANO (EL)" ident="Z50_008_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=Z50_008_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=Z50_008_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Z50</td><td class="model caption tc-rcell"><a class="unlink">MURANO</a></td></tr>
<tr id="_nav-model-table49" class="tc-row tc-data-row " caption="MURANO (EL)" ident="Z51_036_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=Z51_036_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=Z51_036_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Z51</td><td class="model caption tc-rcell"><a class="unlink">MURANO</a></td></tr>
<tr id="_nav-model-table50" class="tc-row tc-data-row " caption="MURANO RUS MAKE (EL)" ident="Z51R_057_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=Z51R_057_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=Z51R_057_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Z51R</td><td class="model caption tc-rcell"><a class="unlink">MURANO RUS MAKE</a></td></tr>
<tr id="_nav-model-table51" class="tc-row tc-data-row " caption="MURANO RUS MAKE (EL)" ident="Z52R_137_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=Z52R_137_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=Z52R_137_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Z52R</td><td class="model caption tc-rcell"><a class="unlink">MURANO RUS MAKE</a></td></tr>
<tr id="_nav-model-table52" class="tc-row tc-data-row " caption="NAVARA (EL)" ident="D40M_010_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=D40M_010_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=D40M_010_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">D40M</td><td class="model caption tc-rcell"><a class="unlink">NAVARA</a></td></tr>
<tr id="_nav-model-table53" class="tc-row tc-data-row " caption="NAVARA NP300 (EL)" ident="D23M_135_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=D23M_135_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=D23M_135_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">D23M</td><td class="model caption tc-rcell"><a class="unlink">NAVARA NP300</a></td></tr>
<tr id="_nav-model-table54" class="tc-row tc-data-row " caption="NOTE UK MAKE (EL)" ident="E11E_015_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=E11E_015_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=E11E_015_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">E11E</td><td class="model caption tc-rcell"><a class="unlink">NOTE UK MAKE</a></td></tr>
<tr id="_nav-model-table55" class="tc-row tc-data-row " caption="NOTE UK MAKE (EL)" ident="E12E_113_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=E12E_113_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=E12E_113_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">E12E</td><td class="model caption tc-rcell"><a class="unlink">NOTE UK MAKE</a></td></tr>
<tr id="_nav-model-table56" class="tc-row tc-data-row " caption="NP300 SAF MAKE (EL)" ident="D22SS_055_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=D22SS_055_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=D22SS_055_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">D22SS</td><td class="model caption tc-rcell"><a class="unlink">NP300 SAF MAKE</a></td></tr>
<tr id="_nav-model-table57" class="tc-row tc-data-row " caption="NV200 (EL)" ident="M20_046_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=M20_046_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=M20_046_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">M20</td><td class="model caption tc-rcell"><a class="unlink">NV200</a></td></tr>
<tr id="_nav-model-table58" class="tc-row tc-data-row " caption="NV200 SPAINMAKE (EL)" ident="M20M_050_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=M20M_050_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=M20M_050_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">M20M</td><td class="model caption tc-rcell"><a class="unlink">NV200 SPAINMAKE</a></td></tr>
<tr id="_nav-model-table59" class="tc-row tc-data-row " caption="NV250 (EL)" ident="X61_EL" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=X61_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=X61_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">X61</td><td class="model caption tc-rcell"><a class="unlink">NV250</a></td></tr>
<tr id="_nav-model-table60" class="tc-row tc-data-row " caption="NV300/PRIMASTAR (EL)" ident="X82_EL" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=X82_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=X82_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">X82</td><td class="model caption tc-rcell"><a class="unlink">NV300/PRIMASTAR</a></td></tr>
<tr id="_nav-model-table61" class="tc-row tc-data-row " caption="NV400 (EL)" ident="X62_EL" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=X62_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=X62_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">X62</td><td class="model caption tc-rcell"><a class="unlink">NV400</a></td></tr>
<tr id="_nav-model-table62" class="tc-row tc-data-row " caption="NV400/INTERSTAR (EL)" ident="X62B_EL" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=X62B_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=X62B_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">X62B</td><td class="model caption tc-rcell"><a class="unlink">NV400/INTERSTAR</a></td></tr>
<tr id="_nav-model-table63" class="tc-row tc-data-row " caption="PATHFINDER (EL)" ident="R51M_009_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=R51M_009_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=R51M_009_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">R51M</td><td class="model caption tc-rcell"><a class="unlink">PATHFINDER</a></td></tr>
<tr id="_nav-model-table64" class="tc-row tc-data-row " caption="PATHFINDER (EL)" ident="R52R_123_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=R52R_123_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=R52R_123_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">R52R</td><td class="model caption tc-rcell"><a class="unlink">PATHFINDER</a></td></tr>
<tr id="_nav-model-table65" class="tc-row tc-data-row " caption="PATHFINDER (EL)" ident="R53_159_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=R53_159_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=R53_159_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">R53</td><td class="model caption tc-rcell"><a class="unlink">PATHFINDER</a></td></tr>
<tr id="_nav-model-table66" class="tc-row tc-data-row " caption="PATHFINDER RUS (EL)" ident="R52RR_132_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=R52RR_132_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=R52RR_132_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">R52RR</td><td class="model caption tc-rcell"><a class="unlink">PATHFINDER RUS</a></td></tr>
<tr id="_nav-model-table67" class="tc-row tc-data-row " caption="PATROL (EL)" ident="Y62_048_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=Y62_048_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=Y62_048_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Y62</td><td class="model caption tc-rcell"><a class="unlink">PATROL</a></td></tr>
<tr id="_nav-model-table68" class="tc-row tc-data-row " caption="PATROL(GR) (EL)" ident="Y61_107_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=Y61_107_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=Y61_107_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Y61</td><td class="model caption tc-rcell"><a class="unlink">PATROL(GR)</a></td></tr>
<tr id="_nav-model-table69" class="tc-row tc-data-row " caption="PIXO (EL)" ident="UA0_043_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=UA0_043_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=UA0_043_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">UA0</td><td class="model caption tc-rcell"><a class="unlink">PIXO</a></td></tr>
<tr id="_nav-model-table70" class="tc-row tc-data-row " caption="PRIMASTAR (EL)" ident="X83_EL" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=X83_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=X83_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">X83</td><td class="model caption tc-rcell"><a class="unlink">PRIMASTAR</a></td></tr>
<tr id="_nav-model-table71" class="tc-row tc-data-row " caption="PRIMERA (EL)" ident="P11E_090_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=P11E_090_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=P11E_090_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">P11E</td><td class="model caption tc-rcell"><a class="unlink">PRIMERA</a></td></tr>
<tr id="_nav-model-table72" class="tc-row tc-data-row " caption="PRIMERA (EL)" ident="P12E_001_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=P12E_001_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=P12E_001_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">P12E</td><td class="model caption tc-rcell"><a class="unlink">PRIMERA</a></td></tr>
<tr id="_nav-model-table73" class="tc-row tc-data-row " caption="PRIMERA WAGON (EL)" ident="W10_102_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=W10_102_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=W10_102_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">W10</td><td class="model caption tc-rcell"><a class="unlink">PRIMERA WAGON</a></td></tr>
<tr id="_nav-model-table74" class="tc-row tc-data-row " caption="PRIMERA WAGON (EL)" ident="WP11E_104_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=WP11E_104_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=WP11E_104_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">WP11E</td><td class="model caption tc-rcell"><a class="unlink">PRIMERA WAGON</a></td></tr>
<tr id="_nav-model-table75" class="tc-row tc-data-row " caption="PULSAR (EL)" ident="C13M_124_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=C13M_124_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=C13M_124_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">C13M</td><td class="model caption tc-rcell"><a class="unlink">PULSAR</a></td></tr>
<tr id="_nav-model-table76" class="tc-row tc-data-row " caption="QASHQAI (EL)" ident="J10E_023_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=J10E_023_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=J10E_023_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">J10E</td><td class="model caption tc-rcell"><a class="unlink">QASHQAI</a></td></tr>
<tr id="_nav-model-table77" class="tc-row tc-data-row " caption="QASHQAI (EL)" ident="J12E_160_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=J12E_160_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=J12E_160_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">J12E</td><td class="model caption tc-rcell"><a class="unlink">QASHQAI</a></td></tr>
<tr id="_nav-model-table78" class="tc-row tc-data-row " caption="QASHQAI RUSSIA (EL)" ident="J11R_134_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=J11R_134_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=J11R_134_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">J11R</td><td class="model caption tc-rcell"><a class="unlink">QASHQAI RUSSIA</a></td></tr>
<tr id="_nav-model-table79" class="tc-row tc-data-row " caption="QASHQAI UK MAKE (EL)" ident="J11E_120_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=J11E_120_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=J11E_120_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">J11E</td><td class="model caption tc-rcell"><a class="unlink">QASHQAI UK MAKE</a></td></tr>
<tr id="_nav-model-table80" class="tc-row tc-data-row " caption="QASHQAI+2 (EL)" ident="JJ10E_038_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=JJ10E_038_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=JJ10E_038_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">JJ10E</td><td class="model caption tc-rcell"><a class="unlink">QASHQAI+2</a></td></tr>
<tr id="_nav-model-table81" class="tc-row tc-data-row " caption="SENTRA RUS MAKE (EL)" ident="B17RR_126_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=B17RR_126_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=B17RR_126_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">B17RR</td><td class="model caption tc-rcell"><a class="unlink">SENTRA RUS MAKE</a></td></tr>
<tr id="_nav-model-table82" class="tc-row tc-data-row " caption="SENTRA RUS MAKE (EL)" ident="B17R_125_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=B17R_125_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=B17R_125_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">B17R</td><td class="model caption tc-rcell"><a class="unlink">SENTRA RUS MAKE</a></td></tr>
<tr id="_nav-model-table83" class="tc-row tc-data-row " caption="SERENA (EL)" ident="C23M_070_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=C23M_070_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=C23M_070_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">C23M</td><td class="model caption tc-rcell"><a class="unlink">SERENA</a></td></tr>
<tr id="_nav-model-table84" class="tc-row tc-data-row " caption="SUNNY WAGON (EL)" ident="Y10_105_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=Y10_105_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=Y10_105_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Y10</td><td class="model caption tc-rcell"><a class="unlink">SUNNY WAGON</a></td></tr>
<tr id="_nav-model-table85" class="tc-row tc-data-row " caption="TEANA (EL)" ident="J31_017_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=J31_017_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=J31_017_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">J31</td><td class="model caption tc-rcell"><a class="unlink">TEANA</a></td></tr>
<tr id="_nav-model-table86" class="tc-row tc-data-row " caption="TEANA (EL)" ident="J32_031_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=J32_031_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=J32_031_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">J32</td><td class="model caption tc-rcell"><a class="unlink">TEANA</a></td></tr>
<tr id="_nav-model-table87" class="tc-row tc-data-row " caption="TEANA RUS MAKE (EL)" ident="J32R_041_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=J32R_041_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=J32R_041_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">J32R</td><td class="model caption tc-rcell"><a class="unlink">TEANA RUS MAKE</a></td></tr>
<tr id="_nav-model-table88" class="tc-row tc-data-row " caption="TEANA RUS MAKE (EL)" ident="L33R_118_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=L33R_118_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=L33R_118_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">L33R</td><td class="model caption tc-rcell"><a class="unlink">TEANA RUS MAKE</a></td></tr>
<tr id="_nav-model-table89" class="tc-row tc-data-row " caption="TERRANO (EL)" ident="D10_EL" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=D10_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=D10_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">D10</td><td class="model caption tc-rcell"><a class="unlink">TERRANO</a></td></tr>
<tr id="_nav-model-table90" class="tc-row tc-data-row " caption="TERRANO (EL)" ident="R50_092_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=R50_092_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=R50_092_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">R50</td><td class="model caption tc-rcell"><a class="unlink">TERRANO</a></td></tr>
<tr id="_nav-model-table91" class="tc-row tc-data-row " caption="TERRANO2 (EL)" ident="R20_091_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=R20_091_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=R20_091_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">R20</td><td class="model caption tc-rcell"><a class="unlink">TERRANO2</a></td></tr>
<tr id="_nav-model-table92" class="tc-row tc-data-row " caption="TIIDA (EL)" ident="C11X_028_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=C11X_028_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=C11X_028_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">C11X</td><td class="model caption tc-rcell"><a class="unlink">TIIDA</a></td></tr>
<tr id="_nav-model-table93" class="tc-row tc-data-row " caption="TIIDA RUS MAKE (EL)" ident="C13R_128_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=C13R_128_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=C13R_128_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">C13R</td><td class="model caption tc-rcell"><a class="unlink">TIIDA RUS MAKE</a></td></tr>
<tr id="_nav-model-table94" class="tc-row tc-data-row " caption="TIIDA SEDAN (EL)" ident="SC11X_024_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=SC11X_024_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=SC11X_024_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">SC11X</td><td class="model caption tc-rcell"><a class="unlink">TIIDA SEDAN</a></td></tr>
<tr id="_nav-model-table95" class="tc-row tc-data-row " caption="TOWNSTAR/e-TOWNSTAR (EL)" ident="XFK_EL" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=XFK_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=XFK_EL&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">XFK</td><td class="model caption tc-rcell"><a class="unlink">TOWNSTAR/e-TOWNSTAR</a></td></tr>
<tr id="_nav-model-table96" class="tc-row tc-data-row " caption="X-TRAIL (EL)" ident="AGT32_151_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=AGT32_151_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=AGT32_151_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">AGT32</td><td class="model caption tc-rcell"><a class="unlink">X-TRAIL</a></td></tr>
<tr id="_nav-model-table97" class="tc-row tc-data-row " caption="X-TRAIL (EL)" ident="T30_002_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=T30_002_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=T30_002_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">T30</td><td class="model caption tc-rcell"><a class="unlink">X-TRAIL</a></td></tr>
<tr id="_nav-model-table98" class="tc-row tc-data-row " caption="X-TRAIL (EL)" ident="T32_117_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=T32_117_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=T32_117_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">T32</td><td class="model caption tc-rcell"><a class="unlink">X-TRAIL</a></td></tr>
<tr id="_nav-model-table99" class="tc-row tc-data-row " caption="X-TRAIL (EL)" ident="T33_163_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=T33_163_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=T33_163_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">T33</td><td class="model caption tc-rcell"><a class="unlink">X-TRAIL</a></td></tr>
<tr id="_nav-model-table100" class="tc-row tc-data-row " caption="X-TRAIL JPNMAKE (EL)" ident="T31_026_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=T31_026_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=T31_026_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">T31</td><td class="model caption tc-rcell"><a class="unlink">X-TRAIL JPNMAKE</a></td></tr>
<tr id="_nav-model-table101" class="tc-row tc-data-row " caption="X-TRAIL RUSMAKE (EL)" ident="T31R_045_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=T31R_045_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=T31R_045_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">T31R</td><td class="model caption tc-rcell"><a class="unlink">X-TRAIL RUSMAKE</a></td></tr>
<tr id="_nav-model-table102" class="tc-row tc-data-row " caption="X-TRAIL RUSMAKE (EL)" ident="T32RR_140_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=T32RR_140_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=T32RR_140_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">T32RR</td><td class="model caption tc-rcell"><a class="unlink">X-TRAIL RUSMAKE</a></td></tr>
<tr id="_nav-model-table103" class="tc-row tc-data-row " caption="X-TRAIL RUSMAKE (EL)" ident="T32R_127_3" urltype="INTERN" url="vehicle.action?lang=en&amp;localMarketOnly=true&amp;model=T32R_127_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&amp;localMarketOnly=true&amp;model=T32R_127_3&amp;spec=e30%3D&amp;startup=false&amp;mode=A0LW0DEDE&amp;upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">T32R</td><td class="model caption tc-rcell"><a class="unlink">X-TRAIL RUSMAKE</a></td></tr>
</table></body></html>

View File

@@ -0,0 +1,38 @@
<html><head>
<title>Opel W0LPD5EC9EG058575: P10 - ASTRA-J [2010-2020] - partslink24</title>
<script>
</script></head><body>
<table>
<tr id="_nav-mainGroup-table0" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=394&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="BODY SHELL AND PANELS" ident="394" maingroupcode="A"><td class="mainGroupCode tc-lcell ">A</td><td class="caption tc-rcell ">BODY SHELL AND PANELS</td></tr>
<tr id="_nav-mainGroup-table1" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=395&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="BODY EXTERIOR FITTINGS" ident="395" maingroupcode="B"><td class="mainGroupCode tc-lcell ">B</td><td class="caption tc-rcell ">BODY EXTERIOR FITTINGS</td></tr>
<tr id="_nav-mainGroup-table2" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=396&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="BODY INTERIOR FITTINGS" ident="396" maingroupcode="C"><td class="mainGroupCode tc-lcell ">C</td><td class="caption tc-rcell ">BODY INTERIOR FITTINGS</td></tr>
<tr id="_nav-mainGroup-table3" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=397&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="BODY INTERIOR TRIM" ident="397" maingroupcode="D"><td class="mainGroupCode tc-lcell ">D</td><td class="caption tc-rcell ">BODY INTERIOR TRIM</td></tr>
<tr id="_nav-mainGroup-table4" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=398&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="ENGINE AND CLUTCH" ident="398" maingroupcode="E"><td class="mainGroupCode tc-lcell ">E</td><td class="caption tc-rcell ">ENGINE AND CLUTCH</td></tr>
<tr id="_nav-mainGroup-table5" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=399&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="COOLING" ident="399" maingroupcode="F"><td class="mainGroupCode tc-lcell ">F</td><td class="caption tc-rcell ">COOLING</td></tr>
<tr id="_nav-mainGroup-table6" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=400&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="FUEL AND EXHAUST" ident="400" maingroupcode="G"><td class="mainGroupCode tc-lcell ">G</td><td class="caption tc-rcell ">FUEL AND EXHAUST</td></tr>
<tr id="_nav-mainGroup-table7" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=401&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="TRANSMISSION" ident="401" maingroupcode="H"><td class="mainGroupCode tc-lcell ">H</td><td class="caption tc-rcell ">TRANSMISSION</td></tr>
<tr id="_nav-mainGroup-table8" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=402&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="BRAKES" ident="402" maingroupcode="J"><td class="mainGroupCode tc-lcell ">J</td><td class="caption tc-rcell ">BRAKES</td></tr>
<tr id="_nav-mainGroup-table9" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=403&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="FRONT AXLE AND SUSPENSION" ident="403" maingroupcode="K"><td class="mainGroupCode tc-lcell ">K</td><td class="caption tc-rcell ">FRONT AXLE AND SUSPENSION</td></tr>
<tr id="_nav-mainGroup-table10" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=404&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="STEERING" ident="404" maingroupcode="L"><td class="mainGroupCode tc-lcell ">L</td><td class="caption tc-rcell ">STEERING</td></tr>
<tr id="_nav-mainGroup-table11" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=405&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="REAR AXLE AND SUSPENSION" ident="405" maingroupcode="M"><td class="mainGroupCode tc-lcell ">M</td><td class="caption tc-rcell ">REAR AXLE AND SUSPENSION</td></tr>
<tr id="_nav-mainGroup-table12" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=406&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="ROAD WHEELS" ident="406" maingroupcode="N"><td class="mainGroupCode tc-lcell ">N</td><td class="caption tc-rcell ">ROAD WHEELS</td></tr>
<tr id="_nav-mainGroup-table13" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=407&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="ELECTRICAL" ident="407" maingroupcode="P"><td class="mainGroupCode tc-lcell ">P</td><td class="caption tc-rcell ">ELECTRICAL</td></tr>
<tr id="_nav-mainGroup-table14" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=408&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="ACCESSORIES" ident="408" maingroupcode="Q"><td class="mainGroupCode tc-lcell ">Q</td><td class="caption tc-rcell ">ACCESSORIES</td></tr>
<tr id="_nav-mainGroup-table15" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=409&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="SPECIAL OPTIONS / DUAL FUEL" ident="409" maingroupcode="R"><td class="mainGroupCode tc-lcell ">R</td><td class="caption tc-rcell ">SPECIAL OPTIONS / DUAL FUEL</td></tr>
<tr id="_nav-mainGroup-table16" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=410&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="PAINTS" ident="410" maingroupcode="Z"><td class="mainGroupCode tc-lcell ">Z</td><td class="caption tc-rcell ">PAINTS</td></tr>
<tr id="_nav-mainGroup-table18" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=741&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="PAINTS" ident="741" maingroupcode="1"><td class="mainGroupCode tc-lcell ">1</td><td class="caption tc-rcell ">PAINTS</td></tr>
<tr id="_nav-mainGroup-table19" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=742&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="WIRING HARNESS REPAIR KITS" ident="742" maingroupcode="2"><td class="mainGroupCode tc-lcell ">2</td><td class="caption tc-rcell ">WIRING HARNESS REPAIR KITS</td></tr>
<tr id="_nav-mainGroup-table20" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=743&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="CAR CARE MATERIALS" ident="743" maingroupcode="3"><td class="mainGroupCode tc-lcell ">3</td><td class="caption tc-rcell ">CAR CARE MATERIALS</td></tr>
<tr id="_nav-mainGroup-table21" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=744&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="ADHESIVES / REPAIR KITS" ident="744" maingroupcode="4"><td class="mainGroupCode tc-lcell ">4</td><td class="caption tc-rcell ">ADHESIVES / REPAIR KITS</td></tr>
<tr id="_nav-mainGroup-table22" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=745&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="ANTI-FREEZE AND COOLANT" ident="745" maingroupcode="5"><td class="mainGroupCode tc-lcell ">5</td><td class="caption tc-rcell ">ANTI-FREEZE AND COOLANT</td></tr>
<tr id="_nav-mainGroup-table23" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=746&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="BODY REPAIR MATERIALS" ident="746" maingroupcode="6"><td class="mainGroupCode tc-lcell ">6</td><td class="caption tc-rcell ">BODY REPAIR MATERIALS</td></tr>
<tr id="_nav-mainGroup-table24" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=747&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="BRAKE AND CLUTCH FLUID" ident="747" maingroupcode="7"><td class="mainGroupCode tc-lcell ">7</td><td class="caption tc-rcell ">BRAKE AND CLUTCH FLUID</td></tr>
<tr id="_nav-mainGroup-table25" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=748&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="OIL" ident="748" maingroupcode="8"><td class="mainGroupCode tc-lcell ">8</td><td class="caption tc-rcell ">OIL</td></tr>
<tr id="_nav-mainGroup-table26" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=749&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="GREASES AND PASTES" ident="749" maingroupcode="9"><td class="mainGroupCode tc-lcell ">9</td><td class="caption tc-rcell ">GREASES AND PASTES</td></tr>
<tr id="_nav-mainGroup-table27" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=750&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="SEALANT" ident="750" maingroupcode="A"><td class="mainGroupCode tc-lcell ">A</td><td class="caption tc-rcell ">SEALANT</td></tr>
<tr id="_nav-mainGroup-table28" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=751&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="TOOLS AND PERSONAL PROTECTION" ident="751" maingroupcode="B"><td class="mainGroupCode tc-lcell ">B</td><td class="caption tc-rcell ">TOOLS AND PERSONAL PROTECTION</td></tr>
<tr id="_nav-mainGroup-table29" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=752&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="MISCELLANEOUS" ident="752" maingroupcode="C"><td class="mainGroupCode tc-lcell ">C</td><td class="caption tc-rcell ">MISCELLANEOUS</td></tr>
<tr id="_nav-mainGroup-table30" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=753&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="EMISSION CONTROL FLUID / FUEL ADDITIVES" ident="753" maingroupcode="D"><td class="mainGroupCode tc-lcell ">D</td><td class="caption tc-rcell ">EMISSION CONTROL FLUID / FUEL ADDITIVES</td></tr>
<tr id="_nav-mainGroup-table31" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&amp;lang=en&amp;mainGroupId=754&amp;openVinDialog=true&amp;startup=false&amp;subGroupId=0&amp;vin=W0LPD5EC9EG058575&amp;mode=A0LW0DEDE&amp;upds=2026.09.07+21%3A00%3A03+CEST" caption="DIAGNOSTIC DEVICES" ident="754" maingroupcode="S"><td class="mainGroupCode tc-lcell ">S</td><td class="caption tc-rcell ">DIAGNOSTIC DEVICES</td></tr>
</table></body></html>

View File

@@ -0,0 +1,198 @@
{
"link": {
"wid": "mainGroupTable",
"path": "/p5volvo/extern/groups/vin/mainGroup?lang=en&model=308&modelYear=1620&partnerGroup=46&serviceName=volvo_parts&upds=2026-08-24--11-02&vin=YV1AS84ABD1168166"
},
"segments": {
"vinfoBasic": {
"records": [
{
"values": {
"description": "Vehicle Identification No.",
"value": "YV1AS84ABD1168166"
}
},
{
"values": {
"description": "Year",
"value": "2013"
}
},
{
"values": {
"description": "Model",
"value": "S80 (07\\-)"
}
},
{
"values": {
"description": "Km/h or mph",
"value": "K"
}
},
{
"values": {
"description": "Factory code",
"value": "21"
}
},
{
"values": {
"description": "Steering gear prod no",
"value": "31360538"
}
},
{
"values": {
"description": "Structure week",
"value": "201236"
}
},
{
"values": {
"description": "Type",
"value": "S80"
}
},
{
"values": {
"description": "Chassis",
"value": "168166"
}
},
{
"values": {
"description": "Partner group",
"value": "Europe"
}
},
{
"values": {
"description": "Upholstery/interior code",
"value": "210100"
}
},
{
"values": {
"description": "Upholstery/interior",
"value": "LEATHER/ANTHR/QRTZCEIL/NV"
}
},
{
"values": {
"description": "Exterior color",
"value": "61400"
}
},
{
"values": {
"description": "Exterior color",
"value": "WHITE SOLID ICE WHITE"
}
},
{
"values": {
"description": "Body style code",
"value": "0"
}
},
{
"values": {
"description": "Body style",
"value": "Sedan"
}
},
{
"values": {
"description": "Special vehicle code",
"value": " "
}
},
{
"values": {
"description": "Special vehicles",
"value": " "
}
},
{
"values": {
"description": "Sales type",
"value": "42"
}
},
{
"values": {
"description": "Sales type",
"value": "SALES VERSION 42"
}
},
{
"values": {
"description": "Market code",
"value": "49"
}
},
{
"values": {
"description": "Market",
"value": "TR"
}
},
{
"values": {
"description": "Engine Code",
"value": "84"
}
},
{
"values": {
"description": "Engine",
"value": "D4162T"
}
},
{
"values": {
"description": "Engine part no",
"value": "6906309"
}
},
{
"values": {
"description": "Engine serial no",
"value": "00000000000004138845 / 0ELD61 2208122233587"
}
},
{
"values": {
"description": "Transmission Code",
"value": "B"
}
},
{
"values": {
"description": "Transmission",
"value": "6\\-PSHIFT 2WD / MPS6"
}
},
{
"values": {
"description": "Transmission part no",
"value": "1285041"
}
},
{
"values": {
"description": "Transmission serial no",
"value": "00AWBB1 170812170654"
}
},
{
"values": {
"description": "Chassis code",
"value": "35659B7A6276"
}
}
]
}
}
}

View File

@@ -0,0 +1,198 @@
{
"link": {
"wid": "mainGroupTable",
"path": "/p5volvo/extern/groups/vin/mainGroup?lang=tr&model=308&modelYear=1620&partnerGroup=46&serviceName=volvo_parts&upds=2026-08-24--11-02&vin=YV1AS84ABD1168166"
},
"segments": {
"vinfoBasic": {
"records": [
{
"values": {
"description": "Sasi numarasi",
"value": "YV1AS84ABD1168166"
}
},
{
"values": {
"description": "Model yili",
"value": "2013"
}
},
{
"values": {
"description": "Model",
"value": "S80 (07\\-)"
}
},
{
"values": {
"description": "Hız Birimi",
"value": "K"
}
},
{
"values": {
"description": "Fabrika Kodu",
"value": "21"
}
},
{
"values": {
"description": "Direksiyon Kutusu Üretim No",
"value": "31360538"
}
},
{
"values": {
"description": "Üretim Haftası",
"value": "201236"
}
},
{
"values": {
"description": "Türü",
"value": "S80"
}
},
{
"values": {
"description": "Şasi",
"value": "168166"
}
},
{
"values": {
"description": "Ortak grubu",
"value": "Europe"
}
},
{
"values": {
"description": "Döşeme/iç mekan kodu",
"value": "210100"
}
},
{
"values": {
"description": "Döşeme",
"value": "LEATHER/ANTHR/QRTZCEIL/NV"
}
},
{
"values": {
"description": "Dis rengi",
"value": "61400"
}
},
{
"values": {
"description": "Dis rengi",
"value": "WHITE SOLID ICE WHITE"
}
},
{
"values": {
"description": "Karoseri Tipi Kodu",
"value": "0"
}
},
{
"values": {
"description": "Kaporta Stili",
"value": "Sedan"
}
},
{
"values": {
"description": "Özel Araç Kodu",
"value": " "
}
},
{
"values": {
"description": "Özel araçlar",
"value": " "
}
},
{
"values": {
"description": "Satis tipi",
"value": "42"
}
},
{
"values": {
"description": "Satis tipi",
"value": "SALES VERSION 42"
}
},
{
"values": {
"description": "Piyasa Kodu",
"value": "49"
}
},
{
"values": {
"description": "Market",
"value": "TR"
}
},
{
"values": {
"description": "Motor kodu",
"value": "84"
}
},
{
"values": {
"description": "Motor",
"value": "D4162T"
}
},
{
"values": {
"description": "Motor Parça No",
"value": "6906309"
}
},
{
"values": {
"description": "Motor Seri Numarası",
"value": "00000000000004138845 / 0ELD61 2208122233587"
}
},
{
"values": {
"description": "Şanzıman kodu",
"value": "B"
}
},
{
"values": {
"description": "Şanzıman",
"value": "6\\-PSHIFT 2WD / MPS6"
}
},
{
"values": {
"description": "Şanzıman Parça No",
"value": "1285041"
}
},
{
"values": {
"description": "Şanzıman Seri No",
"value": "00AWBB1 170812170654"
}
},
{
"values": {
"description": "Şasi Kodu",
"value": "35659B7A6276"
}
}
]
}
}
}

View File

@@ -0,0 +1,492 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PL24AuthService } from "./pl24-auth.service";
import { PL24BudgetExceededError } from "./pl24-budget.service";
// Oturum modeli (1 login → PL24TOKEN çerezi → yalnız authorize ile yenileme),
// PL24_TR_DISABLED köprüsü ve login devre kesici. Ağ yok: global fetch stub'ı;
// private durum repo genelindeki `as unknown as` kalıbıyla okunur.
type RedisStub = {
store: Map<string, unknown>;
get: (k: string) => Promise<string | null>;
set: (k: string, v: string, ttl?: number) => Promise<void>;
exists: (k: string) => Promise<boolean>;
getJson: (k: string) => Promise<unknown>;
setJson: (k: string, v: unknown) => Promise<void>;
del: (k: string) => Promise<void>;
setNx: (k: string, v: string, ttl: number) => Promise<boolean>;
incr: (k: string) => Promise<number>;
expire: (k: string, ttl: number) => Promise<void>;
};
const makeRedis = (opts: { lockTaken?: boolean } = {}): RedisStub => {
const store = new Map<string, unknown>();
const counters = new Map<string, number>();
return {
store,
get: async (k: string) => (store.has(k) ? String(store.get(k)) : null),
set: async (k: string, v: string) => {
store.set(k, v);
},
exists: async (k: string) => store.has(k),
getJson: async (k: string) => store.get(k) ?? null,
setJson: async (k: string, v: unknown) => {
store.set(k, v);
},
del: async (k: string) => {
store.delete(k);
},
// Gerçek SET NX semantiği: var olan anahtarı ikinci kez yazmaz. Alarm
// tekrarının bastırılması buna dayanıyor.
setNx: async (k: string, v: string) => {
if (opts.lockTaken && k.includes("login-lock")) return false;
if (store.has(k)) return false;
store.set(k, v);
return true;
},
incr: async (k: string) => {
const n = (counters.get(k) ?? 0) + 1;
counters.set(k, n);
return n;
},
expire: async () => {},
};
};
/** Bütçe servisi stub'ı: sayar ama engellemez, telemetriyi yutar. */
const makeBudget = () => ({
consumed: [] as string[],
recorded: [] as unknown[],
consume: async function (kind: string) {
this.consumed.push(kind);
},
record: function (e: unknown) {
this.recorded.push(e);
},
spentToday: async () => 0,
});
const makeService = (cfgOverrides: Record<string, string> = {}, redis = makeRedis()) => {
const cfg: Record<string, string> = {
"pl24.companyCode": "tr-000000",
"pl24.username": "admin",
"pl24.password": "pw-tr",
"pl24.companyCode2": "de-000000",
"pl24.username2": "admin",
"pl24.password2": "pw-de",
"pl24.proxyDe": "http://u:p@127.0.0.1:9",
...cfgOverrides,
};
const configService = { get: (k: string, d?: unknown) => cfg[k] ?? d } as never;
const budget = makeBudget();
const telegram = {
sent: [] as string[],
isConfigured: () => true,
send: async function (t: string) {
this.sent.push(t);
return true;
},
};
return {
svc: new PL24AuthService(configService, redis as never, budget as never, telegram as never),
redis,
budget,
telegram,
};
};
type Exposed = {
effectiveAccount(account: "tr" | "de"): "tr" | "de";
login(account: "tr" | "de"): Promise<unknown>;
ensureSession(account: "tr" | "de"): Promise<{ sessionToken: string }>;
sessions: Record<string, { sessionToken: string } | undefined>;
serviceTokens: Record<"tr" | "de", Map<string, { token: string; expiresAt: number }>>;
};
/** Portal login yanıtı: {loginStatus, sessionToken} + Set-Cookie PL24TOKEN. */
const loginOk = (token = "sess-token-1") => ({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (h: string) => (h.toLowerCase() === "set-cookie" ? `PL24TOKEN=${token}; Path=/` : null),
getSetCookie: () => [`PL24TOKEN=${token}; Path=/; Secure`],
},
json: async () => ({ loginStatus: "OK", sessionToken: token }),
});
const jwt = (payload: Record<string, unknown>) =>
`h.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.s`;
const authorizeOk = (scope = "vw_parts pl24-usage", sessionStatus = "alive") => ({
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => null, getSetCookie: () => [] },
json: async () => ({
access_token: jwt({ exp: Math.floor(Date.now() / 1000) + 600, sid: "sess-token-1" }),
expires_in: 600,
scope,
session_status: sessionStatus,
}),
});
const problem = (status: number, type: string) => ({
ok: false,
status,
statusText: "Precondition Failed",
headers: { get: () => null, getSetCookie: () => [] },
json: async () => ({ type, title: type, detail: type }),
});
const savedFlag = process.env.PL24_TR_DISABLED;
beforeEach(() => {
// "" ≠ "true" → bayrak kapalı; delete yerine atama (biome noDelete).
process.env.PL24_TR_DISABLED = "";
});
afterEach(() => {
process.env.PL24_TR_DISABLED = savedFlag ?? "";
vi.unstubAllGlobals();
});
describe("PL24AuthService — PL24_TR_DISABLED tr→de köprüsü", () => {
it("bayrak kapalıyken hesaplar olduğu gibi kalır", () => {
const { svc } = makeService();
const p = svc as unknown as Exposed;
expect(p.effectiveAccount("tr")).toBe("tr");
expect(p.effectiveAccount("de")).toBe("de");
});
it("bayrak açıkken tr → de'ye maplenir, de değişmez", () => {
process.env.PL24_TR_DISABLED = "true";
const { svc } = makeService();
const p = svc as unknown as Exposed;
expect(p.effectiveAccount("tr")).toBe("de");
expect(p.effectiveAccount("de")).toBe("de");
});
it("de hesabı tanımlı değilse mapleme yapılmaz (duvara yönlendirme yok)", () => {
process.env.PL24_TR_DISABLED = "true";
const { svc } = makeService({ "pl24.companyCode2": "" });
expect((svc as unknown as Exposed).effectiveAccount("tr")).toBe("tr");
});
it("bayrak açıkken tr login'i ağa çıkmadan reddedilir", async () => {
process.env.PL24_TR_DISABLED = "true";
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { svc } = makeService();
await expect((svc as unknown as Exposed).login("tr")).rejects.toThrow(/PL24_TR_DISABLED/);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("legacy clearTokens (tr) bayrak altında de servis token'larını temizler", () => {
process.env.PL24_TR_DISABLED = "true";
const { svc } = makeService();
const p = svc as unknown as Exposed;
p.serviceTokens.de.set("vw_parts", { token: "x", expiresAt: Date.now() + 60_000 });
svc.clearTokens();
expect(p.serviceTokens.de.size).toBe(0);
});
});
describe("PL24AuthService — oturum modeli", () => {
it("bir kez login eder, sonraki çağrılar aynı oturumu kullanır (yeniden login yok)", async () => {
const fetchSpy = vi.fn().mockResolvedValue(loginOk());
vi.stubGlobal("fetch", fetchSpy);
const { svc } = makeService();
const c1 = await svc.getSessionCookieForAccount("de");
const c2 = await svc.getSessionCookieForAccount("de");
expect(c1).toBe("PL24TOKEN=sess-token-1");
expect(c2).toBe(c1);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("eşzamanlı çağrılar tek login paylaşır (single-flight)", async () => {
const fetchSpy = vi.fn().mockImplementation(async () => {
await new Promise((r) => setTimeout(r, 10));
return loginOk();
});
vi.stubGlobal("fetch", fetchSpy);
const { svc } = makeService();
await Promise.all([
svc.getSessionCookieForAccount("de"),
svc.getSessionCookieForAccount("de"),
svc.getSessionCookieForAccount("de"),
]);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("başka bir süreç oturumu tutuyorsa Redis'ten devralır, login etmez", async () => {
const redis = makeRedis();
redis.store.set("pl24:auth:session:de", {
sessionToken: "shared-token",
loginAt: Date.now(),
lastOkAt: Date.now(),
});
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { svc } = makeService({}, redis);
expect(await svc.getSessionCookieForAccount("de")).toBe("PL24TOKEN=shared-token");
expect(fetchSpy).not.toHaveBeenCalled();
});
it("login sadece çerez mint eder; servis token'ı authorize'dan gelir (Bearer'sız)", async () => {
const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
if (String(url).includes("/login")) return loginOk();
return authorizeOk();
});
vi.stubGlobal("fetch", fetchSpy);
const { svc } = makeService();
await svc.authorizeServiceForAccount("vw_parts", "de");
const authorizeCall = fetchSpy.mock.calls.find((c) => String(c[0]).includes("/authorize"));
expect(authorizeCall).toBeTruthy();
const headers = (authorizeCall?.[1] as { headers: Record<string, string> }).headers;
expect(headers.Cookie).toBe("PL24TOKEN=sess-token-1");
expect(headers.Authorization).toBeUndefined();
expect(headers["User-Agent"]).toMatch(/Chrome\/\d/);
});
it("servis token'ı süresi dolmadan yeniden authorize edilmez", async () => {
const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
if (String(url).includes("/login")) return loginOk();
return authorizeOk();
});
vi.stubGlobal("fetch", fetchSpy);
const { svc } = makeService();
await svc.authorizeServiceForAccount("vw_parts", "de");
await svc.authorizeServiceForAccount("vw_parts", "de");
const authorizeCalls = fetchSpy.mock.calls.filter((c) => String(c[0]).includes("/authorize"));
expect(authorizeCalls).toHaveLength(1);
});
it("session_status=gone → oturumu düşürür, bir kez yeniden login edip devam eder", async () => {
let authorizeCalls = 0;
const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
if (String(url).includes("/login")) return loginOk(`sess-${authorizeCalls}`);
authorizeCalls += 1;
return authorizeCalls === 1 ? authorizeOk("vw_parts", "gone") : authorizeOk();
});
vi.stubGlobal("fetch", fetchSpy);
const { svc } = makeService();
const token = await svc.authorizeServiceForAccount("vw_parts", "de");
expect(token).toBeTruthy();
expect(authorizeCalls).toBe(2);
expect(fetchSpy.mock.calls.filter((c) => String(c[0]).includes("/login"))).toHaveLength(2);
});
it("authorize 401 → oturumu düşürür ve tek sefer yeniden dener", async () => {
let authorizeCalls = 0;
const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
if (String(url).includes("/login")) return loginOk();
authorizeCalls += 1;
if (authorizeCalls === 1) {
return {
ok: false,
status: 401,
statusText: "Unauthorized",
headers: { get: () => null, getSetCookie: () => [] },
json: async () => ({}),
};
}
return authorizeOk();
});
vi.stubGlobal("fetch", fetchSpy);
const { svc } = makeService();
await expect(svc.authorizeServiceForAccount("vw_parts", "de")).resolves.toBeTruthy();
expect(authorizeCalls).toBe(2);
});
it("oturum sınırı aşıldı (412) → tek sefer squeezeOut ile tekrar dener", async () => {
const bodies: string[] = [];
let loginCalls = 0;
const fetchSpy = vi.fn().mockImplementation(async (url: string, opts: { body: string }) => {
if (!String(url).includes("/login")) return authorizeOk();
bodies.push(opts.body);
loginCalls += 1;
return loginCalls === 1
? problem(412, "urn:login:session-limit-exceeded")
: loginOk("after-squeeze");
});
vi.stubGlobal("fetch", fetchSpy);
const { svc } = makeService();
expect(await svc.getSessionCookieForAccount("de")).toBe("PL24TOKEN=after-squeeze");
expect(JSON.parse(bodies[0]).squeezeOut).toBe(false);
expect(JSON.parse(bodies[1]).squeezeOut).toBe(true);
});
it("P4 header'ı yalnız çerez taşır (authorize isteği atmaz)", async () => {
const fetchSpy = vi.fn().mockResolvedValue(loginOk());
vi.stubGlobal("fetch", fetchSpy);
const { svc } = makeService();
const headers = await svc.buildFordLegacyHeadersForAccount("opel_parts", "de");
expect(headers.Cookie).toBe("PL24TOKEN=sess-token-1");
expect(headers.Authorization).toBeUndefined();
expect(fetchSpy.mock.calls.filter((c) => String(c[0]).includes("/authorize"))).toHaveLength(0);
});
it("Ford hintstoken değeri oturum token'ıdır", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginOk("hint-1")));
const { svc } = makeService();
await svc.getSessionCookieForAccount("de");
expect(svc.getPL24TokenValueForAccount("de")).toBe("hint-1");
});
});
describe("PL24AuthService — login devre kesici", () => {
it("3 ardışık başarısız login'den sonra açılır ve yeni ağ denemesini keser", async () => {
const fetchSpy = vi.fn().mockRejectedValue(new Error("network down"));
vi.stubGlobal("fetch", fetchSpy);
const { svc } = makeService();
const p = svc as unknown as Exposed;
for (let i = 0; i < 3; i++) {
await expect(p.login("de")).rejects.toThrow(/giris hatasi/);
}
// Ağ hatasında squeezeOut denemesi yapılmaz → login başına tek fetch.
expect(fetchSpy).toHaveBeenCalledTimes(3);
await expect(p.login("de")).rejects.toThrow(/breaker open/);
expect(fetchSpy).toHaveBeenCalledTimes(3);
});
it("hesap kapalı (account-not-active) → ilk hatada uzun süreli kesici", async () => {
const fetchSpy = vi.fn().mockResolvedValue(problem(400, "urn:login:account-not-active"));
vi.stubGlobal("fetch", fetchSpy);
const { svc } = makeService();
const p = svc as unknown as Exposed;
await expect(p.login("de")).rejects.toThrow(/giris hatasi/);
await expect(p.login("de")).rejects.toThrow(/breaker open/);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("başarılı login kesici sayacını sıfırlar", async () => {
let failFirst = 2;
const fetchSpy = vi.fn().mockImplementation(async () => {
if (failFirst > 0) {
failFirst -= 1;
throw new Error("network down");
}
return loginOk();
});
vi.stubGlobal("fetch", fetchSpy);
const { svc } = makeService();
const p = svc as unknown as Exposed;
await expect(p.login("de")).rejects.toThrow();
await expect(p.login("de")).rejects.toThrow();
await expect(p.login("de")).resolves.toBeTruthy();
await expect(p.login("de")).resolves.toBeTruthy();
expect(fetchSpy).toHaveBeenCalledTimes(4);
});
it("saatlik login tavanı aşılırsa yeni login denenmez", async () => {
const fetchSpy = vi.fn().mockResolvedValue(loginOk());
vi.stubGlobal("fetch", fetchSpy);
const { svc } = makeService();
const p = svc as unknown as Exposed;
for (let i = 0; i < 6; i++) await p.login("de");
await expect(p.login("de")).rejects.toThrow(/login rate limit/);
expect(fetchSpy).toHaveBeenCalledTimes(6);
});
});
describe("PL24AuthService — 1 saatlik kesinti alarmı (Telegram)", () => {
const loginFails = () => ({
ok: false,
status: 400,
statusText: "Bad Request",
headers: { get: () => null, getSetCookie: () => [] },
json: async () => ({ type: "urn:login:account-not-active", detail: "account not active" }),
});
it("ilk saat içinde alarm göndermez", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginFails()));
const { svc, telegram } = makeService();
await expect((svc as unknown as Exposed).login("de")).rejects.toThrow();
expect(telegram.sent).toHaveLength(0);
});
it("kesinti 1 saati geçince tek sefer alarm gönderir", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginFails()));
const redis = makeRedis();
// Kesinti 70 dakika önce başlamış gibi davran (paylaşılan Redis saati).
redis.store.set("pl24:auth:fail-since:de", String(Date.now() - 70 * 60_000));
const { svc, telegram } = makeService({}, redis);
await expect((svc as unknown as Exposed).login("de")).rejects.toThrow();
await new Promise((r) => setTimeout(r, 10)); // void alarm promise'i
expect(telegram.sent).toHaveLength(1);
expect(telegram.sent[0]).toContain("PL24 giriş yapılamıyor");
expect(telegram.sent[0]).toContain("70 dakika");
// Kesici açık olsa bile ikinci kez sızlanmaz (alerted anahtarı).
await expect((svc as unknown as Exposed).login("de")).rejects.toThrow();
await new Promise((r) => setTimeout(r, 10));
expect(telegram.sent).toHaveLength(1);
});
it("giriş düzelince kurtarma mesajı gönderir ve saati sıfırlar", async () => {
const redis = makeRedis();
redis.store.set("pl24:auth:fail-since:de", String(Date.now() - 90 * 60_000));
redis.store.set("pl24:auth:alerted:de", "1");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginOk()));
const { svc, telegram } = makeService({}, redis);
await svc.getSessionCookieForAccount("de");
await new Promise((r) => setTimeout(r, 10));
expect(telegram.sent.some((t) => t.includes("PL24 girişi düzeldi"))).toBe(true);
expect(redis.store.has("pl24:auth:fail-since:de")).toBe(false);
});
});
/**
* Kendi günlük tavanımız dolduğunda bu bir GİRİŞ HATASI değildir.
*
* Prod 2026-09-21: tavan dolunca `attemptLogin` içindeki `budget.consume()`
* fırlattı, dış `catch` bunu `{ error }` düzleştirdi, `loginWithLock` giriş
* hatası sanıp devre kesiciyi tetikledi ve kesinti sayacını başlattı — bir saat
* sonra Telegram "hesap banlanmış olabilir" dedi. Hesap sağlamdı (tarayıcıdan
* giriş çalışıyordu, aynı gün 143 başarılı auth ve 0 × 401).
*/
describe("bütçe reddi ban alarmı üretmemeli", () => {
it("bütçe hatası olduğu gibi yukarı çıkar, giriş hatasına çevrilmez", async () => {
const { svc, redis, telegram, budget } = makeService();
budget.consume = async () => {
throw new PL24BudgetExceededError("user", 1205, 1200);
};
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
await expect((svc as never as Exposed).login("de")).rejects.toBeInstanceOf(
PL24BudgetExceededError,
);
// Kesinti sayacı başlamamalı → Telegram susmalı.
expect(telegram.sent).toHaveLength(0);
const failKeys = Object.keys(redis.store ?? {}).filter((k) => k.includes("fail-since"));
expect(failKeys).toHaveLength(0);
vi.unstubAllGlobals();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,166 @@
import { describe, expect, it, vi } from "vitest";
import { backfillContext } from "../../jobs/prefetch-context";
import { PL24BudgetExceededError, PL24BudgetService } from "./pl24-budget.service";
// Günlük PL24 HTTP tavanı + kullanıcı rezervi + proxy_logs telemetrisi.
// Redis ve telemetri stub'lanır; ağ yok.
const makeRedis = (opts: { fail?: boolean } = {}) => {
const counters = new Map<string, number>();
return {
counters,
incr: async (k: string) => {
if (opts.fail) throw new Error("redis down");
const n = (counters.get(k) ?? 0) + 1;
counters.set(k, n);
return n;
},
expire: async () => {},
get: async (k: string) => (counters.has(k) ? String(counters.get(k)) : null),
};
};
const makeTelemetry = () => {
const events: Record<string, unknown>[] = [];
return { events, record: (e: Record<string, unknown>) => events.push(e) };
};
const make = (env: Record<string, string> = {}, redis = makeRedis()) => {
for (const [k, v] of Object.entries(env)) vi.stubEnv(k, v);
const telemetry = makeTelemetry();
return { svc: new PL24BudgetService(redis as never, telemetry as never), redis, telemetry };
};
const inBackfill = <T>(fn: () => Promise<T>) => backfillContext.run(true, fn);
describe("PL24BudgetService — günlük tavan", () => {
it("tavan altında çağrılara izin verir", async () => {
const { svc } = make({ PL24_HTTP_DAILY_MAX: "5" });
for (let i = 0; i < 5; i++) await svc.consume("catalog");
expect(await svc.spentToday()).toBe(5);
});
it("tavan aşılınca kullanıcı çağrısını da reddeder", async () => {
const { svc } = make({ PL24_HTTP_DAILY_MAX: "3" });
for (let i = 0; i < 3; i++) await svc.consume("catalog");
await expect(svc.consume("catalog")).rejects.toBeInstanceOf(PL24BudgetExceededError);
});
it("backfill kullanıcı rezervine dokunamaz, kullanıcı trafiği devam eder", async () => {
// tavan 10, rezerv %40 → backfill 6'da durur, kullanıcı 10'a kadar sürer
const { svc } = make({ PL24_HTTP_DAILY_MAX: "10", PL24_HTTP_USER_RESERVE: "0.4" });
for (let i = 0; i < 6; i++) await inBackfill(() => svc.consume("catalog"));
await expect(inBackfill(() => svc.consume("catalog"))).rejects.toBeInstanceOf(
PL24BudgetExceededError,
);
// Aynı gün, aynı sayaç: kullanıcı hâlâ geçebilmeli (7., 8. … 10. istek)
await expect(svc.consume("catalog")).resolves.toBeUndefined();
await expect(svc.consume("catalog")).resolves.toBeUndefined();
});
it("reddedilen çağrı yalnız reddedildiği yerde sayılır (ağ isteği yok)", async () => {
const { svc, telemetry } = make({ PL24_HTTP_DAILY_MAX: "1" });
await svc.consume("catalog");
await expect(svc.consume("catalog")).rejects.toBeInstanceOf(PL24BudgetExceededError);
expect(telemetry.events).toHaveLength(0);
});
it("Redis düşükse PL24'ü bloklamaz (fail-open)", async () => {
const { svc } = make({ PL24_HTTP_DAILY_MAX: "1" }, makeRedis({ fail: true }));
await expect(svc.consume("catalog")).resolves.toBeUndefined();
await expect(svc.consume("catalog")).resolves.toBeUndefined();
});
});
describe("PL24BudgetService — telemetri", () => {
it("katalog çağrısını proxy_logs biçiminde kaydeder", () => {
const { svc, telemetry } = make();
svc.record({
kind: "catalog",
url: "https://www.partslink24.com/p5psa/extern/group/vin/scope?vin=X",
proxied: true,
account: "de",
statusCode: 200,
success: true,
startedAt: Date.now() - 120,
});
const e = telemetry.events[0];
expect(e.service).toBe("pl24_http");
expect(e.provider).toBe("dataimpulse");
expect(e.targetHost).toBe("www.partslink24.com");
expect(e.success).toBe(true);
expect(e.errorKind).toBeNull();
});
it("login çağrısını pl24_auth olarak ayırır", () => {
const { svc, telemetry } = make();
svc.record({
kind: "auth",
url: "https://www.partslink24.com/auth/ext/api/1.1/login",
proxied: false,
account: "tr",
statusCode: 400,
success: false,
startedAt: Date.now(),
});
expect(telemetry.events[0].service).toBe("pl24_auth");
expect(telemetry.events[0].provider).toBe("none");
});
it("403/429'u ban sinyali olarak işaretler", () => {
const { svc, telemetry } = make();
svc.record({
kind: "catalog",
url: "https://www.partslink24.com/x",
proxied: true,
account: "de",
statusCode: 403,
success: false,
startedAt: Date.now(),
});
expect(telemetry.events[0].errorKind).toBe("banned");
});
it("taşıma hatasını sınıflandırır", () => {
const { svc, telemetry } = make();
const err = Object.assign(new Error("fetch failed"), { name: "TimeoutError" });
svc.record({
kind: "catalog",
url: "https://www.partslink24.com/x",
proxied: true,
account: "de",
success: false,
startedAt: Date.now(),
error: err,
});
expect(telemetry.events[0].errorKind).toBe("timeout");
expect(telemetry.events[0].statusCode).toBeNull();
});
});
/**
* Bütçe reddi bir giriş hatası DEĞİLDİR (plv2.md, bulgu auth-16).
*
* Prod 2026-09-21: günlük tavan dolunca `attemptLogin` içindeki
* `budget.consume()` fırlattı, dış `catch` bunu `{ error }` düzleştirdi,
* `loginWithLock` bunu giriş hatası sanıp devre kesiciyi tetikledi ve kesinti
* sayacını başlattı → bir saat sonra Telegram "hesap banlanmış olabilir" dedi.
* Oysa hesap sağlamdı: tarayıcıdan giriş çalışıyordu, aynı gün 143 başarılı
* auth çağrısı ve 0 × 401 vardı.
*/
describe("PL24BudgetExceededError — kendi frenimiz, upstream hatası değil", () => {
it("kendi hata sınıfını taşır, düz Error değil", () => {
const err = new PL24BudgetExceededError("user", 1205, 1200);
expect(err).toBeInstanceOf(PL24BudgetExceededError);
expect(err).toBeInstanceOf(Error);
});
it("mesajı hangi şeridin ve hangi sayının durduğunu söyler", () => {
const err = new PL24BudgetExceededError("user", 1205, 1200);
expect(err.message).toContain("1205");
expect(err.message).toContain("1200");
expect(err.message.toLowerCase()).toContain("user");
});
});

View File

@@ -0,0 +1,145 @@
import { Injectable, Logger } from "@nestjs/common";
import { isBackfillContext } from "../../jobs/prefetch-context";
import { RedisService } from "../../redis/redis.service";
import {
ProxyTelemetryService,
classifyTransportError,
isBanStatus,
} from "../proxy-telemetry/proxy-telemetry.service";
/**
* PL24 HTTP budget + telemetry.
*
* WHY (see /home/s/ss/plv2.md §2.2, findings consumers_jobs-03/07):
* Both PL24 account bans (tr 2026-07-24, de 2026-09-04) followed days that wrote
* 5-7k new category rows — roughly 10k+ upstream calls/day — while real user
* decodes never exceeded 14/day. The existing guards are all job-level
* (jobs/min, jobs/day); nothing counted actual HTTP requests, and PL24 calls
* were invisible in `proxy_logs` (only pcat/emex were logged), so the volume was
* only reconstructable after the fact from DB row counts.
*
* This service is the one choke point every PL24 upstream call passes through:
* - counts requests per UTC day in Redis and refuses new ones past the cap,
* - reserves a share of that cap for real users so a runaway backfill can
* never starve a paying customer's decode,
* - records every attempt into `proxy_logs` (service `pl24_http`/`pl24_auth`).
*
* The cap is deliberately low to start (PL24_HTTP_DAILY_MAX, default 1200);
* raise it only with telemetry in hand.
*/
@Injectable()
export class PL24BudgetService {
private readonly logger = new Logger(PL24BudgetService.name);
/** Log "budget exhausted" once per day per lane instead of on every call. */
private warnedFor = new Set<string>();
constructor(
private readonly redis: RedisService,
private readonly telemetry: ProxyTelemetryService,
) {}
private get dailyMax(): number {
const raw = Number(process.env.PL24_HTTP_DAILY_MAX);
return Number.isFinite(raw) && raw > 0 ? raw : 1200;
}
/** Fraction of the daily cap that only user-facing calls may spend. */
private get userReserve(): number {
const raw = Number(process.env.PL24_HTTP_USER_RESERVE);
return Number.isFinite(raw) && raw > 0 && raw < 1 ? raw : 0.4;
}
private dayKey(): string {
return `pl24:http:${new Date().toISOString().slice(0, 10)}`;
}
/**
* Count one upstream call and decide whether it may proceed.
* Backfill stops at (1 - userReserve) of the cap; user traffic gets the rest.
*/
async consume(kind: "auth" | "catalog"): Promise<void> {
const backfill = isBackfillContext();
let spent: number;
try {
const key = this.dayKey();
spent = await this.redis.incr(key);
if (spent === 1) await this.redis.expire(key, 8 * 86_400);
// Per-lane counters. The shared total tells us WHEN the cap was hit but not
// WHO spent it, and sizing the cap needs that split: on 2026-09-21 the
// total hit 1200 and every user decode after 16:10 UTC was refused, with
// no way to tell from `proxy_logs` how much of it was warm-up prefetch
// versus somebody waiting on a screen.
const laneKey = `${key}:${backfill ? "worker" : "user"}`;
const laneSpent = await this.redis.incr(laneKey);
if (laneSpent === 1) await this.redis.expire(laneKey, 8 * 86_400);
} catch {
return; // Redis down → never block PL24 on telemetry
}
const max = this.dailyMax;
const limit = backfill ? Math.floor(max * (1 - this.userReserve)) : max;
if (spent > limit) {
const lane = backfill ? "backfill" : "user";
if (!this.warnedFor.has(`${lane}:${this.dayKey()}`)) {
this.warnedFor.add(`${lane}:${this.dayKey()}`);
this.logger.warn(
`PL24 daily HTTP budget exhausted for the ${lane} lane (${spent}/${limit}, cap ${max}, kind=${kind}) — refusing further calls today`,
);
}
throw new PL24BudgetExceededError(lane, spent, limit);
}
}
/** Current spend (for /health and the Süper Panel). */
async spentToday(): Promise<number> {
try {
return Number((await this.redis.get(this.dayKey())) ?? 0);
} catch {
return 0;
}
}
record(opts: {
kind: "auth" | "catalog";
url: string;
proxied: boolean;
account: string;
statusCode?: number | null;
success: boolean;
startedAt: number;
error?: unknown;
}): void {
let targetHost: string | null = null;
try {
targetHost = new URL(opts.url).hostname;
} catch {
/* keep null */
}
this.telemetry.record({
service: opts.kind === "auth" ? "pl24_auth" : "pl24_http",
provider: opts.proxied ? "dataimpulse" : "none",
sessionKey: opts.account,
targetHost,
statusCode: opts.statusCode ?? null,
errorKind: opts.error
? classifyTransportError(opts.error)
: isBanStatus(opts.statusCode)
? "banned"
: null,
success: opts.success,
durationMs: Date.now() - opts.startedAt,
});
}
}
/** Thrown when the daily PL24 HTTP budget is used up for this lane. */
export class PL24BudgetExceededError extends Error {
constructor(
readonly lane: "user" | "backfill",
readonly spent: number,
readonly limit: number,
) {
super(`PL24 daily HTTP budget exhausted (${lane} lane: ${spent}/${limit})`);
this.name = "PL24BudgetExceededError";
}
}

View File

@@ -91,6 +91,20 @@ describe("PL24FordLegacyService.parseFordGroupsFromHtml — nav-crumb junk filte
expect(groups.some((g) => g.linkPath.includes("eu.nissan.biz"))).toBe(false);
});
it("appends the Hyundai illustration description to the bare figure code", () => {
// Real Hyundai row shape: first <td> is the figure code (in a link), the
// second <td> is the human description — which the parser used to drop.
const html = [
"<table>",
'<tr class="tc-data-row" url="image-board.action?mainGroup=EN&subGroup=20-201A&illustration=1&lang=tr">' +
'<td class="identifier"><a href="image-board.action?subGroup=20-201A">20-201A .1</a></td>' +
"<td>SUB ENGINE ASSY</td><td></td></tr>",
"</table>",
].join("");
const groups = pg.parseFordGroupsFromHtml(html, "hyundai_parts", "");
expect(groups[0]?.nameTr).toBe("20-201A .1 SUB ENGINE ASSY");
});
it("keeps Volvo vin-group.action?...group1=... (real category) but drops the bare breadcrumb", () => {
const html = [
'<tr class="tc-data-row" url="vin-group.action?group1=2&lang=tr" caption="Motor"><td>Motor</td></tr>',

View File

@@ -116,7 +116,7 @@ export class PL24FordLegacyService {
}
// Ford / Hyundai-Kia / Nissan / Opel / Volvo devam eder...
await this.authService.authorizeServiceForAccount(serviceName, account);
await this.authService.ensureSession(account);
const html = await this.fetchVinGroupPage(vin, serviceName, account);
if (!html) return null;
@@ -124,8 +124,8 @@ export class PL24FordLegacyService {
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
this.logger.warn(`P4 legacy: demo mode for ${serviceName}, retrying with fresh auth`);
this.authService.clearTokensForAccount(account);
await this.authService.authorizeServiceForAccount(serviceName, account);
await this.authService.dropSessionForAccount(account, "P4 page 401");
await this.authService.ensureSession(account);
const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account);
if (!retryHtml) return null;
@@ -1110,7 +1110,7 @@ export class PL24FordLegacyService {
this.logger.log(`Ford: fetching vehicle list for ${serviceName}`);
try {
await this.authService.authorizeService(serviceName);
await this.authService.ensureSession("tr");
const config = getServiceConfig(serviceName);
const basePath = config ? `${config.basePath}/${serviceName}` : `/ford/${serviceName}`;
@@ -1126,8 +1126,8 @@ export class PL24FordLegacyService {
let support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
this.logger.warn(`Ford: demo mode for ${serviceName}, retrying with fresh auth`);
this.authService.clearTokens();
await this.authService.authorizeService(serviceName);
await this.authService.dropSessionForAccount("tr", "P4 page 401");
await this.authService.ensureSession("tr");
const retryHtml = await this.fetchP4Page(vinGroupUrl, serviceName, true);
if (!retryHtml) return [];
const retrySupport = this.extractScriptVariable<FordPL24Support>(retryHtml, "PL24_SUPPORT");
@@ -1221,7 +1221,7 @@ export class PL24FordLegacyService {
this.logger.log(`HyundaiKia: fetching vehicle list for ${serviceName}`);
try {
await this.authService.authorizeService(serviceName);
await this.authService.ensureSession("tr");
const config = getServiceConfig(serviceName);
const basePath = config
@@ -1306,7 +1306,7 @@ export class PL24FordLegacyService {
this.logger.log(`Nissan: fetching vehicle list for ${serviceName}`);
try {
await this.authService.authorizeService(serviceName);
await this.authService.ensureSession("tr");
const config = getServiceConfig(serviceName);
const basePath = config ? `${config.basePath}/${serviceName}` : `/nissan/${serviceName}`;
@@ -1377,7 +1377,7 @@ export class PL24FordLegacyService {
this.logger.log(`Opel: fetching vehicle list for ${serviceName}`);
try {
await this.authService.authorizeService(serviceName);
await this.authService.ensureSession("tr");
const config = getServiceConfig(serviceName);
const basePath = config ? `${config.basePath}/${serviceName}` : `/opel/${serviceName}`;
@@ -1452,7 +1452,7 @@ export class PL24FordLegacyService {
this.logger.log(`Volvo: fetching vehicle list for ${serviceName}`);
try {
await this.authService.authorizeService(serviceName);
await this.authService.ensureSession("tr");
const config = getServiceConfig(serviceName);
const basePath = config ? `${config.basePath}/${serviceName}` : `/volvo/${serviceName}`;
@@ -1556,7 +1556,7 @@ export class PL24FordLegacyService {
this.logger.log(`Volvo: fetching model config for ${serviceName} mdl=${mdlId}`);
await this.authService.authorizeService(serviceName);
await this.authService.ensureSession("tr");
try {
const config = getServiceConfig(serviceName);
@@ -1626,7 +1626,7 @@ export class PL24FordLegacyService {
this.logger.log(`Ford: fetching model config for ${serviceName} family=${familyId}`);
await this.authService.authorizeService(serviceName);
await this.authService.ensureSession("tr");
const config = getServiceConfig(serviceName);
const arch = config?.architecture;
@@ -1785,7 +1785,7 @@ export class PL24FordLegacyService {
: `Ford: fetching main groups for ${serviceName} family=${familyId} year=${modelYear} engine=${engine} gearbox=${gearbox}`,
);
await this.authService.authorizeService(serviceName);
await this.authService.ensureSession("tr");
try {
const basePath = config ? `${config.basePath}/${serviceName}` : `/ford/${serviceName}`;
@@ -1917,6 +1917,10 @@ export class PL24FordLegacyService {
* Fiat services always use 'de'; others use round-robin via Redis.
*/
private async resolveAccount(userId?: string, serviceName?: string): Promise<"tr" | "de"> {
// PL24_TR_DISABLED=true → the tr account is inactive on PL24's side; route
// everyone to de so page fetches, proxies and cache labels all agree with the
// auth layer's tr→de mapping (PL24AuthService.effectiveAccount).
if (process.env.PL24_TR_DISABLED === "true") return "de";
// Fiat always needs de account; Hyundai/Kia/Nissan parts are licensed only on de.
if (serviceName && ["fiatp_parts", "fiatt_parts"].includes(serviceName)) {
return "de";
@@ -1953,7 +1957,7 @@ export class PL24FordLegacyService {
cacheKey: string,
account: "tr" | "de",
): Promise<PL24DecodedVehicle | null> {
await this.authService.authorizeServiceForAccount(serviceName, account);
await this.authService.ensureSession(account);
const html = await this.fetchVinGroupPage(vin, serviceName, account);
if (!html) return null;
@@ -1961,8 +1965,8 @@ export class PL24FordLegacyService {
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
this.logger.warn(`Fiat: demo mode for ${serviceName}, retrying with fresh auth`);
this.authService.clearTokensForAccount(account);
await this.authService.authorizeServiceForAccount(serviceName, account);
await this.authService.dropSessionForAccount(account, "P4 demo page (session not accepted)");
await this.authService.ensureSession(account);
const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account);
if (!retryHtml) return null;
@@ -2889,7 +2893,7 @@ export class PL24FordLegacyService {
private async initPsaSession(
serviceName: string,
): Promise<{ jsessionId: string; mode: string; upds: string } | null> {
await this.authService.authorizeService(serviceName);
await this.authService.ensureSession("tr");
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
try {
@@ -3324,7 +3328,7 @@ export class PL24FordLegacyService {
const isDeOnly = LEGACY_DE_SERVICES.has(serviceName);
const account = isDeOnly ? "de" : accountParam;
if (isDeOnly) {
await this.authService.authorizeServiceForAccount(serviceName, "de");
await this.authService.ensureSession("de");
}
// Some catalogs (Volvo vin-group.action) store hrefs relative to the catalog
// directory (e.g. "vin-group.action?group1=…"). Prefix the service basePath
@@ -3354,8 +3358,8 @@ export class PL24FordLegacyService {
if (response.status === 401) {
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
this.authService.clearTokensForAccount(account);
await this.authService.authorizeServiceForAccount(serviceName, account);
await this.authService.dropSessionForAccount(account, "P4 page 401");
await this.authService.ensureSession(account);
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(
serviceName,
account,
@@ -3388,8 +3392,11 @@ export class PL24FordLegacyService {
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
this.logger.warn(`Ford legacy: demo page for ${serviceName}, re-authing + retry`);
this.authService.clearTokensForAccount(account);
await this.authService.authorizeServiceForAccount(serviceName, account);
await this.authService.dropSessionForAccount(
account,
"P4 demo page (session not accepted)",
);
await this.authService.ensureSession(account);
return this.fetchP4Page(url, serviceName, isFullUrl, account, true);
}
}
@@ -3434,8 +3441,8 @@ export class PL24FordLegacyService {
if (response.status === 401) {
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
this.authService.clearTokensForAccount(account);
await this.authService.authorizeServiceForAccount(serviceName, account);
await this.authService.dropSessionForAccount(account, "P4 page 401");
await this.authService.ensureSession(account);
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(
serviceName,
account,
@@ -3648,7 +3655,10 @@ export class PL24FordLegacyService {
segment.match(/\bjsonurl="([^"]+)"/);
if (!urlMatch) continue;
const url = urlMatch[1];
// Row attributes are HTML-escaped (`&amp;`), so every query-param check
// below — and the stored linkPath — must work on the decoded URL. Without
// this `[?&]mainGroup=` never matched Hyundai/Kia rows.
const url = urlMatch[1].replace(/&amp;/g, "&").replace(/&#38;/g, "&");
// Skip vehicle.action rows — those are sub-model selectors, not part group links
if (url.includes("vehicle.action")) continue;
// Skip header/breadcrumb navigation links that leak into the group table as
@@ -3657,15 +3667,25 @@ export class PL24FordLegacyService {
// categories use group.action / group-detail.action / json-(main|sub)-group.action,
// none of which match these. Volvo's vin-group.action?...group1=... is kept.
if (/(portal|logout)\.action/i.test(url)) continue;
if (url.includes("vin-group.action") && !url.includes("group1=")) continue;
// Volvo's vin-group.action rows are model pickers unless they carry
// group1=; Hyundai/Kia use the SAME endpoint for real main groups but key
// them with mainGroup= (BO/CH/EL/EN/MI/TR). Keep both, drop the rest.
if (
url.includes("vin-group.action") &&
!url.includes("group1=") &&
!/[?&]mainGroup=/i.test(url)
) {
continue;
}
if (/^https?:\/\//i.test(url) && !/\.action(\?|$)/i.test(url)) continue;
if (seen.has(url)) continue;
seen.add(url);
const captionMatch = segment.match(/\bcaption="([^"]+)"/);
// Try to extract any text from <td> elements, including those with nested tags
let tdName = "";
// Collect all <td> texts (nested tags stripped). The first non-empty one
// is the primary name for most brands.
const tdPattern = /<td[^>]*>([\s\S]*?)<\/td>/g;
const tdTexts: string[] = [];
for (const tdMatch2 of segment.matchAll(tdPattern)) {
const text = tdMatch2[1]
.replace(/<[^>]+>/g, " ")
@@ -3674,10 +3694,22 @@ export class PL24FordLegacyService {
.replace(/\xa0/g, " ")
.replace(/\s+/g, " ")
.trim();
if (text.length >= 3) {
tdName = text;
break;
}
if (text.length >= 2) tdTexts.push(text);
}
let tdName = tdTexts.find((t) => t.length >= 3) ?? "";
// Hyundai/Kia illustration rows put a bare figure code in the first cell
// ("09-091 .1") and the human description in the next ("SUB ENGINE ASSY").
// When the chosen name is code-only, append the first later cell that has
// real words so the leaf is nameable (and canonically mappable via ENGINE).
// Figure code like "09-091", "20-201A .1" (optional letter suffix + " .N"),
// or a purely numeric/dotted code.
const isCodeOnly = (s: string) => {
const t = s.trim();
return /^\d{1,3}-\d{1,4}[a-z]?(\s*\.\s*\d+)?$/i.test(t) || /^[\d.\s-]+$/.test(t);
};
if (tdName && isCodeOnly(tdName)) {
const desc = tdTexts.find((t) => t !== tdName && /[a-zA-ZğüşıöçĞÜŞİÖÇ]{3,}/.test(t));
if (desc) tdName = `${tdName} ${desc}`.replace(/\s+/g, " ").trim();
}
// Caption may contain HTML-encoded content (e.g. &lt;div class="restrictionBlock"...&gt;)
@@ -4038,6 +4070,15 @@ export class PL24FordLegacyService {
return [];
});
}
// Opel/Vauxhall and Hyundai/Kia ship their main groups as `tc-data-row`
// table rows whose link lives in a `url=`/`jsonurl=` ATTRIBUTE, not in an
// <a href>. parseP4NavigationCategories only reads anchors, so these brands
// decoded with ZERO categories (prod: Opel 169/173, Hyundai 11/11, Kia 5/5)
// even though the page carries the full list. parseFordGroupsFromHtml
// already understands those rows — it simply was never called from decode.
if (categories.length === 0) {
categories = this.parseFordGroupsFromHtml(html, serviceName, "");
}
if (categories.length === 0) {
categories = this.parseP4NavigationCategories(html);
}

View File

@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PL24Service } from "./pl24.service";
/**
* Model-list entry point resolution (plv2.md, bulgu p5core-08).
*
* `BACKEND_MODEL_PATH` eksik kaldığında eski davranış yedi bilinen yolu sırayla
* denemekti: paylaşılan günlük bütçeden çağrı başına yedi isteğe kadar, ve
* listede olmayan bir yol için sessiz sıfır sonuç. Volvo tam olarak buydu —
* `/extern/vehicles/models` (çoğul "vehicles") listede yok, dolayısıyla
* Volvo/Polestar browse sıfır model tohumlayıp emekli P4 listesini sunmaya
* devam ediyordu.
*
* Otoritatif kaynak her P5 backend'inin kendi `/extern/catmeta` yanıtındaki
* `data.catalogEntryPoint.path`. Canlı doğrulama (2026-09-20):
* p5volvo → /p5volvo/extern/vehicles/models (60 model)
* p5psa → /p5psa/extern/vehicle/catalogs
*/
type Resolver = {
resolveModelPathFromCatmeta: (
base: string,
svc: string,
headers: Record<string, string>,
) => Promise<string | null>;
baseUrl: string;
language: string;
redis: {
get: (k: string) => Promise<string | null>;
set: (k: string, v: string, ttl?: number) => Promise<unknown>;
};
logger: { log: (m: string) => void; warn: (m: string) => void };
};
function makeService(opts: {
cached?: string | null;
catmeta?: unknown;
status?: number;
throws?: boolean;
}) {
const sets: Array<[string, string]> = [];
const svc = Object.create(PL24Service.prototype) as unknown as Resolver;
svc.baseUrl = "https://pl24.test";
svc.language = "tr";
svc.redis = {
get: vi.fn(async () => opts.cached ?? null),
set: vi.fn(async (k: string, v: string) => {
sets.push([k, v]);
return undefined;
}),
};
svc.logger = { log: vi.fn(), warn: vi.fn() };
const fetchMock = vi.fn(async () => {
if (opts.throws) throw new Error("network down");
return {
ok: (opts.status ?? 200) < 400,
status: opts.status ?? 200,
json: async () => opts.catmeta ?? {},
};
});
vi.stubGlobal("fetch", fetchMock);
return { svc, sets, fetchMock };
}
const volvoMeta = {
data: {
catalogEntryPoint: {
wid: "modelsTable",
path: "/p5volvo/extern/vehicles/models?lang=en&serviceName=volvo_parts",
},
},
};
describe("resolveModelPathFromCatmeta", () => {
beforeEach(() => vi.unstubAllGlobals());
it("catmeta'daki giriş noktasını backend'e göreli yola indirger", async () => {
const { svc } = makeService({ catmeta: volvoMeta });
const out = await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {});
expect(out).toBe("/extern/vehicles/models");
});
it("çözülen yolu 30 gün cache'ler", async () => {
const { svc, sets } = makeService({ catmeta: volvoMeta });
await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {});
expect(sets[0][0]).toBe("pl24:modelpath:p5volvo");
expect(sets[0][1]).toBe("/extern/vehicles/models");
});
it("cache'lenmiş yol için upstream'e hiç gitmez", async () => {
const { svc, fetchMock } = makeService({ cached: "/extern/vehicles/models" });
const out = await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {});
expect(out).toBe("/extern/vehicles/models");
expect(fetchMock).not.toHaveBeenCalled();
});
it("olumsuz sonuç da cache'lenir — her browse'da yeniden denenmez", async () => {
const { svc, fetchMock } = makeService({ cached: "none" });
expect(await svc.resolveModelPathFromCatmeta("/p5x", "x_parts", {})).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("catmeta yoksa veya şekil beklenmedikse null döner", async () => {
const { svc, sets } = makeService({ catmeta: { data: {} } });
expect(await svc.resolveModelPathFromCatmeta("/p5x", "x_parts", {})).toBeNull();
expect(sets[0][1]).toBe("none");
});
it("HTTP hatasında null döner", async () => {
const { svc } = makeService({ status: 403, catmeta: volvoMeta });
expect(await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {})).toBeNull();
});
it("ağ hatası fırlatmaz", async () => {
const { svc } = makeService({ throws: true });
await expect(
svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {}),
).resolves.toBeNull();
});
it("/extern/ ile başlamayan yolu kabul etmez", async () => {
const { svc } = makeService({
catmeta: { data: { catalogEntryPoint: { path: "https://baska.site/kotu" } } },
});
expect(await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {})).toBeNull();
});
});

View File

@@ -0,0 +1,70 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
/**
* Opel / Hyundai / Kia "0 kategori" regresyon kilidi (plv2.md, bulgu p4legacy-03).
*
* Bu markalar ana gruplarını <a href> DEĞİL, `<tr class="tc-data-row"
* url=…|jsonurl=…>` satır attribute'unda taşıyor. Decode yolu yalnız anchor
* tarayan parser'ı kullandığı için prod'da Opel 169/173, Hyundai 11/11, Kia 5/5
* araç SIFIR kategoriyle kaydedilmişti — sayfa listeyi taşıdığı hâlde.
*
* Fixture'lar 2026-09-16 canlı keşfinden (plv2-artefakt/), yalnız ana grup
* tablosuna kırpılmış hâlleri.
*/
const fixture = (name: string) =>
readFileSync(join(__dirname, "__fixtures__", `${name}.html`), "utf-8");
// parseFordGroupsFromHtml saf bir metin işleyicisi: bağımlılıklar kullanılmıyor.
const svc = new PL24FordLegacyService(
{} as never, // authService
{ get: (_k: string, d?: unknown) => d } as never, // configService
{} as never, // redis
{} as never, // storage
) as unknown as {
parseFordGroupsFromHtml(
html: string,
serviceName: string,
familyId: string,
): Array<{ code: string; nameEn: string; linkPath?: string }>;
parseP4NavigationCategories(html: string): Array<{ nameEn: string }>;
};
describe("P4 ana grup tablosu — Opel/Hyundai (0 kategori regresyonu)", () => {
it("Opel: jsonurl satırlarından 31 ana grubu çıkarır", () => {
const groups = svc.parseFordGroupsFromHtml(fixture("p4_opel"), "opel_parts", "");
expect(groups.length).toBe(31);
expect(groups[0].nameEn).toBe("BODY SHELL AND PANELS");
expect(groups[0].code).toContain("json-vin-main-group.action");
expect(groups[0].code).toContain("mainGroupId=394");
// Adların hepsi gerçek metin olmalı (kod artığı değil)
expect(groups.every((g) => /[A-Za-z]{3,}/.test(g.nameEn))).toBe(true);
});
it("Hyundai: mainGroup= anahtarlı vin-group satırları artık elenmez", () => {
const groups = svc.parseFordGroupsFromHtml(fixture("p4_hyundai"), "hyundai_parts", "");
expect(groups.length).toBe(6);
const names = groups.map((g) => g.nameEn);
expect(names).toContain("BODY");
expect(groups[0].code).toContain("mainGroup=BO");
});
it("anchor-only parser bu sayfalarda hâlâ boş döner (fallback'in gerekçesi)", () => {
expect(svc.parseP4NavigationCategories(fixture("p4_opel")).length).toBe(0);
expect(svc.parseP4NavigationCategories(fixture("p4_hyundai")).length).toBe(0);
});
it("Ford: scope satırları (sharedCatCode) bozulmadan okunur", () => {
const groups = svc.parseFordGroupsFromHtml(fixture("p4_ford"), "fordp_parts", "");
expect(groups.length).toBeGreaterThanOrEqual(6);
expect(groups.some((g) => g.code.includes("sharedCatCode=ZE"))).toBe(true);
});
it("Nissan: model seçim satırları ana grup sayılmaz (vehicle.action elenir)", () => {
const groups = svc.parseFordGroupsFromHtml(fixture("p4_nissan"), "nissan_parts", "");
expect(groups.every((g) => !g.code.includes("vehicle.action"))).toBe(true);
});
});

View File

@@ -442,31 +442,50 @@ export class PL24PsaService {
// ==================== PRIVATE: session + HTTP ====================
/**
* PSA requests must leave from the same egress as their auth account: under
* PL24_TR_DISABLED the legacy "tr" auth maps to de, whose session lives behind
* the DE proxy — the auth service picks the dispatcher (null for real tr).
*/
private async psaDispatcher(): Promise<any | null> {
return this.authService.getProxyAgent4Account("tr");
}
/**
* entry.action → startup=true (302) → 302 with mode+upds. Returns JSESSIONID/mode/upds.
*/
private async initPsaSession(serviceName: string): Promise<PsaSession | null> {
await this.authService.authorizeService(serviceName);
// P4 PSA pages authenticate off the session cookie alone — no service token.
await this.authService.ensureSession("tr");
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
const dispatcher = await this.psaDispatcher();
const withDispatcher = (o: RequestInit): RequestInit & { dispatcher?: any } =>
dispatcher ? { ...o, dispatcher } : o;
try {
const entryRes = await fetch(`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`, {
method: "GET",
headers,
redirect: "manual",
signal: AbortSignal.timeout(this.timeout),
});
const entryRes = await fetch(
`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`,
withDispatcher({
method: "GET",
headers,
redirect: "manual",
signal: AbortSignal.timeout(this.timeout),
}),
);
const jsessionId = entryRes.headers.get("set-cookie")?.match(/JSESSIONID=([^;]+)/)?.[1] || "";
const loc1 = entryRes.headers.get("location");
if (!loc1) return null;
const hdrs2 = jsessionId
? { ...headers, Cookie: `${headers.Cookie}; JSESSIONID=${jsessionId}` }
: headers;
const startupRes = await fetch(loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`, {
method: "GET",
headers: hdrs2,
redirect: "manual",
signal: AbortSignal.timeout(this.timeout),
});
const startupRes = await fetch(
loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`,
withDispatcher({
method: "GET",
headers: hdrs2,
redirect: "manual",
signal: AbortSignal.timeout(this.timeout),
}),
);
const loc2 = startupRes.headers.get("location") || loc1;
const mode = loc2.match(/[?&]mode=([^&]+)/)?.[1] || "";
// Keep upds URL-encoded exactly as returned (e.g. "2024.02.13+09%3A27%3A21+CET");
@@ -494,12 +513,15 @@ export class PL24PsaService {
Accept: opts.json ? "application/json,*/*" : "text/html,application/xhtml+xml,*/*;q=0.9",
};
try {
const res = await fetch(url, {
const dispatcher = await this.psaDispatcher();
const fetchOpts: RequestInit & { dispatcher?: any } = {
method: "GET",
headers,
redirect: opts.manual ? "manual" : "follow",
signal: AbortSignal.timeout(this.timeout),
});
};
if (dispatcher) fetchOpts.dispatcher = dispatcher;
const res = await fetch(url, fetchOpts);
const location = res.headers.get("location");
if (opts.manual && res.status >= 300 && res.status < 400) {
return { status: res.status, text: null, location };
@@ -543,13 +565,19 @@ export class PL24PsaService {
Accept: "application/json,image/*,*/*;q=0.9",
Referer: `${this.baseUrl}/psa/${serviceName}/vin-image-board.action`,
};
const dispatcher = await this.psaDispatcher();
const withDispatcher = (o: RequestInit): RequestInit & { dispatcher?: any } =>
dispatcher ? { ...o, dispatcher } : o;
try {
const infoRes = await fetch(`${imageUrl}&request=GetImageInfo&cv=1`, {
method: "GET",
headers,
redirect: "follow",
signal: AbortSignal.timeout(this.timeout),
});
const infoRes = await fetch(
`${imageUrl}&request=GetImageInfo&cv=1`,
withDispatcher({
method: "GET",
headers,
redirect: "follow",
signal: AbortSignal.timeout(this.timeout),
}),
);
if (!infoRes.ok) return null;
const info = (await infoRes.json()) as {
imageWidth: number;
@@ -570,12 +598,15 @@ export class PL24PsaService {
const getImgUrl =
`${imageUrl}&request=GetImage&format=image%2Fpng` +
`&bbox=${encodeURIComponent(`0,0,${w},${h}`)}&width=${w}&height=${h}&scalefac=1.0&cv=1&rnd=${rnd}`;
const imgRes = await fetch(getImgUrl, {
method: "GET",
headers: { ...headers, Accept: "image/png,image/*,*/*;q=0.9" },
redirect: "follow",
signal: AbortSignal.timeout(this.timeout),
});
const imgRes = await fetch(
getImgUrl,
withDispatcher({
method: "GET",
headers: { ...headers, Accept: "image/png,image/*,*/*;q=0.9" },
redirect: "follow",
signal: AbortSignal.timeout(this.timeout),
}),
);
if (!imgRes.ok) return null;
const contentType = imgRes.headers.get("content-type") || "image/png";
const buffer = Buffer.from(await imgRes.arrayBuffer());

View File

@@ -0,0 +1,186 @@
import { describe, expect, it } from "vitest";
import {
isPl24GroupNode,
isPl24LeafNode,
isPl24PartDetailNode,
isStalePl24Architecture,
} from "./pl24-tree";
// Canlı P5 yanıtlarından (2026-09-16 keşfi, plv2-artefakt/) alınan gerçek
// wid + path çiftleri. Bu dosya "0 parça" sınıfı hatanın regresyon kilidi.
describe("isPl24LeafNode — canlı P5 şekilleri", () => {
it("VW (p5vwag): maingroups/subgroups grup, bom/vin yaprak", () => {
expect(
isPl24GroupNode({
linkWid: "mainGroupsTable",
linkPath: "/p5vwag/extern/groups/vin_maingroups?vin=X",
}),
).toBe(true);
expect(
isPl24GroupNode({
linkWid: "subGroupsIllusTable",
linkPath: "/p5vwag/extern/groups/vin_subgroups_illus?maingroup=4",
}),
).toBe(true);
expect(
isPl24LeafNode({
linkWid: "bomlist",
linkPath: "/p5vwag/extern/bom/vin?illustration=407-000",
}),
).toBe(true);
});
it("PSA (p5psa): scope + mainGroups + illusTable grup, bomDetails yaprak", () => {
expect(
isPl24GroupNode({
linkWid: "scopeTable",
linkPath: "/p5psa/extern/group/vin/scope?modelCode=1PD2",
}),
).toBe(true);
expect(
isPl24GroupNode({
linkWid: "mainGroupTable",
linkPath: "/p5psa/extern/group/vin/mainGroups?scope=_FCT0001",
}),
).toBe(true);
// Eski `includes("group")` kuralının kaçırdığı seviye — sessiz boş panelin kaynağı.
expect(
isPl24GroupNode({
linkWid: "illusTable",
linkPath: "/p5psa/extern/group/vin/illus?mainGroup=_FCT0001_FCT0512",
}),
).toBe(true);
// camelCase bomDetails — eski `includes("/bomdetails")` kaçırıyordu.
expect(
isPl24LeafNode({
linkWid: "bomlist",
linkPath: "/p5psa/extern/details/vin/bomDetails?illustration=D2F001A48A",
}),
).toBe(true);
});
it("Volvo (p5volvo): illustrationsTable grup, bom yaprak", () => {
expect(
isPl24GroupNode({
linkWid: "illustrationsTable",
linkPath: "/p5volvo/extern/groups/vin/illustration?group=0b00c8af80205942",
}),
).toBe(true);
expect(isPl24LeafNode({ linkWid: "bomlist", linkPath: "/p5volvo/extern/bom/vin?..." })).toBe(
true,
);
expect(
isPl24LeafNode({
linkWid: "partinfo",
linkPath: "/p5volvo/extern/partinfo/vin?partno=36050493",
}),
).toBe(true);
});
it("Subaru (p5subaru): subgroups/illustrations grup, bom/vin?figNum yaprak", () => {
expect(
isPl24GroupNode({
linkWid: "illustrationsTable",
linkPath: "/p5subaru/extern/groups/vin/illustrations?subGroup=001",
}),
).toBe(true);
expect(
isPl24LeafNode({ linkWid: "bomlist", linkPath: "/p5subaru/extern/bom/vin?figNum=01" }),
).toBe(true);
});
it("wid yoksa yol kalıbına düşer (eski DB satırları)", () => {
expect(isPl24LeafNode({ linkPath: "/p5vwag/extern/bom/vin?illustration=1" })).toBe(true);
expect(
isPl24LeafNode({ linkPath: "/psa/peugeot_parts/vin-image-board.action?illCode=1" }),
).toBe(true);
expect(
isPl24GroupNode({
linkPath: "/psa/peugeot_parts/json-vin-main-groups.action?scope=_FCT0001",
}),
).toBe(true);
});
it("hasSubgroups=true yol tahminini ezer", () => {
expect(isPl24LeafNode({ linkPath: "/p5x/extern/unknown", hasSubgroups: true })).toBe(false);
});
it("servicepart öğe listesi yapraktır", () => {
expect(
isPl24LeafNode({
linkWid: "servicePartsItemsTable",
linkPath: "/p5vwag/extern/servicepart/vin_items?x=1",
}),
).toBe(true);
});
});
describe("isStalePl24Architecture — P4→P5 göç tespiti", () => {
it("eski PSA/Volvo yolu + artık P5 olan servis → bayat", () => {
expect(
isStalePl24Architecture({ catalogPath: "/psa/peugeot_parts", currentApiPath: "/p5psa" }),
).toBe(true);
expect(isStalePl24Architecture({ catalogPath: "/volvo", currentApiPath: "/p5volvo" })).toBe(
true,
);
});
it("zaten P5 kaydı bayat değil", () => {
expect(isStalePl24Architecture({ catalogPath: "/p5psa", currentApiPath: "/p5psa" })).toBe(
false,
);
});
it("hâlâ P4 olan markalar (Ford/Opel/Hyundai) dokunulmaz", () => {
expect(isStalePl24Architecture({ catalogPath: "/ford", currentApiPath: "/ford" })).toBe(false);
expect(isStalePl24Architecture({ catalogPath: "/opel", currentApiPath: "/opel" })).toBe(false);
});
it("eksik bilgi → bayat sayma (güvenli taraf)", () => {
expect(isStalePl24Architecture({ catalogPath: null, currentApiPath: "/p5psa" })).toBe(false);
expect(isStalePl24Architecture({ catalogPath: "/psa/x", currentApiPath: null })).toBe(false);
});
});
/**
* Mitsubishi: parça listesi grup sanılıyordu (plv2.md, bulgu consumers-13).
*
* `/p5mitsubishi/extern/details/vinDetails` yanıtı `partno`/`qty` taşıyan bir
* PARÇA listesi (canlı doğrulama 2026-09-20: 16 kayıt), ama her kaydın linki
* `partInfoTable`. Ne wid ne yol sınıflandırıcıda karşılık bulmuyordu → 2.193
* parça listesi gruba, içlerindeki 19.576 tekil parça kategoriye dönüşmüştü;
* hepsinde toplam 2 parça vardı ve her prefetch turunda yeniden çekiliyorlardı.
*/
describe("Mitsubishi detailsTable / partInfoTable", () => {
const detailsPath =
"/p5mitsubishi/extern/details/vinDetails?bomDetails=133_110D00125Y&mainGroup=33";
const partInfoPath = "/p5mitsubishi/extern/details/vinpartinfo?bomDetails=142_7103K22Y5T";
it("detailsTable bir parça listesi — yaprak", () => {
expect(isPl24LeafNode({ linkWid: "detailsTable", linkPath: detailsPath })).toBe(true);
expect(isPl24GroupNode({ linkWid: "detailsTable", linkPath: detailsPath })).toBe(false);
});
it("partInfoTable tekil parça detayı — ne grup ne liste", () => {
expect(isPl24PartDetailNode({ linkWid: "partInfoTable", linkPath: partInfoPath })).toBe(true);
expect(isPl24GroupNode({ linkWid: "partInfoTable", linkPath: partInfoPath })).toBe(false);
});
it("wid yoksa yol kalıbı parça-detayını yine yakalar", () => {
expect(isPl24PartDetailNode({ linkPath: partInfoPath })).toBe(true);
});
it("gerçek parça uçlarını parça-detayı sanmaz", () => {
// Volvo'nun partinfo yaprağı ve VW'nin bom listesi etkilenmemeli.
expect(
isPl24PartDetailNode({
linkWid: "partinfo",
linkPath: "/p5volvo/extern/partinfo/vin?partno=1",
}),
).toBe(false);
expect(
isPl24PartDetailNode({ linkWid: "bomlist", linkPath: "/p5vwag/extern/bom/vin?x=1" }),
).toBe(false);
});
});

View File

@@ -0,0 +1,151 @@
/**
* One source of truth for "is this PL24 node a parts leaf or a group to drill?".
*
* WHY THIS FILE EXISTS (plv2.md, findings p5core-02 / psa-05 / consumers_jobs-01):
* the same question was answered by four independent heuristics —
* `categories.service` (twice), `catalog.service` and the prefetch worker — and
* they disagreed. Two failure modes shipped repeatedly:
*
* 1. `linkWid.includes("group")` as the "is a group" test. Live P5 PSA's second
* level has wid `illusTable`, Volvo's and Subaru's `illustrationsTable` —
* none contain "group", so those nodes fell through to the parts fetcher,
* which asked an *illustration list* for parts, got records with no partno,
* and rendered a silent empty panel (`parts: []`, no error). This is the same
* class of bug as the 2026-06 `isPsaParent` incident.
* 2. Case-sensitive `includes("/bomdetails")` while p5psa and p5volvo spell the
* endpoint `/details/vin/bomDetails` — so those leaves were queued as groups,
* `getChildren` returned [] and their parts were never prefetched (live today
* for Mitsubishi 99.9% / Fiat 80% / Renault 70% of bomDetails leaves).
*
* The reliable cross-brand marker is the response's own `link.wid`: `bomlist`
* (and the service-parts/partinfo variants) means parts, anything else means
* drill. Path matching stays as a fallback for stored rows without a wid.
*/
/**
* `link.wid` values that identify a parts (BOM) node across every P5 backend.
*
* `detailstable` is Mitsubishi's: `/p5mitsubishi/extern/details/vinDetails`
* answers with 16-ish records carrying `partno`/`qty`, i.e. it IS the parts
* list — but each record's own link is a `partInfoTable` per-part detail, and
* neither the wid nor the path matched anything here, so the whole list was
* drilled as a group. Prod on 2026-09-20: 2,193 Mitsubishi parts lists turned
* into group nodes and their 19,576 individual parts ("SCREW,LOCK CYLINDER",
* "BOLT,STEERING COLUMN WASHER") became categories — 19,576 fake tree nodes
* with 2 parts between them, each re-fetched on every prefetch pass.
*/
const LEAF_WIDS = new Set([
"bomlist",
"bomoverviewlist",
"servicepartsitemstable",
"partinfo",
"detailstable",
]);
/**
* Nodes that describe ONE part rather than a list of them. They are neither a
* group to drill nor a list to fetch: the parent's own response already carried
* the part. Queueing them buys nothing and costs one upstream request each —
* 19,576 of them on prod before this was recognised.
*/
const PART_DETAIL_WIDS = new Set(["partinfotable"]);
const PART_DETAIL_PATH = /\/details\/vinpartinfo\b/i;
/** True when this node is a single part's detail view, not a listing. */
export function isPl24PartDetailNode(opts: {
linkPath?: string | null;
linkWid?: string | null;
}): boolean {
const wid = opts.linkWid?.toLowerCase().trim();
if (wid && PART_DETAIL_WIDS.has(wid)) return true;
return PART_DETAIL_PATH.test(opts.linkPath ?? "");
}
/** `link.wid` values that identify a drillable group node. */
const GROUP_WID_PATTERN =
/(group|scope|illus|illustration|catalog|model|vpages|msppages|chemicals|category|categories)/i;
/**
* Path fragments that only ever appear on a parts endpoint. `image-board` has no
* leading slash on purpose: the legacy PSA leaf is `vin-image-board.action`.
*/
const LEAF_PATH_PATTERN =
/\/(bom|bomdetails|partinfo|vin_items|mdl_items|vin_bomdetails)\b|\/bom\/|\/details\/vin\/bomdetails|\/servicepart\/vin_items|image-board/i;
/** True when this node yields parts (never children). */
export function isPl24LeafNode(opts: {
linkPath?: string | null;
linkWid?: string | null;
hasSubgroups?: boolean | null;
}): boolean {
const wid = opts.linkWid?.toLowerCase().trim();
if (wid) {
if (LEAF_WIDS.has(wid)) return true;
if (GROUP_WID_PATTERN.test(wid)) return false;
}
// Explicit DB hint wins over path guessing when there is no usable wid.
if (opts.hasSubgroups === true) return false;
const lp = opts.linkPath?.toLowerCase() ?? "";
if (!lp) return false;
return LEAF_PATH_PATTERN.test(lp);
}
/** True when this node should be drilled for children. */
export function isPl24GroupNode(opts: {
linkPath?: string | null;
linkWid?: string | null;
hasSubgroups?: boolean | null;
}): boolean {
if (!opts.linkPath && !opts.linkWid) return false;
// A per-part detail node is not a group; drilling it returns nothing.
if (isPl24PartDetailNode(opts)) return false;
return !isPl24LeafNode(opts);
}
/**
* True when a stored vehicle's catalogInfo points at an architecture the service
* no longer uses — i.e. the row was decoded before PSA/Volvo moved to P5.
*
* These vehicles keep serving a tree built from the frozen P4 PSA snapshot
* (upds 2024-02-13) or the dead P4 Volvo endpoint (HTTP 503), and `decodeVin`'s
* db_hit short-circuit means a code fix never reaches them. Detecting the
* mismatch at read time lets each vehicle heal itself on first view instead of
* needing a bulk re-decode storm against the one surviving account.
*/
export function isStalePl24Architecture(opts: {
catalogPath?: string | null;
currentApiPath?: string | null;
}): boolean {
const stored = opts.catalogPath?.toLowerCase() ?? "";
const current = opts.currentApiPath?.toLowerCase() ?? "";
if (!stored || !current) return false;
// Only the two migrated legacy backends; unknown/other paths are left alone.
const storedIsLegacyPsaOrVolvo = stored.startsWith("/psa") || stored.startsWith("/volvo");
if (!storedIsLegacyPsaOrVolvo) return false;
return current.startsWith("/p5");
}
/**
* True when a stored `catalog_vehicles` browse row was listed under an
* architecture the service table no longer uses.
*
* Browse rows are a PERMANENT cache: `CatalogService.getModels` returns early
* whenever the brand already has rows, so `fetchVehicleList` is never called
* again for that brand. The 188 rows listed while PSA and Volvo were still P4
* (127 LEGACY_PSA + 61 LEGACY_VOLVO on prod, 2026-09-20) are therefore pinned
* forever: Peugeot/Citroën browse serves the frozen 2024-02-13 snapshot and
* Volvo/Polestar browse serves an endpoint that answers HTTP 503. Detecting the
* mismatch at list time lets the brand re-list itself once, the same way
* `isStalePl24Architecture` heals a VIN-decoded vehicle.
*/
export function isStaleBrowseArchitecture(opts: {
storedArchitecture?: string | null;
currentArchitecture?: string | null;
}): boolean {
const stored = opts.storedArchitecture?.trim();
const current = opts.currentArchitecture?.trim();
// An unknown service (no config) or an unlabelled row is left alone: without a
// current architecture to compare against there is nothing to migrate TO.
if (!stored || !current) return false;
return stored !== current;
}

View File

@@ -0,0 +1,68 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { PL24Service } from "./pl24.service";
/**
* Volvo P5 vinfoBasic regression lock (plv2.md, finding p5core-09).
*
* Volvo emits BOTH a bare internal code and a readable description for the same
* attribute — "Şanzıman kodu" = "B" next to "Şanzıman" = "6-PSHIFT 2WD / MPS6",
* and "Satis tipi" = "42" next to "Türü" = "S80". The key order the parser needs
* for VAG (whose "Şanzıman kodu" IS the useful value, e.g. "MQ200") therefore
* rendered a single letter as the gearbox and a bare number as the series.
*
* Fixtures are the real 2026-09-16 discovery captures for VIN
* YV1AS84ABD1168166 (S80), trimmed to the segments the parser reads, in both
* the Turkish and the English label locale.
*/
const fixture = (name: string) =>
JSON.parse(readFileSync(join(__dirname, "__fixtures__", `${name}.json`), "utf-8"));
// parseVehicleResponse only touches the response and the service name.
const parse = (data: unknown, serviceName = "volvo_parts") => {
const svc = Object.create(PL24Service.prototype) as unknown as {
parseVehicleResponse: (v: string, d: unknown, s: string) => Record<string, unknown>;
isDaimlerService: (s: string) => boolean;
};
svc.isDaimlerService = () => false;
return svc.parseVehicleResponse("YV1AS84ABD1168166", data, serviceName);
};
describe("Volvo P5 vinfoBasic — kod yerine açıklama", () => {
it("Türkçe etiketlerde şanzımanı okunur değerinden alır", () => {
const out = parse(fixture("p5_volvo_tr"));
expect(out.transmission).toBe("6-PSHIFT 2WD / MPS6");
// Tek harflik "Şanzıman kodu" artık kazanmıyor.
expect(out.transmission).not.toBe("B");
});
it("İngilizce etiketlerde de aynı sonucu verir", () => {
const out = parse(fixture("p5_volvo_en"));
expect(out.transmission).toBe("6-PSHIFT 2WD / MPS6");
});
it("seri, çıplak satış kodu yerine gerçek tipi döner", () => {
expect(parse(fixture("p5_volvo_tr")).series).toBe("S80");
expect(parse(fixture("p5_volvo_en")).series).toBe("S80");
expect(parse(fixture("p5_volvo_tr")).series).not.toBe("42");
});
it("kaporta stili iki dilde de çözülür ve '0' kodu sızmaz", () => {
expect(parse(fixture("p5_volvo_tr")).bodyType).toBe("Sedan");
expect(parse(fixture("p5_volvo_en")).bodyType).toBe("Sedan");
});
it("motor alanları bozulmadan kalır", () => {
const out = parse(fixture("p5_volvo_tr"));
expect(out.engineCode).toBe("84");
expect(out.engineType).toBe("D4162T");
});
it("model ve yıl korunur", () => {
const out = parse(fixture("p5_volvo_tr"));
expect(out.model).toBe("S80 (07-)");
expect(out.year).toBe(2013);
});
});

View File

@@ -1,13 +1,29 @@
export const PL24_DEFAULTS = {
AUTH_TOKEN_TTL: 3600, // 1 hour in seconds
/** Service JWTs live 600s (measured); refresh this many ms before expiry. */
SERVICE_TOKEN_SKEW_MS: 60_000,
/** PL24TOKEN is a session cookie with no expiry — we keep our copy for a day. */
SESSION_TTL_S: 86_400,
CACHE_PREFIX: "pl24:",
REQUEST_TIMEOUT: 30000,
MAX_RETRIES: 3,
} as const;
/**
* Real Chrome UA. The old value ("…AppleWebKit/537.36" with no Chrome/Safari
* token) matches no real browser and is a cheap automation tell.
*/
export const PL24_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
export const PL24_ENDPOINTS = {
// Auth
LOGIN: "/pl24-appgtw/ext/api/1.0/login",
// Auth — LOGIN is the portal/landing endpoint (returns {loginStatus, sessionToken}
// + Set-Cookie PL24TOKEN). LOGIN_LEGACY is the in-SPA one our old code used; it
// still works and returns a 600s base JWT we no longer need. PL24_LOGIN_API=legacy
// switches back for one release.
LOGIN: "/auth/ext/api/1.1/login",
LOGIN_LEGACY: "/pl24-appgtw/ext/api/1.0/login",
LOGOUT_LEGACY: "/pl24-appgtw/ext/api/1.0/logout",
SESSION: "/auth/ext/api/1.1/session",
AUTHORIZE: "/auth/ext/api/1.1/authorize",
// Catalog

View File

@@ -1,5 +1,7 @@
import { Module } from "@nestjs/common";
import { ProxyTelemetryModule } from "../proxy-telemetry/proxy-telemetry.module";
import { PL24AuthService } from "./pl24-auth.service";
import { PL24BudgetService } from "./pl24-budget.service";
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
import { PL24FordService } from "./pl24-ford.service";
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
@@ -11,6 +13,7 @@ import { PL24Service } from "./pl24.service";
const PL24_PROVIDERS = [
PL24Service,
PL24AuthService,
PL24BudgetService,
PL24FordLegacyService,
PL24PsaService,
PL24VolvoService,
@@ -20,6 +23,7 @@ const PL24_PROVIDERS = [
];
@Module({
imports: [ProxyTelemetryModule],
providers: PL24_PROVIDERS,
exports: PL24_PROVIDERS,
})

View File

@@ -15,6 +15,7 @@ const svc = new PL24Service(
{} as never, // redis
{} as never, // storage
{} as never, // posthog
{} as never, // budget
);
const p = svc as unknown as {
parseVehicleResponse(

View File

@@ -20,6 +20,7 @@ import { PostHogService } from "../../posthog/posthog.service";
import { RedisService } from "../../redis/redis.service";
import { StorageService } from "../../storage/storage.service";
import { PL24AuthService } from "./pl24-auth.service";
import { PL24BudgetService } from "./pl24-budget.service";
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
import { PL24FordService } from "./pl24-ford.service";
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
@@ -55,6 +56,65 @@ const LEGACY_ARCH_SOURCE_TAG: Record<string, string> = {
LEGACY_HYUNDAI_KIA: "hyundai-kia",
};
/**
* Normalise a vinfoBasic label into a lookup key.
*
* JS `toLowerCase()` maps Turkish "İ" to "i" + U+0307 (combining dot), so PSA
* labels like "AKTARMA SİSTEMLERİ" / "GÖVDE TİPİ" produced keys no lookup could
* ever match and the transmission/body fields silently stayed null. Lower-case
* with the Turkish locale, then strip combining marks and fold "ı" → "i" so a
* single spelling matches both "Model yılı" and "MODEL YILI".
*/
export function normalizeLabel(label: string): string {
return (
label
.toLocaleLowerCase("tr")
.normalize("NFD")
// \p{M} (all combining marks) rather than the U+0300–U+036F range: the range
// is a character class that can also match a base character followed by a
// combining one, which biome flags as misleading. NFD has already split every
// accent into its own mark, so matching marks directly is both correct and
// broader (Turkish, Latin-Extended, anything PL24 sends).
.replace(/\p{M}/gu, "")
.replace(/ı/g, "i")
.replace(/[\s/]+/g, "_")
.trim()
);
}
/**
* PSA model year: "AM 2005" → 2005. Index labels ("01 MAJÖR ENDEKS",
* 'MAJÖR ENDEKS "0C"') are NOT years — returning one there is how a Citroën
* ended up as a 2001 model. PSA VINs also don't encode the model year in
* position 10, so the caller must fall back to DAM or leave it null.
*/
export function parsePsaModelYear(value: string | null | undefined): number | null {
if (!value) return null;
if (/endeks/i.test(value)) return null;
const m = value.match(/\b(?:AM\s*)?((?:19|20)\d{2})\b/i);
if (m) return Number(m[1]);
const short = value.match(/^\s*AM\s*(\d{2})\s*$/i);
if (short) {
const n = Number(short[1]);
return n >= 70 ? 1900 + n : 2000 + n;
}
return null;
}
/**
* PSA "DAM" (e.g. "10479CJ") = days since 1976-01-01 + plant code → build date.
* PSA model years roll in July, so a build after June belongs to the next one.
*/
export function damToModelYear(dam: string | null | undefined): number | null {
if (!dam) return null;
const m = dam.match(/^(\d{4,5})/);
if (!m) return null;
const date = new Date(Date.UTC(1976, 0, 1) + Number(m[1]) * 86_400_000);
const year = date.getUTCFullYear();
if (year < 1980 || year > 2100) return null;
return date.getUTCMonth() >= 6 ? year + 1 : year;
}
@Injectable()
export class PL24Service {
private readonly logger = new Logger(PL24Service.name);
@@ -74,6 +134,7 @@ export class PL24Service {
private redis: RedisService,
private storage: StorageService,
private posthog: PostHogService,
private readonly budget: PL24BudgetService,
) {
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
this.timeout = 30000;
@@ -944,15 +1005,28 @@ export class PL24Service {
return opts;
};
const response = await fetch(url, buildOpts(headers));
// Every PL24 upstream call is counted and logged here (the one choke point).
const response = await this.budgetedFetch(
url,
buildOpts(headers),
account,
Boolean(dispatcher),
);
if (response.status === 401) {
this.logger.warn(`Got 401 (account=${account}), refreshing token...`);
this.logger.warn(`Got 401 (account=${account}), refreshing service token...`);
// A stale *service* token is the cheap explanation; the session itself is
// only dropped if the re-authorize also fails (handled in the auth service).
this.authService.clearTokensForAccount(account);
await this.authService.authorizeServiceForAccount(serviceName, account);
const newHeaders = await this.authService.buildAuthHeadersForAccount(account, serviceName);
const retry = await fetch(url, buildOpts(newHeaders));
const retry = await this.budgetedFetch(
url,
buildOpts(newHeaders),
account,
Boolean(dispatcher),
);
if (!retry.ok) {
throw new Error(`HTTP ${retry.status}: ${retry.statusText}`);
@@ -971,6 +1045,50 @@ export class PL24Service {
return response;
}
/**
* Single gate for PL24 upstream traffic: daily budget first (a refusal costs
* no request at all), then the call, then telemetry into `proxy_logs`.
*/
private async budgetedFetch(
url: string,
opts: RequestInit & { dispatcher?: any },
account: "tr" | "de",
proxied: boolean,
): Promise<Response> {
// The kill switch used to gate decodeVin only, so drills, backfill and
// catalog browse kept hammering PL24 after the source was "killed".
if (!(await this.posthog.isSourceLive("pl24"))) {
throw new ServiceUnavailableException("PL24 kapali (kill-source-pl24)");
}
await this.budget.consume("catalog");
const startedAt = Date.now();
const activeAccount = this.authService.activeAccount(account);
try {
const response = await fetch(url, opts);
this.budget.record({
kind: "catalog",
url,
proxied,
account: activeAccount,
statusCode: response.status,
success: response.ok,
startedAt,
});
return response;
} catch (error) {
this.budget.record({
kind: "catalog",
url,
proxied,
account: activeAccount,
success: false,
startedAt,
error,
});
throw error;
}
}
// ==================== PRIVATE: Account routing ====================
/**
@@ -1026,6 +1144,78 @@ export class PL24Service {
// ==================== PRIVATE: Response parsers ====================
/**
* Ask a P5 backend where its own model list lives, instead of guessing.
*
* Every P5 backend serves `/extern/catmeta`, whose `data.catalogEntryPoint.path`
* is the authoritative browse entry point — e.g. p5psa answers
* `/p5psa/extern/vehicle/catalogs`, p5volvo `/p5volvo/extern/vehicles/models`.
* The old behaviour, when `BACKEND_MODEL_PATH` had no entry, was to fire the
* seven known paths in turn and keep whichever returned rows. That costs up to
* seven upstream requests per call on a shared daily budget, and it silently
* fails for any backend whose path is not already on the list — which is how
* Volvo/Polestar browse ended up seeding zero models while still serving the
* retired P4 listing.
*
* The resolved path is cached per backend (30 days) so this costs one request
* for a backend's whole lifetime, and it self-heals if PL24 moves an endpoint.
* Returns null on any failure; the caller then falls back as before.
*/
private async resolveModelPathFromCatmeta(
catalogBase: string,
serviceName: string,
headers: Record<string, string>,
): Promise<string | null> {
const cacheKey = `pl24:modelpath:${catalogBase.replace(/^\//, "")}`;
try {
const cached = await this.redis.get(cacheKey);
if (cached) return cached === "none" ? null : cached;
} catch {
// Redis down — resolve live rather than failing the listing.
}
let resolved: string | null = null;
try {
const url = `${this.baseUrl}${catalogBase}/extern/catmeta?serviceName=${serviceName}&country=DE&lang=${this.language}`;
const res = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(15000) });
if (res.ok) {
const meta = (await res.json()) as {
data?: { catalogEntryPoint?: { path?: string } };
};
const full = meta.data?.catalogEntryPoint?.path;
if (full) {
// catmeta returns the absolute path with query string
// ("/p5volvo/extern/vehicles/models?lang=en&serviceName=…"); the caller
// appends its own lang/serviceName, so keep only the backend-relative
// path segment.
const withoutQuery = full.split("?")[0];
const relative = withoutQuery.startsWith(catalogBase)
? withoutQuery.slice(catalogBase.length)
: withoutQuery;
if (relative.startsWith("/extern/")) resolved = relative;
}
}
} catch (err) {
this.logger.warn(
`fetchVehicleList: catmeta lookup failed for ${serviceName}: ${(err as Error).message}`,
);
}
if (resolved) {
this.logger.log(
`fetchVehicleList: ${serviceName} model path resolved from catmeta → ${resolved}`,
);
}
try {
// Cache the negative too, so a backend without a usable catmeta does not
// re-probe on every browse.
await this.redis.set(cacheKey, resolved ?? "none", 30 * 86_400);
} catch {
// best effort
}
return resolved;
}
/**
* Parse vehicle data from directAccess response.
*/
@@ -1051,7 +1241,7 @@ export class PL24Service {
const label = (v.key !== undefined ? v.key : v.description) || "";
const value = (v.key !== undefined ? v.description : v.value) || "";
if (!label) continue;
const key = label.toLowerCase().replace(/[\s\/]+/g, "_");
const key = normalizeLabel(label);
if (!(key in vehicleData)) {
// Normalize like prNr col3: newlines→space, unescape the literal "\-" some P5
// backends emit (JLR "XJ 2010 \- 2019", Toyota/Suzuki dates "2023\-11\-29"/"2005\-07"),
@@ -1073,6 +1263,31 @@ export class PL24Service {
return null;
};
/**
* Like `lookup`, but prefers a human-readable value over a bare internal
* code when the record carries both.
*
* Volvo's vinfoBasic has BOTH "Şanzıman kodu" ("B") and "Şanzıman"
* ("6-PSHIFT 2WD / MPS6"), and BOTH "Satis tipi" ("42") and a second
* "Satis tipi" ("SALES VERSION 42"). The code-first key order that VAG needs
* (its "Şanzıman kodu" IS the useful value, e.g. "MQ200") therefore rendered
* a single letter as the Volvo gearbox and a bare number as its series.
*
* Falls back to the first present value, so a backend that only ever emits
* codes behaves exactly as before.
*/
const lookupDescriptive = (...keys: string[]): string | null => {
let firstPresent: string | null = null;
for (const k of keys) {
const v = vehicleData[k]?.trim();
if (!v) continue;
if (firstPresent === null) firstPresent = v;
// Two characters or fewer, or digits only → an index, not a description.
if (v.length > 2 && !/^\d+$/.test(v)) return v;
}
return firstPresent;
};
// Extract prNr records for richer vehicle attributes
const prNrRecords = segments.prNr?.records || [];
const prNrByCode: Record<string, string> = {};
@@ -1098,26 +1313,47 @@ export class PL24Service {
const engineCode = lookup("motor_kodu", "engine_code");
// Non-VAG P5 OEMs label the engine differently and give a description (sometimes with a
// code in parens): JLR "Motor Tipi", Toyota "ENGINE 1", MAN "Yedek motor", Suzuki "Motor No.".
const engineLabel = lookup("motor_tipi", "engine_1", "yedek_motor", "motor_no.");
// PSA labels the full designation "MOTOR" ("TÜRBO DİZEL DV6TED4…"), Volvo
// "Motor" ("D4162T"), Subaru "Engine" — none of which the VAG-shaped list had.
const engineLabel = lookup(
"motor_tipi",
"engine_1",
"yedek_motor",
"motor_no.",
"motor",
"engine",
);
// Transmission: VAG "Şanzıman kodu"; other OEMs use their own labels — JLR "Vites Kutusu",
// Toyota "ATM,MTM" (key "atm,mtm" — only spaces/slashes are underscored), MAN "Şanzıman",
// Suzuki "Şanzıman numarası".
const transmissionCode = lookup(
"şanzıman_kodu",
"sanzıman_kodu",
// normalizeLabel folds ı→i and strips diacritics, so "Şanzıman kodu" and
// "ŞANZIMAN KODU" both arrive as "sanziman_kodu". PSA uses "AKTARMA
// SİSTEMLERİ" ("5 MEKANİK VİTES KUTUSU"), Subaru "Mission".
const transmissionCode = lookupDescriptive(
"sanziman_kodu",
"transmission_code",
"vites_kutusu",
"atm,mtm",
"şanzıman",
"şanzıman_numarası",
"aktarma_sistemleri",
"sanziman",
// Volvo in the English locale: "Transmission code" ("B") plus the readable
// "Transmission" ("6-PSHIFT 2WD / MPS6"). Without the plain key the
// English response falls back to the single-letter code.
"transmission",
"sanziman_numarasi",
"mission",
);
// Build body type from prNr K8* (Kaporta formları); brands without a prNr segment
// (e.g. BMW) carry it in vinfoBasic "Karoseri" ("Limousine").
const bodyType =
Object.entries(prNrByCode).find(([code]) => code.startsWith("K8"))?.[1] ||
lookup("karoseri", "body", "body_type") ||
// PSA "GÖVDE TİPİ" ("4 KAPILI SEDAN"), Volvo "Kaporta Stili" ("Sedan").
// Volvo: TR "Kaporta Stili" / EN "Body style" ("Sedan"). Its
// "Karoseri Tipi Kodu" / "Body style code" is a bare "0" — never matched
// here because the lookup is exact-key, and that is deliberate.
lookup("karoseri", "body", "body_type", "govde_tipi", "kaporta_stili", "body_style") ||
null;
// Engine description from prNr D3* (Motor nitelikleri)
@@ -1140,7 +1376,7 @@ export class PL24Service {
// the friendly name is in "Araç" / description ("L200(EUR/MMTH)") — strip the region suffix.
const isMitsubishi = getServiceApiPath(serviceName) === "/p5mitsubishi";
const baseModel = isMitsubishi
? (lookup("araç") || (data.description as string) || lookup("model") || "")
? (lookup("arac") || (data.description as string) || lookup("model") || "")
.replace(/\s*\([^)]*\)\s*$/, "")
.trim()
: lookup("model_bilgisi", "model")?.trim() ||
@@ -1163,17 +1399,23 @@ export class PL24Service {
// production date ("05/09/2014"); p5daimler → only "Teslimat tarihi" (delivery, "04.05.2009").
// Falling through to the VIN year-char (extractModelYear) is the last resort and is often
// wrong for Mercedes (10th-char "1" → 2001 for a 2015 car), so read the dates first.
// PSA writes "AM 2005" (or an index label that is NOT a year) and its VINs
// do not encode the model year in position 10, so parse the PSA forms
// first and fall back to the DAM build date before ever touching the VIN.
year:
parsePsaModelYear(lookup("model_yili", "year")) ||
Number.parseInt(lookup("model_yili", "year") || "", 10) ||
Number.parseInt(
(lookup("my", "üretim_tarihi", "uretim_tarihi", "teslimat_tarihi") || "").match(
/(19|20)\d{2}/,
)?.[0] || "",
(lookup("my", "uretim_tarihi", "teslimat_tarihi") || "").match(/(19|20)\d{2}/)?.[0] || "",
10,
) ||
damToModelYear(lookup("dam")) ||
extractModelYear(vin) ||
0,
series: lookup("seri", "satis_tipi", "sales_type"),
// Volvo's "Satis tipi" is the bare sales code ("42") with the readable
// form ("SALES VERSION 42") in a duplicate row, and its "Türü" is the real
// series ("S80") — so prefer whichever of these is actually descriptive.
series: lookupDescriptive("seri", "satis_tipi", "sales_type", "turu", "type"),
bodyType,
engineCode:
engineCode ||
@@ -1187,7 +1429,7 @@ export class PL24Service {
colorCode:
lookup("dis_rengi_boya_numarasi", "exterior_color___paint_code") ||
lookup("tavan_rengi", "roof_color"),
productionDate: lookup("üretim_tarihi", "date_of_production"),
productionDate: lookup("uretim_tarihi", "date_of_production", "teslimat_tarihi"),
raw: data,
catalogInfo: {
serviceName,
@@ -1352,9 +1594,14 @@ export class PL24Service {
const values = (record.values as Record<string, string>) || {};
const link = (record.link as Record<string, unknown>) || {};
// PSA (p5psa) shares one `record.id` (the illusPath) across many
// illustrations — 36 live records had only 11 distinct ids — so the unique
// key is `values.illustration` ("D2F 0 01A 48A", spaces stripped). Without
// this the children collapse onto each other and most get dropped.
const code =
values.subgroup ||
values.illustrationNumber ||
values.illustration?.replace(/\s+/g, "") ||
values.id ||
values.code ||
String(record.id || "");
@@ -1432,11 +1679,14 @@ export class PL24Service {
records = responseData.parts as Array<Record<string, unknown>>;
}
const partRecords = records.filter(
(record) =>
record.characteristic !== "sectionrow" &&
(record.partno || (record.values as Record<string, unknown>)?.partno),
);
const partRecords = records.filter((record) => {
if (record.characteristic === "sectionrow") return false;
if (!(record.partno || (record.values as Record<string, unknown>)?.partno)) return false;
// Volvo (p5volvo) prefixes each BOM with a header row that carries a
// partno but no link and is flagged unavailable — a phantom part if kept.
if (record.id === "null_null" || (record.unavailable === true && !record.link)) return false;
return true;
});
return partRecords.map((part) => {
const values = (part.values as Record<string, string>) || {};
@@ -1444,11 +1694,14 @@ export class PL24Service {
const formattedPartNo = ((part.partno as string) || values.partno || "").trim();
const cleanPartNo = formattedPartNo.replace(/\s+/g, "");
const qtyStr = values.qty || "";
// qty (VAG/Subaru) · coef (PSA) · unit (Volvo) — same field, three names.
const qtyStr = values.qty || values.coef || values.unit || "";
const quantity = Number.parseInt(qtyStr.trim(), 10) || undefined;
const remark = values.remark?.trim() || undefined;
const modelCodes = values.modelDescription?.trim() || undefined;
// PSA/Volvo/Subaru express applicability as `restriction`
// ("+ DIESEL TURBO DV6TED4 WITHOUT FAP"); VAG uses modelDescription.
const modelCodes = (values.modelDescription || values.restriction)?.trim() || undefined;
let superseded: { oldCode: string; newCode: string } | undefined;
const supersededByValue = (part.supersededBy as string) || values.supersededBy || "";
@@ -2052,11 +2305,22 @@ export class PL24Service {
p5mitsubishi: "/extern/vehicles/vehiclesOverview", // Mitsubishi
p5suzuki: "/extern/vehicle/modelFamilies", // Suzuki
p5man: "/extern/model/categories", // MAN trucks
// Pinned 2026-09-20 from each backend's own catmeta `catalogEntryPoint`
// (see resolveModelPathFromCatmeta). Before this, p5psa needed five wasted
// probe requests to rediscover its path on every call, and p5volvo found
// nothing at all — none of the seven guessed paths matches its plural
// `/extern/vehicles/models`, so Volvo/Polestar browse silently seeded zero
// models and kept serving the dead P4 listing.
p5psa: "/extern/vehicle/catalogs", // Peugeot, Citroën, DS, psa_opel, psa_vauxhall
p5volvo: "/extern/vehicles/models", // Volvo, Polestar — NOTE: plural "vehicles"
};
// catalogBase is like "/p5vwag" — strip leading slash for map lookup
const backendKey = catalogBase.replace(/^\//, "");
const modelPath = BACKEND_MODEL_PATH[backendKey] ?? "/extern/vehicle/modelfamilies";
const modelPath =
BACKEND_MODEL_PATH[backendKey] ??
(await this.resolveModelPathFromCatmeta(catalogBase, serviceName, headers)) ??
"/extern/vehicle/modelfamilies";
const url = `${this.baseUrl}${catalogBase}${modelPath}?lang=${this.language}&serviceName=${serviceName}`;

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { PL24_WMI_SERVICE_MAP, SERVICE_TO_BRAND } from "./pl24.types";
import { PL24_SERVICE_CATALOGS, PL24_WMI_SERVICE_MAP, SERVICE_TO_BRAND } from "./pl24.types";
// Q6 routing additions (undecoded-vin-rca.md). Both target services are already
// live in prod (nissan_parts: JN1 success; mercedesvans_parts: WDF decodes today),
@@ -27,3 +27,71 @@ describe("PL24_WMI_SERVICE_MAP — Q6 routing additions", () => {
}
});
});
// PL24'te bulunmayan WMI'ler: haritada yer almamalı, yoksa her sorguda boşuna
// upstream isteği üretir ve gerçek kaynağa (pcat/emex/vinpin) geçişi geciktirir.
describe("PL24_WMI_SERVICE_MAP — kapsam dışı WMI'ler", () => {
it("VR7 haritada değil (canlı: HTTP 410 'no brands found for WMI = VR7')", () => {
expect(PL24_WMI_SERVICE_MAP.VR7).toBeUndefined();
});
it("NM4 (Tofaş) haritada değil (canlı: HTTP 410)", () => {
expect(PL24_WMI_SERVICE_MAP.NM4).toBeUndefined();
});
it("canlı doğrulanmış WMI'ler doğru katalogda", () => {
expect(PL24_WMI_SERVICE_MAP.JF1).toBe("subaru_parts");
expect(PL24_WMI_SERVICE_MAP.ZAR).toBe("alfa_parts");
expect(PL24_WMI_SERVICE_MAP.VXK).toBe("psa_opel_parts");
});
});
/**
* 2026-09-20 canlı WMI servisi taraması (plv2.md, bulgu types_wmi-06/09).
* Prod'daki 1.406 haritasız araçtan hangilerinin PL24'te gerçekten karşılığı
* olduğu `/pl24-wmi/ext/api/2.0/decode` ile tek tek soruldu; bu test o cevapları
* kilitliyor — özellikle "yok" cevaplarını, çünkü onları haritaya eklemek
* boşuna istek üretir.
*/
describe("WMI haritası — canlı WMI servisi taraması (2026-09-20)", () => {
it("Renault yeniden açıldı (VIN tanımlama askısı kalktı)", () => {
expect(PL24_WMI_SERVICE_MAP.VF1).toBe("renault_parts");
expect(PL24_WMI_SERVICE_MAP.VF6).toBe("renault_parts");
expect(PL24_WMI_SERVICE_MAP.VNE).toBe("renault_parts");
});
it("canlı servisin çözdüğü yeni WMI'ler haritada", () => {
expect(PL24_WMI_SERVICE_MAP.NLH).toBe("hyundai_parts");
expect(PL24_WMI_SERVICE_MAP.TMA).toBe("hyundai_parts");
expect(PL24_WMI_SERVICE_MAP.NLJ).toBe("hyundai_parts");
expect(PL24_WMI_SERVICE_MAP.KMF).toBe("hyundai_parts");
expect(PL24_WMI_SERVICE_MAP.KNE).toBe("kia_parts");
expect(PL24_WMI_SERVICE_MAP.KNC).toBe("kia_parts");
expect(PL24_WMI_SERVICE_MAP.MMC).toBe("mmc_parts");
expect(PL24_WMI_SERVICE_MAP.XMC).toBe("mmc_parts");
expect(PL24_WMI_SERVICE_MAP.JSA).toBe("suzuki_parts");
});
it("NMB binek Mercedes değil, kamyon kataloğuna gider", () => {
expect(PL24_WMI_SERVICE_MAP.NMB).toBe("mercedestrucks_parts");
// Binek WMI'leri bozulmadı.
expect(PL24_WMI_SERVICE_MAP.WDD).toBe("mercedes_parts");
});
it("PL24'te olmayan WMI'ler haritaya EKLENMEDİ (HTTP 410)", () => {
// Honda ve Chevrolet'nin PL24'te kataloğu yok; eklemek boşuna istek olurdu.
for (const wmi of ["JHM", "SHH", "SHS", "MAK", "NLA", "KL1", "NM4", "VR7"]) {
expect(PL24_WMI_SERVICE_MAP[wmi]).toBeUndefined();
}
});
it("JMZ (Mazda) eklenmedi — servis Ford kataloğu öneriyor, marka tutarsız", () => {
expect(PL24_WMI_SERVICE_MAP.JMZ).toBeUndefined();
});
it("eşlenen her servisin katalog tanımı var", () => {
for (const [wmi, svc] of Object.entries(PL24_WMI_SERVICE_MAP)) {
expect(PL24_SERVICE_CATALOGS[svc], `${wmi} → ${svc}`).toBeDefined();
}
});
});

View File

@@ -53,6 +53,58 @@ export interface PL24JWTPayload {
alo: string;
}
/** Portal login (`/auth/ext/api/1.1/login`) request + response. */
export interface PL24LoginRequestV2 {
account: string;
user: string;
password: string;
squeezeOut: boolean;
/** Two-factor confirmation code, when PL24 asks for one. */
code?: string;
}
export interface PL24LoginResponseV2 {
loginStatus?: "OK" | string;
sessionToken?: string;
}
/**
* RFC7807 body PL24 returns on a refused login (HTTP 400/412). `type` carries
* the reason; the UI bundle enumerates these.
*/
export interface PL24LoginProblem {
type?: string;
title?: string;
detail?: string;
/** For two-fa-required: "AUTHENTICATOR" | "EMAIL". */
method?: string;
code?: string;
token?: string;
completionToken?: string;
}
export const PL24_LOGIN_PROBLEM = {
SESSION_LIMIT_EXCEEDED: "urn:login:session-limit-exceeded",
TWO_FA_REQUIRED: "urn:login:two-factor-authentication-required",
TWO_FA_INVALID: "urn:login:two-factor-authentication-invalid-code",
ACCOUNT_PENDING: "urn:login:account-pending",
PRECONDITION_FAILED: "urn:login:precondition-failed",
ACCOUNT_NOT_ACTIVE: "urn:login:account-not-active",
USER_NOT_ACTIVE: "urn:login:user-not-active",
AUTHENTICATION_FAILED: "urn:login:authentication",
} as const;
/**
* One PL24 session = the PL24TOKEN cookie. It has no expiry of its own; it dies
* when PL24 squeezes it out (another login on the same account) or is revoked,
* which surfaces as authorize → 401 or `session_status: "gone"`.
*/
export interface PL24Session {
sessionToken: string;
loginAt: number;
lastOkAt: number;
}
export interface PL24TokenData {
accessToken: string;
refreshToken: string;
@@ -287,19 +339,61 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
// PSA Group (Citroën, Peugeot)
citroen_parts: {
basePath: "/psa",
apiPath: "/psa",
architecture: "LEGACY_PSA",
basePath: "/pl24-app/citroen_parts",
apiPath: "/p5psa",
architecture: "P5_MODERN",
},
citroenDs_parts: {
basePath: "/psa",
apiPath: "/psa",
architecture: "LEGACY_PSA",
basePath: "/pl24-app/citroenDs_parts",
apiPath: "/p5psa",
architecture: "P5_MODERN",
},
peugeot_parts: {
basePath: "/psa",
apiPath: "/psa",
architecture: "LEGACY_PSA",
basePath: "/pl24-app/peugeot_parts",
apiPath: "/p5psa",
architecture: "P5_MODERN",
},
// Stellantis-era Opel/Vauxhall — the PSA-platform catalogue (Corsa F, Mokka B…).
// GM-era W0L/W0V cars stay in the legacy opel_parts P4 catalogue below.
psa_opel_parts: {
basePath: "/pl24-app/psa_opel_parts",
apiPath: "/p5psa",
architecture: "P5_MODERN",
},
psa_vauxhall_parts: {
basePath: "/pl24-app/psa_vauxhall_parts",
apiPath: "/p5psa",
architecture: "P5_MODERN",
},
// Subaru (own P5 backend)
subaru_parts: {
basePath: "/pl24-app/subaru_parts",
apiPath: "/p5subaru",
architecture: "P5_MODERN",
},
// Rest of the Fiat/Stellantis family — same /p5fiat backend as fiatp/fiatt
abarth_parts: {
basePath: "/pl24-app/abarth_parts",
apiPath: "/p5fiat",
architecture: "P5_MODERN",
},
alfa_parts: {
basePath: "/pl24-app/alfa_parts",
apiPath: "/p5fiat",
architecture: "P5_MODERN",
},
jeep_parts: {
basePath: "/pl24-app/jeep_parts",
apiPath: "/p5fiat",
architecture: "P5_MODERN",
},
lancia_parts: {
basePath: "/pl24-app/lancia_parts",
apiPath: "/p5fiat",
architecture: "P5_MODERN",
},
// Ford Group
@@ -352,14 +446,14 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
// Volvo/Polestar
volvo_parts: {
basePath: "/volvo",
apiPath: "/volvo",
architecture: "LEGACY_VOLVO",
basePath: "/pl24-app/volvo_parts",
apiPath: "/p5volvo",
architecture: "P5_MODERN",
},
polestar_parts: {
basePath: "/volvo",
apiPath: "/volvo",
architecture: "LEGACY_VOLVO",
basePath: "/pl24-app/polestar_parts",
apiPath: "/p5volvo",
architecture: "P5_MODERN",
},
// Fiat Group (FCA) — P5 Modern catalog at /p5fiat, requires de-708171 account.
@@ -446,6 +540,10 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
// Mercedes-Benz
WDB: "mercedes_parts",
// NMB: canlı WMI servisi bunu mercedes_parts'a DEĞİL mercedestrucks_parts'a
// çözüyor (error:false) — 30 prod aracı "Mercedes-Benz" etiketliydi ama binek
// kataloğunda yok.
NMB: "mercedestrucks_parts",
WDD: "mercedes_parts",
WDC: "mercedes_parts",
W1K: "mercedes_parts",
@@ -484,15 +582,21 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
JTJ: "lexus_parts",
"2T2": "lexus_parts",
// Renault — DISABLED: PL24 has suspended Renault VIN identification ("Bu marka
// için şasi numarası tanımlamasının belirsiz bir süre için mevcut olmayacağını
// üzülerek bildiririz."). renault_parts authorizes fine but every decode throws
// that message → wasted ~1s call AND it trips the PL24 circuit breaker, which
// then skips PL24 for ALL brands. PCAT + EMEX cover Renault. Re-enable when PL24
// restores Renault VIN decode.
// VF1: "renault_parts",
// VF6: "renault_parts",
// VNE: "renault_parts",
// Renault — RE-ENABLED 2026-09-20. It was disabled while PL24 had suspended
// Renault VIN identification ("Bu marka için şasi numarası tanımlamasının
// belirsiz bir süre için mevcut olmayacağını üzülerek bildiririz."), which both
// wasted a call per decode and, back then, tripped the global PL24 breaker.
// BOTH reasons are gone, each verified against prod rather than assumed:
// 1. The suspension is over — live directAccess on /p5renault for a real
// customer VIN (VF14SRCL458170337) returns resultStatus
// VEHICLE_IDENTIFIED, "SYMBOL II/LOGAN II", and the WMI service answers
// {service: renault_parts, valid: true, error: false}.
// 2. The breaker no longer counts definitive upstream negatives, only
// transient transport faults (see vehicles.service `isTransient`).
// 450 prod vehicles carry VF1 and had no PL24 catalog at all.
VF1: "renault_parts",
VF6: "renault_parts",
VNE: "renault_parts",
// Dacia
UU1: "dacia_parts",
@@ -512,6 +616,10 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
WMH: "man_parts",
// Mitsubishi
// XMC / MMC: canlı WMI servisi ikisini de mmc_parts'a çözüyor ve P5 doğruluyor
// (error:false) — 26 prod aracı haritasızdı.
MMC: "mmc_parts", // Mitsubishi Japan
XMC: "mmc_parts", // Mitsubishi (diğer pazarlar)
JMB: "mmc_parts",
JMY: "mmc_parts",
MMB: "mmc_parts",
@@ -521,6 +629,7 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
JS2: "suzuki_parts",
JS3: "suzuki_parts",
TSM: "suzuki_parts",
JSA: "suzuki_parts", // Suzuki (canlı WMI: suzuki_parts, error:false)
MA3: "suzuki_parts",
MBH: "suzuki_parts",
@@ -536,18 +645,27 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
ZFA: "fiatp_parts", // Fiat SpA Italy (most common)
ZCF: "fiatp_parts", // Tofaş Turkey (Linea, Fiorino, etc.)
ZFF: "fiatp_parts", // Abarth / Fiat Sport
ZAR: "fiatp_parts", // Alfa Romeo
ZLA: "fiatp_parts", // Lancia
ZAR: "alfa_parts", // Alfa Romeo (kendi kataloğu, /p5fiat backend)
ZLA: "lancia_parts", // Lancia (kendi kataloğu, /p5fiat backend)
// Fiat Commercial (fiatt_parts)
ZFC: "fiatt_parts", // Fiat Commercial
// Hyundai
KMH: "hyundai_parts", // Hyundai Korea Motor House
// Aşağıdakiler 2026-09-20'de canlı WMI servisinden alındı; hepsi hyundai_parts.
// TMA iki marka döndürüyor (hyundai_parts + kia_parts) — Hyundai Assan (Türkiye)
// üretimi olduğu için hyundai seçildi.
NLH: "hyundai_parts", // Hyundai Assan / Türkiye (106 prod aracı)
TMA: "hyundai_parts", // Hyundai Türkiye (49)
NLJ: "hyundai_parts", // Hyundai (8)
KMF: "hyundai_parts", // Hyundai (7)
TMK: "hyundai_parts", // Hyundai (Turkey/other markets)
// Kia
KNA: "kia_parts", // Kia (worldwide production)
KNE: "kia_parts", // Kia (canlı WMI, 31 prod aracı)
KNC: "kia_parts", // Kia (canlı WMI, 8)
U5Y: "kia_parts", // Kia Slovakia
// Nissan
@@ -563,8 +681,16 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
JNK: "infiniti_parts", // Infiniti (Japan/Korea)
// Opel / Vauxhall
// Subaru (canlı WMI decode: JF1 → subaru_parts, error:false)
JF1: "subaru_parts",
JF2: "subaru_parts",
// Jeep (Stellantis; /p5fiat backend)
"1C4": "jeep_parts",
"1J4": "jeep_parts",
W0L: "opel_parts", // Opel AG (Germany)
W0V: "opel_parts", // Opel (newer Stellantis-era WMI)
VXK: "psa_opel_parts", // PSA-platform Opel (Corsa F, Mokka B) → Stellantis kataloğu /p5psa
// Citroën (PSA)
VF7: "citroen_parts", // Citroën SA (France)
@@ -573,7 +699,19 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
// Peugeot (PSA)
VF3: "peugeot_parts", // Peugeot SA (France)
VR3: "peugeot_parts", // Peugeot (newer WMI)
VR7: "peugeot_parts", // Peugeot (newer WMI)
// VR7: PL24'ün WMI veritabanında HİÇ YOK — canlı doğrulama (2026-09-19,
// /pl24-wmi/ext/api/2.0/decode): HTTP 410 "no brands found for WMI = VR7",
// tıpkı Tofaş NM4 gibi. Önceden peugeot_parts'a yönlendiriliyordu; prod'daki
// 17 VR7 aracının hepsi model çözülmeden ("Peugeot Peugeot") ve 0 kategoriyle
// kaydedilmişti. Haritada tutmak yalnız boşuna PL24 isteği üretir ve
// pcat/emex/vinpin fallback'ini geciktirir.
// PL24'TE HİÇ OLMAYANLAR — canlı WMI servisi HTTP 410 "no brands found for WMI"
// dedi (2026-09-20), tıpkı NM4 ve VR7 gibi. Haritaya eklenmemeleri kasıtlı:
// eklemek yalnız boşuna istek üretir ve pcat/emex/vinpin fallback'ini geciktirir.
// JHM, SHH, SHS, MAK, NLA (Honda) · KL1 (Chevrolet)
// JMZ (Mazda) 410 DEĞİL ama fordp/fordt döndürüyor (Ford-Mazda platform ortaklığı
// dönemi); marka tutarsız olduğu için eklenmedi — bir Mazda 3 Ford kataloğunda yok.
// Volvo
YV1: "volvo_parts", // Volvo Cars (Sweden)
@@ -793,6 +931,13 @@ export const SERVICE_TO_BRAND: Record<string, string> = {
vauxhall_parts: "Opel",
// Volvo/Polestar
volvo_parts: "Volvo",
subaru_parts: "Subaru",
psa_opel_parts: "Opel",
psa_vauxhall_parts: "Opel",
abarth_parts: "Abarth",
alfa_parts: "Alfa Romeo",
jeep_parts: "Jeep",
lancia_parts: "Lancia",
polestar_parts: "Polestar",
// Fiat Group
fiatp_parts: "Fiat",

View File

@@ -0,0 +1,76 @@
import type { ConfigService } from "@nestjs/config";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProxyHealthService } from "./proxy-health.service";
import { isProxyConnectFailure } from "./proxy-telemetry.service";
function makeConfig(overrides: Record<string, unknown> = {}): ConfigService {
return {
get: vi.fn((key: string, def?: unknown) =>
Object.hasOwn(overrides, key) ? overrides[key] : def,
),
} as unknown as ConfigService;
}
describe("ProxyHealthService — Floxy failover gate", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("starts healthy (Floxy not down)", () => {
const h = new ProxyHealthService(makeConfig());
expect(h.isFloxyDown()).toBe(false);
});
it("trips for the cooldown window then self-heals when it lapses", () => {
const h = new ProxyHealthService(makeConfig({ FLOXY_FAILOVER_COOLDOWN_MS: 180_000 }));
h.reportFloxyFailure("test");
expect(h.isFloxyDown()).toBe(true);
expect(h.cooldownRemainingSec()).toBe(180);
vi.advanceTimersByTime(179_000);
expect(h.isFloxyDown()).toBe(true);
vi.advanceTimersByTime(2_000); // past the 180s window
expect(h.isFloxyDown()).toBe(false);
});
it("a Floxy success clears the cooldown immediately", () => {
const h = new ProxyHealthService(makeConfig({ FLOXY_FAILOVER_COOLDOWN_MS: 180_000 }));
h.reportFloxyFailure();
expect(h.isFloxyDown()).toBe(true);
h.reportFloxySuccess();
expect(h.isFloxyDown()).toBe(false);
});
it("re-reporting a failure extends the window", () => {
const h = new ProxyHealthService(makeConfig({ FLOXY_FAILOVER_COOLDOWN_MS: 100_000 }));
h.reportFloxyFailure();
vi.advanceTimersByTime(90_000);
h.reportFloxyFailure(); // extends from now
vi.advanceTimersByTime(20_000); // 110s since first failure, 20s since second
expect(h.isFloxyDown()).toBe(true);
});
});
describe("isProxyConnectFailure", () => {
it("matches proxy/tunnel connection failures (the Floxy-down signal)", () => {
for (const msg of [
"page.goto: net::ERR_TUNNEL_CONNECTION_FAILED at https://emexdwc.ae/",
"net::ERR_PROXY_CONNECTION_FAILED",
"TypeError: fetch failed",
"connect ECONNREFUSED 1.2.3.4:12321",
"Client network socket disconnected (ECONNRESET)",
]) {
expect(isProxyConnectFailure(new Error(msg)), msg).toBe(true);
}
});
it("does NOT match a plain slow-site navigation timeout", () => {
const e = new Error("Timeout 30000ms exceeded.");
e.name = "TimeoutError";
expect(isProxyConnectFailure(e)).toBe(false);
});
});

View File

@@ -0,0 +1,68 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
/**
* Shared Floxy-health gate. Floxy is the residential primary for EMEX + pcat
* egress (HTTP fetch, Playwright capture, browser scrape). When Floxy goes dark
* — balance exhausted (proxy returns 402) or the entry node refuses CONNECT
* (`ERR_TUNNEL_CONNECTION_FAILED` / `fetch failed`) — every consumer otherwise
* keeps hammering the dead proxy and the decode success rate collapses (this
* happened 2026-06-11/13: ~17k tunnel failures/24h, decode 78% → 45%).
*
* This singleton is the one place that answers "should we route through Floxy
* right now?". Any consumer that sees a Floxy transport failure trips the gate;
* for the cooldown window all consumers fail over to the DataImpulse datacenter
* pool instead. The gate self-heals: once the cooldown lapses, the next request
* probes Floxy again and `reportFloxySuccess()` clears it (or re-trips if still
* down). A live Floxy answer at any time clears the cooldown immediately.
*
* In-memory only and deliberately dependency-light (no DB, no Redis): a single
* api/worker process is the egress unit, and a stale cooldown is self-correcting
* within one window. Cooldown is tunable via FLOXY_FAILOVER_COOLDOWN_MS.
*/
@Injectable()
export class ProxyHealthService {
private readonly logger = new Logger(ProxyHealthService.name);
private readonly cooldownMs: number;
private floxyDownUntil = 0;
constructor(private readonly config: ConfigService) {
const raw = Number(this.config.get("FLOXY_FAILOVER_COOLDOWN_MS", 180_000));
this.cooldownMs = Number.isFinite(raw) && raw >= 0 ? raw : 180_000;
}
/** True while Floxy is in cooldown — consumers should route via DataImpulse. */
isFloxyDown(): boolean {
return Date.now() < this.floxyDownUntil;
}
/**
* Floxy transport failure observed — open (or extend) the cooldown so every
* consumer fails over to DataImpulse. Logged once per outage, not per failure,
* so a 6k-failure storm doesn't flood the logs.
*/
reportFloxyFailure(reason?: string): void {
const wasDown = this.isFloxyDown();
this.floxyDownUntil = Date.now() + this.cooldownMs;
if (!wasDown) {
this.logger.warn(
`Floxy proxy unhealthy${reason ? ` (${reason})` : ""} — failing over to DataImpulse for ${Math.round(
this.cooldownMs / 1000,
)}s`,
);
}
}
/** A Floxy request just succeeded — Floxy is healthy again, clear the gate. */
reportFloxySuccess(): void {
if (this.isFloxyDown()) {
this.logger.log("Floxy proxy recovered — resuming Floxy-primary routing");
}
this.floxyDownUntil = 0;
}
/** Seconds remaining in the current cooldown (0 when healthy). */
cooldownRemainingSec(): number {
return Math.max(0, Math.ceil((this.floxyDownUntil - Date.now()) / 1000));
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { ProxyHealthService } from "./proxy-health.service";
import { ProxyTelemetryService } from "./proxy-telemetry.service";
// Imported by PartsCatalogsModule and EmexModule; Nest module caching makes the
// services singletons (one shared flush buffer / one shared Floxy-health gate)
// across both.
@Module({
providers: [ProxyTelemetryService, ProxyHealthService],
exports: [ProxyTelemetryService, ProxyHealthService],
})
export class ProxyTelemetryModule {}

View File

@@ -0,0 +1,175 @@
import { Inject, Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
import { DATABASE, type Database } from "../../database/database.provider";
import { proxyLogs } from "../../database/schema/core";
/**
* Proxy telemetry — fire-and-forget logging of every proxied upstream attempt
* (pcat call/capture/validate, emex http, pl24 catalog/auth) into `proxy_logs`, so the Süper Panel
* can grade proxy providers per service and track banned exit IPs / sessions.
*
* Design constraints:
* - MUST never affect the request path: record() only pushes to an in-memory
* buffer; a timer flushes batches, and every DB error is swallowed.
* - Bounded memory: the buffer is capped; when full, oldest rows are dropped.
*/
export type ProxyServiceLeg =
| "pcat_call"
| "pcat_capture"
| "pcat_validate"
| "emex_http"
| "pl24_http"
| "pl24_auth";
export type ProxyProviderName = "floxy" | "dataimpulse" | "none";
export interface ProxyLogEvent {
service: ProxyServiceLeg;
provider: ProxyProviderName;
sessionKey?: string | null;
exitIp?: string | null;
targetHost?: string | null;
statusCode?: number | null;
errorKind?: string | null;
success: boolean;
durationMs?: number | null;
}
const FLUSH_INTERVAL_MS = 5_000;
const FLUSH_BATCH_SIZE = 50;
const BUFFER_CAP = 500;
// Upstream statuses that signal an IP-level block (not an auth/data problem):
// 403 = forbidden (WAF/geo/IP ban), 429 = rate limited. 402 is the proxy
// provider itself rejecting (balance exhausted — Floxy did this 2026-06-10);
// counted as banned=false but worth surfacing via status_code.
export function isBanStatus(status: number | null | undefined): boolean {
return status === 403 || status === 429;
}
/** Classify a transport error (no HTTP answer) into a coarse error_kind. */
export function classifyTransportError(err: unknown): string {
const e = err as Error & { cause?: unknown };
const text = `${e?.name ?? ""} ${e?.message ?? ""} ${String(e?.cause ?? "")}`;
if (/TimeoutError|UND_ERR_CONNECT_TIMEOUT|UND_ERR_HEADERS_TIMEOUT/i.test(text)) return "timeout";
if (/AbortError|aborted/i.test(text)) return "aborted";
if (/ECONNREFUSED|UND_ERR_SOCKET|connect/i.test(text)) return "connect";
if (/ECONNRESET|socket hang up|other side closed|terminated/i.test(text)) return "reset";
if (/EAI_AGAIN|ENOTFOUND/i.test(text)) return "dns";
return "transport";
}
/**
* True when an error means we could not even reach the upstream THROUGH the
* proxy — the proxy entry node refused/closed/reset the CONNECT, or undici/
* Chromium reported a tunnel/proxy connection failure ("fetch failed",
* ERR_TUNNEL_CONNECTION_FAILED, ERR_PROXY_CONNECTION_FAILED, ECONNREFUSED…).
* This is the "the proxy itself is dead" signal that should trip Floxy failover.
*
* Deliberately EXCLUDES a plain navigation/request TimeoutError with no proxy
* marker: a slow-but-alive residential exit shouldn't trip failover — only a
* genuine connection failure should.
*/
export function isProxyConnectFailure(err: unknown): boolean {
const e = err as Error & { cause?: unknown };
const text = `${e?.name ?? ""} ${e?.message ?? ""} ${String(e?.cause ?? "")}`;
return /ERR_TUNNEL_CONNECTION_FAILED|ERR_PROXY_CONNECTION_FAILED|ERR_SOCKS_CONNECTION_FAILED|NS_ERROR_PROXY|ECONNREFUSED|ECONNRESET|UND_ERR_SOCKET|UND_ERR_CONNECT_TIMEOUT|socket hang up|other side closed|tunnel|proxy connection|fetch failed/i.test(
text,
);
}
/**
* Derive provider + session key from a proxy URL the way the integrations
* build them: Floxy passwords may embed `_session-<id>_lifetime-<n>` (sticky);
* DataImpulse rotates by port, so the port IS the session identity.
*/
export function describeProxyUrl(proxyUrl: string | null | undefined): {
provider: ProxyProviderName;
sessionKey: string | null;
} {
if (!proxyUrl) return { provider: "none", sessionKey: null };
try {
const u = new URL(proxyUrl);
if (/floxy/i.test(u.hostname)) {
const m = decodeURIComponent(u.password).match(/_session-([A-Za-z0-9]+)/);
return { provider: "floxy", sessionKey: m ? `s-${m[1]}` : null };
}
return { provider: "dataimpulse", sessionKey: `di:${u.port}` };
} catch {
return { provider: "none", sessionKey: null };
}
}
/**
* Resolve the residential exit IP of a (sticky) proxy session with one cheap
* probe through the same proxy. Only meaningful for sticky sessions — on a
* rotating leg the probe and the real request exit from different IPs.
*/
export async function resolveExitIp(proxyUrl: string, timeoutMs = 5_000): Promise<string | null> {
try {
const { ProxyAgent } = await import("undici");
const res = await fetch("https://api.ipify.org?format=text", {
dispatcher: new ProxyAgent({ uri: proxyUrl, connect: { timeout: timeoutMs } }),
signal: AbortSignal.timeout(timeoutMs),
} as unknown as RequestInit);
if (!res.ok) return null;
const ip = (await res.text()).trim();
return /^[0-9a-fA-F.:]{3,45}$/.test(ip) ? ip : null;
} catch {
return null;
}
}
@Injectable()
export class ProxyTelemetryService implements OnModuleDestroy {
private readonly logger = new Logger(ProxyTelemetryService.name);
private buffer: ProxyLogEvent[] = [];
private flushTimer: NodeJS.Timeout | null = null;
private flushing = false;
constructor(@Inject(DATABASE) private readonly db: Database) {
this.flushTimer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
// Never keep the process alive just to flush telemetry.
this.flushTimer.unref?.();
}
/** Queue one proxied-attempt record. Never throws, never blocks. */
record(event: ProxyLogEvent): void {
if (this.buffer.length >= BUFFER_CAP) this.buffer.shift();
this.buffer.push(event);
if (this.buffer.length >= FLUSH_BATCH_SIZE) void this.flush();
}
private async flush(): Promise<void> {
if (this.flushing || this.buffer.length === 0) return;
this.flushing = true;
const batch = this.buffer.splice(0, FLUSH_BATCH_SIZE * 2);
try {
await this.db.insert(proxyLogs).values(
batch.map((e) => ({
service: e.service,
provider: e.provider,
sessionKey: e.sessionKey ?? null,
exitIp: e.exitIp ?? null,
targetHost: e.targetHost?.slice(0, 120) ?? null,
statusCode: e.statusCode ?? null,
errorKind: e.errorKind ?? null,
success: e.success,
banned: isBanStatus(e.statusCode),
durationMs: e.durationMs != null ? Math.round(e.durationMs) : null,
})),
);
} catch (err) {
// Telemetry must never matter more than the product. Drop the batch.
this.logger.debug(
`proxy_logs flush failed (${batch.length} rows): ${(err as Error).message}`,
);
} finally {
this.flushing = false;
}
}
async onModuleDestroy(): Promise<void> {
if (this.flushTimer) clearInterval(this.flushTimer);
await this.flush();
}
}

View File

@@ -0,0 +1,148 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { RpartstoreAuthError, decodeJwtExpiry, loginRpartstore } from "./rpartstore.auth";
const b64url = (s: string): string => Buffer.from(s).toString("base64url");
const makeJwt = (claims: Record<string, unknown>): string =>
`${b64url('{"alg":"RS256"}')}.${b64url(JSON.stringify(claims))}.sig`;
function jsonResponse(
body: unknown,
init: { status?: number; headers?: Record<string, string> } = {},
) {
return new Response(JSON.stringify(body), {
status: init.status ?? 200,
headers: { "content-type": "application/json", ...(init.headers ?? {}) },
});
}
describe("loginRpartstore (Okta IDX, browser-free)", () => {
afterEach(() => vi.unstubAllGlobals());
it("walks authorize → introspect → identify → answer → redirect → token and returns the access token", async () => {
const exp = Math.floor(Date.now() / 1000) + 3600;
const jwt = makeJwt({ sub: "G123326", exp });
const calls: { url: string; body?: string; cookie?: string }[] = [];
let redirectState = "";
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
const u = String(url);
const headers = (init?.headers ?? {}) as Record<string, string>;
calls.push({
url: u,
body: init?.body ? String(init.body) : undefined,
cookie: headers.Cookie,
});
if (u.includes("/v1/authorize")) {
redirectState = new URL(u).searchParams.get("state") ?? "";
return new Response("<script>var stateToken = '02.id.abc\\x2Ddef';</script>", {
status: 200,
headers: { "set-cookie": "JSESSIONID=js1; Path=/; HttpOnly" },
});
}
if (u.endsWith("/idp/idx/introspect")) return jsonResponse({ stateHandle: "sh-1" });
if (u.endsWith("/idp/idx/identify")) return jsonResponse({ stateHandle: "sh-2" });
if (u.endsWith("/idp/idx/challenge/answer")) {
return jsonResponse({
success: { href: "https://sso.renault.com/login/token/redirect?stateToken=st" },
});
}
if (u.includes("/login/token/redirect")) {
return new Response(null, {
status: 302,
headers: {
location: `https://rpartstore.renault.com/idp-redirect?code=CODE1&state=${redirectState}`,
},
});
}
if (u.endsWith("/v1/token")) {
return jsonResponse({
token_type: "Bearer",
expires_in: 3600,
access_token: jwt,
scope: "openid",
});
}
throw new Error(`unexpected url ${u}`);
});
const token = await loginRpartstore({
username: "G123326",
password: "pw",
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(token.accessToken).toBe(jwt);
expect(token.subject).toBe("G123326");
expect(token.expiresAt).toBe(exp * 1000);
// stateToken unescaped (\x2D → "-") and carried into introspect
expect(calls[1].body).toBe(JSON.stringify({ stateToken: "02.id.abc-def" }));
// identify uses the introspect handle, answer the identify handle
expect(JSON.parse(calls[2].body!)).toEqual({ identifier: "G123326", stateHandle: "sh-1" });
expect(JSON.parse(calls[3].body!)).toEqual({
credentials: { passcode: "pw" },
stateHandle: "sh-2",
});
// cookies from the authorize page are replayed on later hops
expect(calls[3].cookie).toContain("JSESSIONID=js1");
// PKCE: the token exchange sends the code and a verifier, never the password
const tokenBody = new URLSearchParams(calls.at(-1)!.body);
expect(tokenBody.get("grant_type")).toBe("authorization_code");
expect(tokenBody.get("code")).toBe("CODE1");
expect(tokenBody.get("code_verifier")).toBeTruthy();
expect(calls.at(-1)!.body).not.toContain("pw");
});
it("fails with a challenge error when Okta returns no success redirect (bad password / MFA)", async () => {
const fetchImpl = vi.fn(async (url: string | URL) => {
const u = String(url);
if (u.includes("/v1/authorize")) return new Response("stateToken = 'x'", { status: 200 });
if (u.endsWith("/introspect")) return jsonResponse({ stateHandle: "sh" });
if (u.endsWith("/identify")) return jsonResponse({ stateHandle: "sh" });
if (u.endsWith("/challenge/answer")) {
return jsonResponse({ messages: { value: [{ message: "Authentication failed" }] } });
}
throw new Error(`unexpected url ${u}`);
});
await expect(
loginRpartstore({
username: "u",
password: "bad",
fetchImpl: fetchImpl as unknown as typeof fetch,
}),
).rejects.toMatchObject({ name: "RpartstoreAuthError", step: "challenge" });
});
it("rejects an OAuth state mismatch on the callback", async () => {
const fetchImpl = vi.fn(async (url: string | URL) => {
const u = String(url);
if (u.includes("/v1/authorize")) return new Response("stateToken = 'x'", { status: 200 });
if (u.endsWith("/introspect")) return jsonResponse({ stateHandle: "sh" });
if (u.endsWith("/identify")) return jsonResponse({ stateHandle: "sh" });
if (u.endsWith("/challenge/answer"))
return jsonResponse({ success: { href: "https://sso.renault.com/r" } });
if (u === "https://sso.renault.com/r") {
return new Response(null, {
status: 302,
headers: { location: "https://rpartstore.renault.com/idp-redirect?code=C&state=forged" },
});
}
throw new Error(`unexpected url ${u}`);
});
await expect(
loginRpartstore({
username: "u",
password: "p",
fetchImpl: fetchImpl as unknown as typeof fetch,
}),
).rejects.toBeInstanceOf(RpartstoreAuthError);
});
it("decodes exp/sub from the access token", () => {
expect(decodeJwtExpiry(makeJwt({ sub: "G1", exp: 1790334740 }))).toEqual({
exp: 1790334740,
sub: "G1",
});
expect(() => decodeJwtExpiry(makeJwt({ sub: "G1" }))).toThrow(RpartstoreAuthError);
});
});

View File

@@ -0,0 +1,238 @@
/**
* Browser-free Okta (OIE / IDX) login for rpartstore.renault.com.
*
* Flow (verified live 2026-09-25, ~1.8 s, no MFA / captcha):
* 1. GET {issuer}/v1/authorize?...PKCE... → hosted page HTML containing `stateToken`
* 2. POST /idp/idx/introspect {stateToken} → stateHandle
* 3. POST /idp/idx/identify {identifier, stateHandle}
* 4. POST /idp/idx/challenge/answer {credentials:{passcode}, stateHandle} → success.href
* 5. GET success.href (follow redirects manually) → …/idp-redirect?code=…&state=…
* 6. POST {issuer}/v1/token (authorization_code + code_verifier) → access_token (1 h, no refresh_token)
*/
import { createHash, randomBytes } from "node:crypto";
export interface RpartstoreAuthConfig {
username: string;
password: string;
issuer?: string;
clientId?: string;
redirectUri?: string;
scope?: string;
/** Injectable for tests. */
fetchImpl?: typeof fetch;
}
export interface RpartstoreToken {
accessToken: string;
/** Epoch ms. */
expiresAt: number;
subject: string;
}
export class RpartstoreAuthError extends Error {
constructor(
message: string,
readonly step: string,
readonly detail?: unknown,
) {
super(message);
this.name = "RpartstoreAuthError";
}
}
const DEFAULTS = {
issuer: "https://sso.renault.com/oauth2/aus133y6mks4ptDss417",
clientId: "irn-72795_ope_pkce_4hcafvxlbcil",
redirectUri: "https://rpartstore.renault.com/idp-redirect",
scope: "openid alliance_profile apis.default",
};
const USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36";
const ION = "application/ion+json; okta-version=1.0.0";
const b64url = (buf: Buffer): string => buf.toString("base64url");
/** Tiny cookie jar: Okta needs `idx`/`JSESSIONID` cookies across the IDX steps. */
class CookieJar {
private readonly jar = new Map<string, string>();
absorb(res: Response): void {
const setCookies: string[] =
typeof (res.headers as { getSetCookie?: () => string[] }).getSetCookie === "function"
? (res.headers as unknown as { getSetCookie: () => string[] }).getSetCookie()
: [];
for (const sc of setCookies) {
const kv = sc.split(";")[0];
const i = kv.indexOf("=");
if (i > 0) this.jar.set(kv.slice(0, i).trim(), kv.slice(i + 1).trim());
}
}
header(): string {
return Array.from(this.jar.entries())
.map(([k, v]) => `${k}=${v}`)
.join("; ");
}
}
export function decodeJwtExpiry(accessToken: string): { exp: number; sub: string } {
const payload = JSON.parse(
Buffer.from(accessToken.split(".")[1], "base64url").toString("utf8"),
) as {
exp?: number;
sub?: string;
};
if (!payload.exp) throw new RpartstoreAuthError("access token has no exp claim", "token");
return { exp: payload.exp, sub: payload.sub ?? "" };
}
export async function loginRpartstore(cfg: RpartstoreAuthConfig): Promise<RpartstoreToken> {
const issuer = cfg.issuer ?? DEFAULTS.issuer;
const clientId = cfg.clientId ?? DEFAULTS.clientId;
const redirectUri = cfg.redirectUri ?? DEFAULTS.redirectUri;
const scope = cfg.scope ?? DEFAULTS.scope;
const doFetch = cfg.fetchImpl ?? fetch;
const jar = new CookieJar();
const request = async (url: string, init: RequestInit = {}): Promise<Response> => {
const res = await doFetch(url, {
redirect: "manual",
...init,
headers: {
"User-Agent": USER_AGENT,
Cookie: jar.header(),
...(init.headers as Record<string, string> | undefined),
},
});
jar.absorb(res);
return res;
};
const verifier = b64url(randomBytes(48));
const challenge = b64url(createHash("sha256").update(verifier).digest());
const state = randomBytes(16).toString("hex");
const nonce = randomBytes(16).toString("hex");
// 1. hosted authorize page → stateToken
const authorize = new URL(`${issuer}/v1/authorize`);
for (const [k, v] of Object.entries({
client_id: clientId,
redirect_uri: redirectUri,
response_type: "code",
scope,
state,
nonce,
code_challenge: challenge,
code_challenge_method: "S256",
response_mode: "query",
})) {
authorize.searchParams.set(k, v);
}
const authorizeRes = await request(authorize.toString());
const html = await authorizeRes.text();
const stateTokenMatch =
html.match(/stateToken\s*=\s*'([^']+)'/) ?? html.match(/"stateToken":"([^"]+)"/);
if (authorizeRes.status !== 200 || !stateTokenMatch) {
throw new RpartstoreAuthError(
`authorize page did not expose a stateToken (HTTP ${authorizeRes.status})`,
"authorize",
);
}
const stateToken = stateTokenMatch[1].replace(/\\x([0-9A-Fa-f]{2})/g, (_, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16)),
);
const idx = async (
path: string,
body: Record<string, unknown>,
step: string,
): Promise<Record<string, any>> => {
const res = await request(`https://sso.renault.com/idp/idx/${path}`, {
method: "POST",
headers: { "Content-Type": ION, Accept: ION, Origin: "https://sso.renault.com" },
body: JSON.stringify(body),
});
const json = (await res.json().catch(() => ({}))) as Record<string, any>;
if (!res.ok) {
throw new RpartstoreAuthError(
`Okta ${step} failed (HTTP ${res.status})`,
step,
json.messages ?? json,
);
}
return json;
};
// 2-4. IDX remediation
const introspected = await idx("introspect", { stateToken }, "introspect");
const identified = await idx(
"identify",
{ identifier: cfg.username, stateHandle: introspected.stateHandle },
"identify",
);
const answered = await idx(
"challenge/answer",
{
credentials: { passcode: cfg.password },
stateHandle: identified.stateHandle ?? introspected.stateHandle,
},
"challenge",
);
const successHref: string | undefined = answered.success?.href;
if (!successHref) {
throw new RpartstoreAuthError(
"Okta did not return a success redirect (wrong password, locked account or MFA now required)",
"challenge",
answered.messages,
);
}
// 5. follow the redirect chain until the app callback carries ?code=
let next = successHref;
let code: string | undefined;
for (let hop = 0; hop < 6 && !code; hop += 1) {
const res = await request(next);
const location = res.headers.get("location");
if (!location) break;
const target = new URL(location, next);
const gotCode = target.searchParams.get("code");
if (gotCode) {
if (target.searchParams.get("state") !== state)
throw new RpartstoreAuthError("OAuth state mismatch", "redirect");
code = gotCode;
}
next = target.toString();
}
if (!code)
throw new RpartstoreAuthError("redirect chain ended without an authorization code", "redirect");
// 6. PKCE token exchange
const tokenRes = await request(`${issuer}/v1/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Origin: "https://rpartstore.renault.com",
},
body: new URLSearchParams({
grant_type: "authorization_code",
redirect_uri: redirectUri,
code,
code_verifier: verifier,
client_id: clientId,
}).toString(),
});
const token = (await tokenRes.json().catch(() => ({}))) as {
access_token?: string;
error?: string;
error_description?: string;
};
if (!tokenRes.ok || !token.access_token) {
throw new RpartstoreAuthError(
`token exchange failed (HTTP ${tokenRes.status}): ${token.error_description ?? token.error ?? ""}`,
"token",
);
}
const { exp, sub } = decodeJwtExpiry(token.access_token);
return { accessToken: token.access_token, expiresAt: exp * 1000, subject: sub };
}

View File

@@ -0,0 +1,157 @@
import { type RpartstoreToken, loginRpartstore } from "./rpartstore.auth";
import { canonicalRpartstoreBrand } from "./rpartstore.routing";
import {
RpartstoreBffError,
RpartstoreRateLimitError,
RpartstoreSession,
} from "./rpartstore.session";
import type {
RpartstoreDecoded,
RpartstoreVehicle,
SearchVehicleResponsePayload,
} from "./rpartstore.types";
export const RPARTSTORE_DEFAULTS = {
brokerUrl: "wss://1po-bff.renault-edh.com/ws",
appVersion: "1.34.0.6",
webLanguage: "tr",
country: "TR",
} as const;
/** Where the (1 h, non-refreshable) Okta access token is cached between decodes. */
export interface TokenStore {
get(): Promise<RpartstoreToken | null>;
set(token: RpartstoreToken, ttlSeconds: number): Promise<void>;
clear(): Promise<void>;
}
export interface RpartstoreClientOptions {
username: string;
password: string;
tokenStore: TokenStore;
brokerUrl?: string;
appVersion?: string;
webLanguage?: string;
country?: string;
/** Injectable for tests. */
login?: typeof loginRpartstore;
sessionFactory?: (opts: ConstructorParameters<typeof RpartstoreSession>[0]) => RpartstoreSession;
logger?: { log: (msg: string) => void; warn: (msg: string) => void };
}
/** Refresh the token this many ms before its `exp`. */
const TOKEN_SKEW_MS = 5 * 60 * 1000;
export class RpartstoreClient {
private readonly login: typeof loginRpartstore;
private readonly sessionFactory: NonNullable<RpartstoreClientOptions["sessionFactory"]>;
constructor(private readonly opts: RpartstoreClientOptions) {
this.login = opts.login ?? loginRpartstore;
this.sessionFactory = opts.sessionFactory ?? ((o) => new RpartstoreSession(o));
}
/** Cached token when still valid (with skew), otherwise a fresh Okta login. */
async getToken(force = false): Promise<RpartstoreToken> {
if (!force) {
const cached = await this.opts.tokenStore.get();
if (cached && cached.expiresAt - Date.now() > TOKEN_SKEW_MS) return cached;
}
const token = await this.login({ username: this.opts.username, password: this.opts.password });
const ttl = Math.max(60, Math.floor((token.expiresAt - Date.now() - TOKEN_SKEW_MS) / 1000));
await this.opts.tokenStore.set(token, ttl);
this.opts.logger?.log(`[rpartstore] logged in as ${token.subject}, token ttl ${ttl}s`);
return token;
}
/**
* One VIN search on a fresh STOMP session (volume is capped at a handful per
* day, so a persistent socket buys nothing). Resolves to the decoded vehicle,
* `null` when RPartStore answers NOT_FOUND, and throws `RpartstoreRateLimitError`
* / other errors for the caller to handle. A CONNECT failure is retried once
* with a forced re-login (expired or revoked token).
*/
async searchVin(vin: string): Promise<RpartstoreDecoded | null> {
let token = await this.getToken();
const session = await this.openSession(token.accessToken).catch(async (err: Error) => {
this.opts.logger?.warn(`[rpartstore] CONNECT failed (${err.message}); re-login and retry`);
await this.opts.tokenStore.clear();
token = await this.getToken(true);
return this.openSession(token.accessToken);
});
try {
const country = this.opts.country ?? RPARTSTORE_DEFAULTS.country;
const lang = this.opts.webLanguage ?? RPARTSTORE_DEFAULTS.webLanguage;
const msg = await session.request<SearchVehicleResponsePayload>(
"/vehicles/search/vin-or-vrn",
{
requestId: crypto.randomUUID(),
value: vin,
queryType: "VIN",
userContext: {
webLanguage: lang,
documentLanguage: lang,
documentCountryLanguage: country,
documentFallbackLanguage: lang,
documentFallbackCountryLanguage: country,
userCountry: country,
r1Country: country,
},
countryCode: country,
includeEstimate: false,
},
{ expect: ["1PO/CATALOG/SEARCH_VEHICLE_RESPONSE"], timeoutMs: 15_000 },
);
const vehicle = msg.payload?.vehicles?.[0];
if (!vehicle) return null;
return normalizeVehicle(vehicle);
} catch (err) {
if (err instanceof RpartstoreBffError) {
const errorType = (err.payload as { errorType?: string } | undefined)?.errorType;
if (err.type === "1PO/CATALOG/SEARCH_VEHICLE_ERROR" && errorType === "NOT_FOUND")
return null;
if (err.type === "1PO/CATALOG/SEARCH_VEHICLE_NOT_COVERED_IN_COUNTRY") return null;
}
if (err instanceof RpartstoreRateLimitError) throw err;
throw err;
} finally {
session.close();
}
}
private async openSession(accessToken: string): Promise<RpartstoreSession> {
const session = this.sessionFactory({
brokerUrl: this.opts.brokerUrl ?? RPARTSTORE_DEFAULTS.brokerUrl,
accessToken,
profile: this.opts.username,
appVersion: this.opts.appVersion ?? RPARTSTORE_DEFAULTS.appVersion,
webLanguage: this.opts.webLanguage ?? RPARTSTORE_DEFAULTS.webLanguage,
logger: this.opts.logger
? { debug: () => undefined, warn: (m) => this.opts.logger?.warn(`[rpartstore] ${m}`) }
: undefined,
});
await session.connect();
return session;
}
}
export function normalizeVehicle(v: RpartstoreVehicle): RpartstoreDecoded {
const dh = v.dataHubVehicle ?? {};
const brandName = canonicalRpartstoreBrand(v.vehicleBrand) ?? v.vehicleBrand;
const clean = (s: string | undefined): string | null => {
const t = (s ?? "").trim();
return t.length > 0 ? t : null;
};
return {
brandName,
model: clean(v.model),
modelCode: clean(dh.modelTypeCode),
familyCode: clean(dh.familyCode),
modelYear: clean(dh.modelYear),
engine: clean(dh.engine),
gearbox: clean(dh.gearbox),
energyType: clean(dh.energyType),
manufacturingDate: clean(v.manufacturingDate),
raw: v,
};
}

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import {
type RpartstoreCatalogCandidate,
pickRpartstoreCatalogMatch,
rpartstoreModelTokens,
} from "./rpartstore.matcher";
// Real PL24 `catalog_vehicles.model` values for Renault (prod, 2026-09-25).
const RENAULT = [
"ALASKAN",
"ARKANA EUROPE / XM3",
"ARKANA RUSYA",
"AUSTRAL/ESPACE VI/RAFALE",
"CAPTUR / QM3",
"CAPTUR II EUROPE/SYMBIOZ",
"CAPTUR II ÇİN",
"CAPTUR/KAPTUR",
"CLIO 4 / LUTECIA 4",
"CLIO 5/LUTECIA 5",
"DUSTER 1",
"DUSTER 2",
"DUSTER III / BIGSTER",
"EXPRESS",
"FLUENCE / FLUENCE Z.E.",
"KADJAR",
"KADJAR ÇİN",
"KANGOO 1",
"KANGOO 2 / KANGOO Z.E.",
"KANGOO 3",
"LATITUDE / SAFRANE 2",
"LOGAN\\-SANDERO 1/TONDAR 1",
"LOGAN\\-SANDERO 3 / TALIANT",
"MASTER 3",
"MASTER 4 VAN",
"MEGANE 1",
"MEGANE 2 / SCENIC 2",
"MEGANE 3 / SCENIC 3",
"MEGANE 4",
"MEGANE 4 SEDAN",
"RENAULT 5 EXPRESS / RAPID",
"RENAULT 9 / 11",
"TRAFIC 2",
"TRAFIC 3",
"X62 CHINE",
];
const DACIA = [
"DOKKER",
"DUSTER 1",
"DUSTER 2",
"DUSTER 3/BIGSTER",
"LOGAN\\-SANDERO 1/TONDAR 1",
"LOGAN\\-SANDERO 2/SYMBOL 2",
"LOGAN\\-SANDERO 3 / TALIANT",
];
const cands = (models: string[]): RpartstoreCatalogCandidate[] =>
models.map((model) => ({ id: model, model }));
describe("rpartstoreModelTokens", () => {
it("drops the model code in parentheses and converts roman generations", () => {
expect(rpartstoreModelTokens("Clio IV / Lutecia IV (B98)")).toEqual([
"CLIO",
"4",
"LUTECIA",
"4",
]);
expect(rpartstoreModelTokens("Duster III (SUV)")).toEqual(["DUSTER", "3"]);
expect(rpartstoreModelTokens("Megane I Classic (L64)")).toEqual(["MEGANE", "1", "CLASSIC"]);
});
it("keeps single-digit generation tokens and folds diacritics", () => {
expect(rpartstoreModelTokens("MEGANE 4 SEDAN")).toEqual(["MEGANE", "4", "SEDAN"]);
expect(rpartstoreModelTokens("KADJAR ÇİN")).toEqual(["KADJAR", "CIN"]);
expect(rpartstoreModelTokens("LOGAN\\-SANDERO 3 / TALIANT")).toEqual([
"LOGAN",
"SANDERO",
"3",
"TALIANT",
]);
});
});
describe("pickRpartstoreCatalogMatch (prod benchmark models → PL24 Renault catalog)", () => {
const expectPick = (model: string, expected: string | null, pool = RENAULT) =>
expect(pickRpartstoreCatalogMatch(model, cands(pool))).toBe(expected);
it("matches roman-numeral generations to digit generations", () => {
expectPick("Clio IV / Lutecia IV (B98)", "CLIO 4 / LUTECIA 4");
expectPick("Trafic III (J82)", "TRAFIC 3");
expectPick("Master III (F62)", "MASTER 3");
expectPick("Kangoo II (K61)", "KANGOO 2 / KANGOO Z.E.");
expectPick("Logan III (LJF)", "LOGAN\\-SANDERO 3 / TALIANT");
expectPick("Duster III (SUV)", "DUSTER III / BIGSTER");
});
it("prefers the body-qualified catalog when the decode carries the qualifier, and the plain one otherwise", () => {
expectPick("Megane IV Sedan (LFF)", "MEGANE 4 SEDAN");
expectPick("Megane IV (BFB)", "MEGANE 4");
expectPick("Megane I Classic (L64)", "MEGANE 1");
});
it("never picks a wrong-market catalog when a mainstream one exists", () => {
expectPick("Kadjar (HFE)", "KADJAR");
expectPick("Captur II (HJB)", "CAPTUR II EUROPE/SYMBIOZ");
});
it("does not let a newer generation steal a decode without a generation", () => {
expectPick("Captur (J87)", "CAPTUR / QM3");
expectPick("Express (KJK)", "EXPRESS");
});
it("matches multi-name catalogs and pre-2000 models", () => {
expectPick("Latitude / Safrane II (L43)", "LATITUDE / SAFRANE 2");
expectPick("Fluence (L38)", "FLUENCE / FLUENCE Z.E.");
expectPick("Renault 9 / 11 (L42)", "RENAULT 9 / 11");
});
it("matches Dacia models within the Dacia catalog", () => {
expectPick("Duster I (H79)", "DUSTER 1", DACIA);
expectPick("Duster II (HJD)", "DUSTER 2", DACIA);
expectPick("Dokker (K67)", "DOKKER", DACIA);
expectPick("Logan I (L90)", "LOGAN\\-SANDERO 1/TONDAR 1", DACIA);
});
it("returns null when nothing qualifies or the model is missing", () => {
expectPick("Arkana (LJL)", "ARKANA EUROPE / XM3");
expectPick("Twingo Z.E.", null);
expect(pickRpartstoreCatalogMatch(undefined, cands(RENAULT))).toBeNull();
expect(pickRpartstoreCatalogMatch("", cands(RENAULT))).toBeNull();
});
it("breaks exact ties by the fuller catalog", () => {
const pool: RpartstoreCatalogCandidate[] = [
{ id: "a", model: "KADJAR", categoryCount: 12 },
{ id: "b", model: "KADJAR", categoryCount: 44 },
];
expect(pickRpartstoreCatalogMatch("Kadjar (HFE)", pool)).toBe("b");
});
});

View File

@@ -0,0 +1,133 @@
import { MARKET_QUALIFIERS } from "../vinpin/vinpin.matcher";
/**
* Map an RPartStore model label ("Megane IV Sedan (LFF)", "Clio IV / Lutecia IV
* (B98)", "Duster III (SUV)") onto an EXISTING PL24 `catalog_vehicles` row of the
* same brand ("MEGANE 4 SEDAN", "CLIO 4 / LUTECIA 4", "DUSTER III / BIGSTER").
*
* Differences from the Vinpin matcher that made a dedicated picker necessary:
* - RPartStore writes generations as roman numerals, PL24 mostly as digits
* ("IV" vs "4") — both sides are normalised to digits.
* - Generation digits are single characters; the Vinpin tokenizer drops tokens
* shorter than 2 chars, which would make "MEGANE 4" ≡ "MEGANE".
* - Body qualifiers (SEDAN, CLASSIC, …) are optional on the catalog side:
* "Megane I Classic" must still match "MEGANE 1".
* - PL24 Renault/Dacia rows carry no year, so there is no year scoring.
*/
export interface RpartstoreCatalogCandidate {
id: string;
model: string | null;
/** Tiebreak only: fuller catalog wins. */
categoryCount?: number | null;
}
const ROMAN: Record<string, string> = {
I: "1",
II: "2",
III: "3",
IV: "4",
V: "5",
VI: "6",
VII: "7",
VIII: "8",
};
/** Body / trim words that PL24 may omit; they only add a small bonus when both sides have them. */
const BODY_QUALIFIERS = new Set<string>([
"SEDAN",
"CLASSIC",
"HATCHBACK",
"HB",
"ESTATE",
"GRANDTOUR",
"SPORTTOURER",
"SW",
"BREAK",
"VAN",
"COMBI",
"KOMBI",
"CABRIO",
"COUPE",
"SASI",
"CHASSIS",
"PICKUP",
"PHASE",
"PH",
]);
/** Tokenize a model label: fold diacritics, drop parenthetical codes/years, split on
* non-alphanumerics, convert roman generation numerals to digits. Keeps 1-char
* numeric tokens (generations) and drops 1-char alpha noise ("Z.E." → "ZE" kept
* as-is is not needed for matching). */
export function rpartstoreModelTokens(s: string | null | undefined): string[] {
if (!s) return [];
return s
.toUpperCase()
.normalize("NFD")
.replace(/\p{M}/gu, "")
.replace(/\([^)]*\)/g, " ")
.replace(/[^A-Z0-9]+/g, " ")
.split(" ")
.map((t) => t.trim())
.filter((t) => t.length > 0)
.map((t) => ROMAN[t] ?? t)
.filter((t) => t.length >= 2 || /^\d$/.test(t));
}
const isNumeric = (t: string): boolean => /^\d+$/.test(t);
/**
* Returns the id of the best candidate or null. A candidate qualifies when every
* "core" decoded token (everything except body qualifiers) appears among its
* tokens. Score, strongest first: market-qualifier penalty (wrong-market catalogs
* only win when alone), exact-match bonus, body-qualifier overlap bonus, a hard
* penalty for candidates that add a generation number the decode lacks
* ("Captur" must not pick "CAPTUR II"), a mild penalty per extra token, then the
* fuller catalog as tiebreak.
*/
export function pickRpartstoreCatalogMatch(
model: string | null | undefined,
candidates: RpartstoreCatalogCandidate[],
): string | null {
const decoded = rpartstoreModelTokens(model);
if (decoded.length === 0) return null;
const core = decoded.filter((t) => !BODY_QUALIFIERS.has(t));
const required = core.length > 0 ? core : decoded;
const decodedSet = new Set(decoded);
const decodedHasGeneration = decoded.some(isNumeric);
let best: { id: string; score: number; categoryCount: number } | null = null;
for (const c of candidates) {
const tokens = rpartstoreModelTokens(c.model);
if (tokens.length === 0) continue;
const tokenSet = new Set(tokens);
if (!required.every((t) => tokenSet.has(t))) continue;
const extras = tokens.filter((t) => !decodedSet.has(t));
const marketExtras = extras.filter((t) => MARKET_QUALIFIERS.has(t)).length;
const generationExtras = decodedHasGeneration ? 0 : extras.filter(isNumeric).length;
const qualifierOverlap = decoded.filter(
(t) => BODY_QUALIFIERS.has(t) && tokenSet.has(t),
).length;
const exact = extras.length === 0 && tokens.length === decoded.length;
const score =
1000 -
marketExtras * 5000 -
generationExtras * 150 -
extras.length * 20 +
qualifierOverlap * 100 +
(exact ? 300 : 0);
const categoryCount = c.categoryCount ?? 0;
if (
!best ||
score > best.score ||
(score === best.score && categoryCount > best.categoryCount)
) {
best = { id: c.id, score, categoryCount };
}
}
return best?.id ?? null;
}

Some files were not shown because too many files have changed in this diff Show More