250 Commits

Author SHA1 Message Date
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
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
4614d79d77 Merge pull request 'dev' (#155) from dev into main
Reviewed-on: #155
2026-07-01 18:15:57 +03:00
5b640988c4 Merge pull request 'dev' (#154) from dev into main
Reviewed-on: #154
2026-07-01 16:28:55 +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
9df85c335f Merge pull request 'dev' (#152) from dev into main
Reviewed-on: #152
2026-07-01 09:07:08 +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
136 changed files with 15573 additions and 1039 deletions

View File

@@ -38,8 +38,12 @@ 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

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

@@ -169,6 +169,90 @@
"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
}
]
}
}

View File

@@ -21,6 +21,7 @@ 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";
@@ -79,6 +80,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
]),
DatabaseModule,
RedisModule,
TelegramModule,
AuthModule,
UsersModule,
EmailModule,

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

@@ -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 } from "../integrations/pl24/pl24-tree";
import { PL24Service } from "../integrations/pl24/pl24.service";
import {
type PL24DecodedCategory,
@@ -349,6 +358,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 +1250,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 +1360,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

@@ -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,17 @@ 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 } from "../integrations/pl24/pl24-tree";
import { PL24Service } from "../integrations/pl24/pl24.service";
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 +51,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;
@@ -443,8 +458,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
@@ -466,6 +496,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.
@@ -663,17 +897,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 [];
}
@@ -1083,9 +1313,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);
@@ -1940,15 +2174,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

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

@@ -203,6 +203,12 @@ export const userSubscriptions = pgTable(
// 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(),
},
@@ -419,10 +425,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) ─
@@ -471,12 +487,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,
@@ -939,3 +970,98 @@ export const discountCodes = pgTable(
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

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

@@ -94,14 +94,16 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
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", "floxy")
.get<string>("EMEX_PROXY_PROVIDER", "dataimpulse")
.toLowerCase();
this.proxyProvider = !this.useProxy
? "none"
: rawProvider === "dataimpulse" || rawProvider === "none"
? (rawProvider as "dataimpulse" | "none")
: "floxy";
: 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);
@@ -260,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

View File

@@ -215,11 +215,15 @@ export class EmexService {
// 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", "floxy").toLowerCase();
return p === "dataimpulse" || p === "none" ? p : "floxy";
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.

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 ~34k 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

@@ -30,6 +30,7 @@ import {
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
@@ -195,6 +196,13 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
// 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,
@@ -216,13 +224,16 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
this.semaphore = new Semaphore(this.jwtSites.length);
// Provider selection. Back-compat: PCAT_USE_PROXY=false still forces direct.
const rawProvider = cfg.get<string>("PCAT_PROXY_PROVIDER", "floxy").toLowerCase();
// 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 === "dataimpulse" || rawProvider === "none"
: rawProvider === "floxy" || rawProvider === "none"
? (rawProvider as ProxyProvider)
: "floxy";
: "dataimpulse";
const toInt = (key: string, def: number): number => {
const n = Number(cfg.get(key, def));
@@ -242,6 +253,9 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
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" &&
@@ -806,7 +820,10 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
// 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.
const exitIpPromise = proxyUrl ? resolveExitIp(proxyUrl) : Promise.resolve(null);
// 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({
@@ -856,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();
@@ -878,6 +902,29 @@ 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();
});

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,461 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PL24AuthService } from "./pl24-auth.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ıı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ıı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);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
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();
});
});

View File

@@ -0,0 +1,137 @@
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);
} 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,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,112 @@
import { describe, expect, it } from "vitest";
import { isPl24GroupNode, isPl24LeafNode } 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);
});
});

View File

@@ -0,0 +1,65 @@
/**
* 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. */
const LEAF_WIDS = new Set(["bomlist", "bomoverviewlist", "servicepartsitemstable", "partinfo"]);
/** `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;
return !isPl24LeafNode(opts);
}

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,58 @@ 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")
.replace(/[\u0300-\u036f]/g, "")
.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 +127,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 +998,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 +1038,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 ====================
/**
@@ -1051,7 +1162,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"),
@@ -1098,26 +1209,40 @@ 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ı".
// 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 = lookup(
"şanzıman_kodu",
"sanzıman_kodu",
"sanziman_kodu",
"transmission_code",
"vites_kutusu",
"atm,mtm",
"şanzıman",
"şanzıman_numarası",
"aktarma_sistemleri",
"sanziman",
"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").
lookup("karoseri", "body", "body_type", "govde_tipi", "kaporta_stili") ||
null;
// Engine description from prNr D3* (Motor nitelikleri)
@@ -1140,7 +1265,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 +1288,20 @@ 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"),
series: lookup("seri", "satis_tipi", "sales_type", "turu"),
bodyType,
engineCode:
engineCode ||
@@ -1187,7 +1315,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 +1480,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 +1565,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 +1580,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 || "";

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.
@@ -536,8 +630,8 @@ 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
@@ -563,9 +657,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: "opel_parts", // PSA/Stellantis-platform Opel (Corsa F, Mokka B — France/Spain plants)
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)
@@ -794,6 +895,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

@@ -4,7 +4,7 @@ import { proxyLogs } from "../../database/schema/core";
/**
* Proxy telemetry — fire-and-forget logging of every proxied upstream attempt
* (pcat call/capture/validate, emex http) into `proxy_logs`, so the Süper Panel
* (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:
@@ -13,7 +13,13 @@ import { proxyLogs } from "../../database/schema/core";
* - Bounded memory: the buffer is capped; when full, oldest rows are dropped.
*/
export type ProxyServiceLeg = "pcat_call" | "pcat_capture" | "pcat_validate" | "emex_http";
export type ProxyServiceLeg =
| "pcat_call"
| "pcat_capture"
| "pcat_validate"
| "emex_http"
| "pl24_http"
| "pl24_auth";
export type ProxyProviderName = "floxy" | "dataimpulse" | "none";
export interface ProxyLogEvent {

View File

@@ -0,0 +1,213 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { VinpinDaemonService } from "./vinpin-daemon.service";
import type { VinpinDriverService } from "./vinpin-driver.service";
/**
* Unit tests for the Vinpin warm-session daemon lifecycle: the business-hours
* scheduler (warm/teardown reconciliation with an injected clock), warm-on-demand
* decode routing, and the reentrancy guard. The driver is a fake — the real seat
* lives on prod and can't be driven from a test.
*/
interface FakeDriver {
isWarm: ReturnType<typeof vi.fn>;
warmUp: ReturnType<typeof vi.fn>;
teardownWarm: ReturnType<typeof vi.fn>;
decode: ReturnType<typeof vi.fn>;
keepalivePing: ReturnType<typeof vi.fn>;
}
function makeDriver(warm = false): FakeDriver {
return {
isWarm: vi.fn(() => warm),
warmUp: vi.fn(async () => true),
teardownWarm: vi.fn(async () => undefined),
decode: vi.fn(async () => null),
keepalivePing: vi.fn(async () => undefined),
};
}
function daemon(driver: FakeDriver, opts: { hours: boolean; enabled?: boolean }) {
return new VinpinDaemonService({
driver: driver as unknown as VinpinDriverService,
isBusinessHours: () => opts.hours,
isEnabled: () => opts.enabled ?? true,
});
}
describe("VinpinDaemonService — scheduler reconcile", () => {
afterEach(() => vi.restoreAllMocks());
it("warms up inside business hours when enabled and not already warm", async () => {
const driver = makeDriver(false);
await daemon(driver, { hours: true }).reconcile();
expect(driver.warmUp).toHaveBeenCalledTimes(1);
expect(driver.teardownWarm).not.toHaveBeenCalled();
});
it("does NOT re-warm when already warm inside hours", async () => {
const driver = makeDriver(true);
await daemon(driver, { hours: true }).reconcile();
expect(driver.warmUp).not.toHaveBeenCalled();
expect(driver.teardownWarm).not.toHaveBeenCalled();
});
it("tears down the warm seat outside business hours", async () => {
const driver = makeDriver(true);
await daemon(driver, { hours: false }).reconcile();
expect(driver.teardownWarm).toHaveBeenCalledTimes(1);
expect(driver.warmUp).not.toHaveBeenCalled();
});
it("tears down a warm seat when the daemon is disabled (kill-switch)", async () => {
const driver = makeDriver(true);
await daemon(driver, { hours: true, enabled: false }).reconcile();
expect(driver.teardownWarm).toHaveBeenCalledTimes(1);
expect(driver.warmUp).not.toHaveBeenCalled();
});
it("does nothing when disabled and already cold", async () => {
const driver = makeDriver(false);
await daemon(driver, { hours: true, enabled: false }).reconcile();
expect(driver.warmUp).not.toHaveBeenCalled();
expect(driver.teardownWarm).not.toHaveBeenCalled();
});
it("does not stack overlapping reconciles (reentrancy guard)", async () => {
const driver = makeDriver(false);
let release!: () => void;
driver.warmUp.mockImplementation(
() =>
new Promise<boolean>((resolve) => {
release = () => resolve(true);
}),
);
const d = daemon(driver, { hours: true });
const first = d.reconcile();
const second = d.reconcile(); // should early-return while the first is in flight
release();
await Promise.all([first, second]);
expect(driver.warmUp).toHaveBeenCalledTimes(1);
});
});
describe("VinpinDaemonService — decode routing", () => {
afterEach(() => vi.restoreAllMocks());
it("warms on-demand then delegates when inside hours and cold", async () => {
const driver = makeDriver(false);
driver.decode.mockResolvedValue({ brand: "Fiat", model: "EGEA" });
const result = await daemon(driver, { hours: true }).decode("NM435600006H43436");
expect(driver.warmUp).toHaveBeenCalledTimes(1);
expect(driver.decode).toHaveBeenCalledWith("NM435600006H43436");
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
});
it("does NOT warm on-demand when already warm — just delegates", async () => {
const driver = makeDriver(true);
await daemon(driver, { hours: true }).decode("NM435600006H43436");
expect(driver.warmUp).not.toHaveBeenCalled();
expect(driver.decode).toHaveBeenCalledTimes(1);
});
it("off-hours delegates straight to the cold path (never holds the seat)", async () => {
const driver = makeDriver(false);
await daemon(driver, { hours: false }).decode("NM435600006H43436");
expect(driver.warmUp).not.toHaveBeenCalled();
expect(driver.decode).toHaveBeenCalledTimes(1);
});
it("returns null (never throws) if the driver decode rejects", async () => {
const driver = makeDriver(true);
driver.decode.mockRejectedValue(new Error("boom"));
const result = await daemon(driver, { hours: true }).decode("NM435600006H43436");
expect(result).toBeNull();
});
});
describe("VinpinDaemonService — warm-up backoff", () => {
afterEach(() => vi.restoreAllMocks());
function backoffDaemon(driver: FakeDriver, clock: { t: number }) {
return new VinpinDaemonService({
driver: driver as unknown as VinpinDriverService,
isBusinessHours: () => true,
isEnabled: () => true,
now: () => clock.t,
});
}
it("a failed warmUp sets a cooldown that reconcile() respects (no immediate re-warm)", async () => {
const driver = makeDriver(false);
driver.warmUp.mockResolvedValue(false); // warm-up keeps failing
const clock = { t: 0 };
const d = backoffDaemon(driver, clock);
await d.reconcile();
expect(driver.warmUp).toHaveBeenCalledTimes(1); // first attempt ran
// Still cold + in hours, but inside the cooldown → no second attempt.
clock.t = 30_000;
await d.reconcile();
expect(driver.warmUp).toHaveBeenCalledTimes(1);
// After the base cooldown (60s) elapses → it tries again.
clock.t = 61_000;
await d.reconcile();
expect(driver.warmUp).toHaveBeenCalledTimes(2);
});
it("warm-on-demand decode ALSO respects the cooldown, but still delegates the cold decode", async () => {
const driver = makeDriver(false);
driver.warmUp.mockResolvedValue(false);
const clock = { t: 0 };
const d = backoffDaemon(driver, clock);
await d.reconcile(); // fails → cooldown until 60_000
expect(driver.warmUp).toHaveBeenCalledTimes(1);
clock.t = 20_000;
await d.decode("NM435600006H43436"); // in cooldown → no warm-on-demand
expect(driver.warmUp).toHaveBeenCalledTimes(1);
expect(driver.decode).toHaveBeenCalledTimes(1); // still decodes via the cold path
});
it("a successful warm resets the backoff (next attempt is not blocked)", async () => {
const driver = makeDriver(false);
driver.warmUp.mockResolvedValueOnce(false).mockResolvedValueOnce(true).mockResolvedValue(false);
const clock = { t: 0 };
const d = backoffDaemon(driver, clock);
await d.reconcile(); // fail → cooldown until 60_000
clock.t = 61_000;
await d.reconcile(); // success → cooldown reset
expect(driver.warmUp).toHaveBeenCalledTimes(2);
// isWarm() is still false in the fake, so reconcile would warm again — and with
// the cooldown reset it may attempt immediately (no leftover backoff window).
clock.t = 61_500;
await d.reconcile();
expect(driver.warmUp).toHaveBeenCalledTimes(3);
});
});
describe("VinpinDaemonService — start/stop lifecycle", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("reconciles on start and drives keepalive on the interval; stop tears down", async () => {
const driver = makeDriver(false);
const d = daemon(driver, { hours: true });
d.start();
// Immediate reconcile → warm.
await vi.waitFor(() => expect(driver.warmUp).toHaveBeenCalledTimes(1));
// Advance past a keepalive interval → a ping fires.
await vi.advanceTimersByTimeAsync(80_000);
expect(driver.keepalivePing).toHaveBeenCalled();
await d.stop();
expect(driver.teardownWarm).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,195 @@
/**
* Vinpin warm-session daemon (worker-process singleton).
*
* Owns the LIFECYCLE of the persistent warm Vinpin seat, on top of the browser
* mechanics in VinpinDriverService:
* - a scheduler that warms the seat at 08:00 and tears it down at 21:00
* Europe/Istanbul (and warms on worker start if already inside the window),
* - an idle keepalive that nudges the RDS session every ~75s so it never drops,
* - `decode(vin)` — the entry the vinpin-decode processor calls. Inside business
* hours it makes sure the seat is warm (warming on-demand once), then delegates
* to the driver, which self-routes to the HOT warm path when warm and the
* proven COLD per-decode path otherwise. Outside hours it never holds the seat
* — the driver's cold path handles the decode and is torn down after.
*
* ADDITIVE + fail-safe: every warm operation degrades to the cold path, and
* `decode()` never throws (the driver returns null on any failure). Gated by
* VINPIN_ENABLED (feature flag) and VINPIN_WARM_DAEMON (kill-switch: set to
* "false" to force the legacy per-decode cold path with the daemon inert).
*/
import { Logger } from "@nestjs/common";
import { getVinpinDriver } from "./vinpin-driver.service";
import type { VinpinDecodeResult, VinpinDriverService } from "./vinpin-driver.service";
import { VINPIN_WARM, isVinpinBusinessHours } from "./vinpin.constants";
/** Whether the warm daemon is allowed to run (feature flag + kill-switch). */
export function isVinpinWarmDaemonEnabled(): boolean {
return process.env.VINPIN_ENABLED === "true" && process.env.VINPIN_WARM_DAEMON !== "false";
}
export interface VinpinDaemonDeps {
/** Driver singleton (injectable for tests). Defaults to the process-wide one. */
driver?: VinpinDriverService;
/** Business-hours predicate (injectable so tests can drive the clock). */
isBusinessHours?: () => boolean;
/** Warm-daemon enabled predicate (injectable for tests). */
isEnabled?: () => boolean;
/** Monotonic-ish clock (injectable so tests can drive the warm-up backoff). */
now?: () => number;
}
export class VinpinDaemonService {
private readonly logger = new Logger(VinpinDaemonService.name);
private readonly driver: VinpinDriverService;
private readonly isBusinessHours: () => boolean;
private readonly isEnabled: () => boolean;
private readonly now: () => number;
private schedulerTimer: ReturnType<typeof setInterval> | null = null;
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
private reconciling = false;
private started = false;
// ─── Warm-up backoff ─────────────────────────────────────
/** Wall-clock time until which a re-warm is suppressed after a failed warmUp.
* Both reconcile() and decode()'s warm-on-demand honour this so a failing seat
* isn't hammered every ~60s (each failed attempt would leave a fresh dirty
* window). A successful warm resets it. */
private warmCooldownUntil = 0;
/** Current backoff span (ms): 0 when healthy, else base…max, doubling per
* consecutive failure. */
private warmBackoffMs = 0;
constructor(deps: VinpinDaemonDeps = {}) {
this.driver = deps.driver ?? getVinpinDriver();
this.isBusinessHours = deps.isBusinessHours ?? (() => isVinpinBusinessHours());
this.isEnabled = deps.isEnabled ?? isVinpinWarmDaemonEnabled;
this.now = deps.now ?? (() => Date.now());
}
/** True while a failed warmUp's cooldown is still in effect. */
private inWarmCooldown(): boolean {
return this.now() < this.warmCooldownUntil;
}
/**
* Record a warm-up outcome and update the backoff. Success clears the cooldown;
* failure sets/extends it (base, then exponential up to max). Returns `ok` so
* callers can chain.
*/
private noteWarmResult(ok: boolean): boolean {
if (ok) {
this.warmBackoffMs = 0;
this.warmCooldownUntil = 0;
} else {
this.warmBackoffMs =
this.warmBackoffMs === 0
? VINPIN_WARM.warmBackoffBaseMs
: Math.min(this.warmBackoffMs * 2, VINPIN_WARM.warmBackoffMaxMs);
this.warmCooldownUntil = this.now() + this.warmBackoffMs;
this.logger.warn(
`warmUp failed — backing off ${Math.round(this.warmBackoffMs / 1000)}s before the next attempt`,
);
}
return ok;
}
/** Start the scheduler + keepalive loops (idempotent). */
start(): void {
if (this.started) return;
this.started = true;
// Reconcile once now (warm immediately if the worker booted inside hours).
void this.reconcile();
this.schedulerTimer = setInterval(() => {
void this.reconcile();
}, VINPIN_WARM.schedulerIntervalMs);
this.keepaliveTimer = setInterval(() => {
void this.driver.keepalivePing();
}, VINPIN_WARM.keepaliveIntervalMs);
// Don't keep the event loop alive just for these timers.
this.schedulerTimer.unref?.();
this.keepaliveTimer.unref?.();
this.logger.log(
`Vinpin warm daemon started (enabled=${this.isEnabled()}, hours ${VINPIN_WARM.businessStartHour}:00${VINPIN_WARM.businessEndHour}:00 Europe/Istanbul)`,
);
}
/** Stop the loops and tear the warm seat down (worker shutdown). */
async stop(): Promise<void> {
if (this.schedulerTimer) clearInterval(this.schedulerTimer);
if (this.keepaliveTimer) clearInterval(this.keepaliveTimer);
this.schedulerTimer = null;
this.keepaliveTimer = null;
this.started = false;
await this.driver.teardownWarm().catch(() => undefined);
}
/**
* Reconcile the warm seat against the schedule: warm up when enabled + inside
* hours + not already warm; tear down when warm but disabled or outside hours.
* Guards against overlapping runs (a slow warmUp must not stack). Never throws.
*/
async reconcile(): Promise<void> {
if (this.reconciling) return;
this.reconciling = true;
try {
const enabled = this.isEnabled();
const inHours = this.isBusinessHours();
if (enabled && inHours && !this.driver.isWarm()) {
if (this.inWarmCooldown()) {
this.logger.debug("scheduler: in warm-up backoff — skipping this cycle");
} else {
this.logger.log("scheduler: inside business hours — warming the seat");
this.noteWarmResult(await this.driver.warmUp());
}
} else if (this.driver.isWarm() && (!enabled || !inHours)) {
this.logger.log("scheduler: outside business hours / disabled — tearing the seat down");
await this.driver.teardownWarm();
// Intentional teardown → clear any stale warm-up backoff so the next window
// (e.g. tomorrow 08:00) isn't blocked by a leftover cooldown.
this.warmBackoffMs = 0;
this.warmCooldownUntil = 0;
}
} catch (err) {
this.logger.warn(`reconcile failed: ${(err as Error).message}`);
} finally {
this.reconciling = false;
}
}
/**
* Decode entry the processor calls. Inside hours, ensure the seat is warm (warm
* on-demand once), then delegate to the driver — which uses the hot warm path
* when warm, else the cold per-decode path. Off-hours, delegate straight to the
* driver's cold path (no seat held). Never throws — returns null on any failure.
*/
async decode(vin: string): Promise<VinpinDecodeResult | null> {
try {
if (
this.isEnabled() &&
this.isBusinessHours() &&
!this.driver.isWarm() &&
!this.inWarmCooldown()
) {
// Warm-on-demand: a decode arrived inside hours before the scheduler warmed
// (e.g. right after 08:00, or after a drop). Best-effort — if it fails the
// driver silently runs the cold path for this decode, and the backoff spaces
// out the next warm attempt (respected by both this check and reconcile()).
this.noteWarmResult(await this.driver.warmUp().catch(() => false));
}
return await this.driver.decode(vin);
} catch (err) {
// Defensive: driver.decode never throws, but guarantee the processor a null.
this.logger.warn(`decode(${vin}) unexpected error: ${(err as Error).message}`);
return null;
}
}
}
// ─── Worker singleton ────────────────────────────────────────
let daemonSingleton: VinpinDaemonService | null = null;
export function getVinpinDaemon(): VinpinDaemonService {
if (!daemonSingleton) daemonSingleton = new VinpinDaemonService();
return daemonSingleton;
}

View File

@@ -0,0 +1,680 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Renault-decode robustness tests (Rpartstore-down hardening). These exercise the
* fixes that stop a DOWN Rpartstore from thrashing the shared seat:
* (2) ensureRpartstore no longer short-circuits "already open" on the flyout /
* window-title word "Rpartstore" — it gates on a CONTENT token and clicks the
* flyout entry when the submenu is up over the grid;
* (3) closeRpartstoreTab never clicks the language-selector coord (1298,14);
* (6) an in-memory cooldown routes Renault decodes straight to Dialogys during a
* persistent Rpartstore outage, and a successful load clears it.
* OCR is mocked so the screen-state sequence is fully controllable — the real seat
* lives on prod and can't be driven from a test.
*/
vi.mock("./vinpin.ocr", () => ({
ocrRegion: vi.fn(async () => ""),
pollForText: vi.fn(async () => ({ matched: false, text: "" })),
terminateOcr: vi.fn(async () => undefined),
}));
import { VinpinDriverService } from "./vinpin-driver.service";
import { VINPIN_COORDS } from "./vinpin.constants";
import { ocrRegion } from "./vinpin.ocr";
const mockOcr = vi.mocked(ocrRegion);
type AnyDriver = Record<string, unknown>;
function fakePage(sink?: (x: number, y: number) => void) {
return {
isClosed: () => false,
frames: () => [] as unknown[],
mouse: {
click: vi.fn(async (x: number, y: number) => sink?.(x, y)),
move: vi.fn(async () => undefined),
down: vi.fn(async () => undefined),
up: vi.fn(async () => undefined),
},
keyboard: { press: vi.fn(async () => undefined) },
waitForTimeout: vi.fn(async () => undefined),
locator: () => ({
first: () => ({
count: async () => 0,
isVisible: async () => false,
click: async () => undefined,
}),
}),
};
}
describe("VinpinDriverService — ensureRpartstore flyout false-positive (change 2)", () => {
const savedEnv = { ...process.env };
beforeEach(() => {
process.env.VINPIN_ENABLED = "true";
mockOcr.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...savedEnv };
});
const ensure = (driver: VinpinDriverService, page: unknown) =>
((driver as unknown as AnyDriver).ensureRpartstore as (p: unknown) => Promise<boolean>).call(
driver,
page,
);
it("does NOT declare Rpartstore open on the FLYOUT — it clicks the Rpartstore entry (584,779)", async () => {
const driver = new VinpinDriverService();
const page = fakePage();
// Flyout is open OVER the grid: brand tiles visible + the Renault submenu items
// "Rpartstore"/"Dialogys" — matches renaultSubmenu but has NO search-home content.
mockOcr
.mockResolvedValueOnce("Volkswagen Mitsubishi TecDoc Renault Rpartstore Dialogys")
.mockResolvedValue("Şasi no ile arama Güncel araçlar"); // then the LOADED home
const ok = await ensure(driver, page);
expect(ok).toBe(true);
// The flyout Rpartstore entry was clicked (old code short-circuited without it).
expect(page.mouse.click).toHaveBeenCalledWith(
VINPIN_COORDS.renaultRpartstore.x,
VINPIN_COORDS.renaultRpartstore.y,
);
});
it("short-circuits (no flyout/tile click) when the LOADED search-home content is already on screen", async () => {
const driver = new VinpinDriverService();
const page = fakePage();
mockOcr.mockResolvedValue("Şasi no ile arama Ne arıyorsunuz Güncel araçlar");
expect(await ensure(driver, page)).toBe(true);
expect(page.mouse.click).not.toHaveBeenCalledWith(
VINPIN_COORDS.renaultRpartstore.x,
VINPIN_COORDS.renaultRpartstore.y,
);
expect(page.mouse.move).not.toHaveBeenCalledWith(
VINPIN_COORDS.renaultBrand.x,
VINPIN_COORDS.renaultBrand.y,
);
});
it("treats an open Rpartstore WINDOW (title only, no grid behind) as present without re-clicking the flyout", async () => {
const driver = new VinpinDriverService();
const page = fakePage();
// Window title bar "Renault Rpartstore" matches renaultSubmenu too, but there's
// NO grid behind it → must be handled by the open-window branch, not the flyout.
mockOcr.mockResolvedValue("Renault Rpartstore");
expect(await ensure(driver, page)).toBe(true);
expect(page.mouse.click).not.toHaveBeenCalledWith(
VINPIN_COORDS.renaultRpartstore.x,
VINPIN_COORDS.renaultRpartstore.y,
);
});
it("bails on the FIRST iteration when the DOWN launch-error modal is over the grid (no repeated flyout clicks)", async () => {
const driver = new VinpinDriverService();
const page = fakePage();
// DOWN Rpartstore: the launch-error modal sits OVER the brand grid. The FULL-frame
// read (no clip) sees the grid tiles + the modal TITLE "Renault Rpartstore" → it
// matches brandGrid AND renaultSubmenu, which — without the fix — makes the flyout
// branch re-click renaultRpartstore(584,779) on all ~6 iterations. The UPSCALED
// modal crop (clip passed) reads the Cyrillic launch error, so the launch-error
// short-circuit must fire and return false on iteration 1.
mockOcr.mockImplementation(async (_page: unknown, clip?: unknown) =>
clip
? "Owwu6ka 3anycka KaTanora" // upscaled modal crop → matches rpartstoreLaunchError
: "Volkswagen Mitsubishi Renault Rpartstore Dialogys",
);
const ok = await ensure(driver, page);
expect(ok).toBe(false);
// Never re-clicked the flyout Rpartstore entry (would be up to 6× without the fix).
expect(page.mouse.click).not.toHaveBeenCalledWith(
VINPIN_COORDS.renaultRpartstore.x,
VINPIN_COORDS.renaultRpartstore.y,
);
// Exactly ONE full-frame OCR read (no clip) proves it bailed on the first iteration
// rather than looping the 6-try budget.
const fullFrameReads = mockOcr.mock.calls.filter(([, clip]) => clip === undefined);
expect(fullFrameReads).toHaveLength(1);
});
it("still enters the flyout branch when the modal crop is ILLEGIBLE (no ffmpeg) — behaviour unchanged", async () => {
const driver = new VinpinDriverService();
const page = fakePage();
// Same grid+flyout-title screen, but the crop OCR yields no launch-error text (the
// no-ffmpeg / illegible case). The launch-error short-circuit must NOT fire, so the
// proven flyout path still runs and clicks the Rpartstore entry.
mockOcr.mockImplementation(async (_page: unknown, clip?: unknown) =>
clip
? "" // illegible crop → rpartstoreLaunchErrorPresent === false
: "Volkswagen Mitsubishi Renault Rpartstore Dialogys",
);
// Bounded loop; we only assert the flyout entry was clicked (grid+submenu branch).
await ensure(driver, page);
expect(page.mouse.click).toHaveBeenCalledWith(
VINPIN_COORDS.renaultRpartstore.x,
VINPIN_COORDS.renaultRpartstore.y,
);
});
});
describe("VinpinDriverService — closeRpartstoreTab never hits the language selector (change 3)", () => {
const savedEnv = { ...process.env };
beforeEach(() => {
process.env.VINPIN_ENABLED = "true";
mockOcr.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...savedEnv };
});
const close = (driver: VinpinDriverService, page: unknown) =>
((driver as unknown as AnyDriver).closeRpartstoreTab as (p: unknown) => Promise<void>).call(
driver,
page,
);
it("uses the tab-✕ (93,45) + Escape and NEVER clicks windowClose (1298,14) on a stuck spinner", async () => {
const driver = new VinpinDriverService();
const page = fakePage();
// Chrome/spinner only (no search-home content) → the second close must NOT fire.
mockOcr.mockResolvedValue("Renault Rpartstore");
await close(driver, page);
expect(page.mouse.click).toHaveBeenCalledWith(
VINPIN_COORDS.catalogTabClose.x,
VINPIN_COORDS.catalogTabClose.y,
);
expect(page.mouse.click).not.toHaveBeenCalledWith(
VINPIN_COORDS.windowClose.x,
VINPIN_COORDS.windowClose.y,
);
expect(page.keyboard.press).toHaveBeenCalledWith("Escape");
// Exactly ONE close click (no content → no retry).
const closeClicks = page.mouse.click.mock.calls.filter(
([x, y]) => x === VINPIN_COORDS.catalogTabClose.x && y === VINPIN_COORDS.catalogTabClose.y,
);
expect(closeClicks).toHaveLength(1);
});
it("retries the tab-✕ (NOT windowClose) when a LOADED home is still up after the first close", async () => {
const driver = new VinpinDriverService();
const page = fakePage();
mockOcr.mockResolvedValue("Şasi no ile arama Güncel araçlar"); // loaded home persists
await close(driver, page);
const closeClicks = page.mouse.click.mock.calls.filter(
([x, y]) => x === VINPIN_COORDS.catalogTabClose.x && y === VINPIN_COORDS.catalogTabClose.y,
);
expect(closeClicks).toHaveLength(2); // first + one content-gated retry
expect(page.mouse.click).not.toHaveBeenCalledWith(
VINPIN_COORDS.windowClose.x,
VINPIN_COORDS.windowClose.y,
);
});
});
describe("VinpinDriverService — Rpartstore-down cooldown routes Renault → Dialogys (change 6)", () => {
const savedEnv = { ...process.env };
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
beforeEach(() => {
process.env.VINPIN_ENABLED = "true";
process.env.VINPIN_USER = "user";
process.env.VINPIN_PASS = "pass";
mockOcr.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...savedEnv };
});
const inCooldown = (driver: VinpinDriverService) =>
((driver as unknown as AnyDriver).rpartstoreInCooldown as () => boolean).call(driver);
const acquire = (driver: VinpinDriverService, page: unknown, warm: boolean) =>
(
(driver as unknown as AnyDriver).acquireLoadedRpartstore as (
p: unknown,
c: unknown,
d: number,
w: boolean,
) => Promise<boolean>
).call(driver, page, {}, FAR_DEADLINE(), warm);
it("while in cooldown, warm Renault decode SKIPS Rpartstore entirely and uses Dialogys", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
any.rpartstoreCooldownUntil = Date.now() + 60_000; // cooldown active
const acquireSpy = vi.spyOn(any as never, "acquireLoadedRpartstore");
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
vi.spyOn(any as never, "runDialogysSearch").mockResolvedValue({
status: "found",
parsed: { brand: "RENAULT", model: "MEGANE", modelYear: null },
rawText: "Megane",
via: "dialogys",
} as never);
const page = { keyboard: { press: vi.fn(async () => undefined) } };
const result = await (
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
).call(driver, page, "VF1LM1B0A37829019", { rpartstoreEnabled: true }, FAR_DEADLINE());
expect(acquireSpy).not.toHaveBeenCalled(); // Rpartstore never opened during the outage
expect(runRpartstore).not.toHaveBeenCalled();
expect(result).toMatchObject({
brand: "RENAULT",
model: "MEGANE",
raw: { source: "vinpin-dialogys" },
});
});
it("a hard launch-error SETS the cooldown", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
loaded: false,
launchError: true,
text: "Owwu6ka 3anycka KaTanora",
} as never);
const page = fakePage();
expect(inCooldown(driver)).toBe(false);
expect(await acquire(driver, page, true)).toBe(false);
expect(inCooldown(driver)).toBe(true); // → next Renault VIN routes straight to Dialogys
});
it("a confirmed Rpartstore load CLEARS an active cooldown", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
any.rpartstoreCooldownUntil = Date.now() + 60_000; // pretend it was marked down
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
loaded: true,
launchError: false,
text: "Şasi no ile arama",
} as never);
const page = fakePage();
expect(inCooldown(driver)).toBe(true);
expect(await acquire(driver, page, true)).toBe(true);
expect(inCooldown(driver)).toBe(false); // healthy again → Rpartstore resumes as primary
});
it("a transient born-stuck-spinner streak (maxOpens exhausted) does NOT set the cooldown", async () => {
// A born-stuck spinner is a TRANSIENT reopen-recoverable blip, not a server
// outage — exhausting maxOpens must fall back to Dialogys for THIS vin only and
// leave Rpartstore healthy for the next one, NOT suppress it for the full
// cooldown (which can't self-clear while it's being skipped).
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
vi.spyOn(any as never, "reopenFreshRpartstore").mockResolvedValue(true as never);
vi.spyOn(any as never, "closeRpartstoreTab").mockResolvedValue(undefined as never);
// Every poll = still spinning (never loaded, no launch error) → born-stuck path.
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
loaded: false,
launchError: false,
text: "",
} as never);
const page = fakePage();
expect(inCooldown(driver)).toBe(false);
expect(await acquire(driver, page, true)).toBe(false); // gave up → Dialogys
expect(inCooldown(driver)).toBe(false); // but NOT cooled down → next VIN retries Rpartstore
});
});
/**
* Ambiguous-retry cheap in-session reset (the dirty-resume relaunch-thrash fix).
* An AMBIGUOUS Renault outcome is transient/state-dependent, so the retry must NOT
* tear the browser down (a full teardown forces a ~6090s relaunch + re-login that
* re-hits the seat's "catalog window resumed open" dirty-resume every attempt →
* 3 attempts blow the 180s budget → not_found). Instead it returns to a clean
* VinPower brand grid on the SAME live session via ensureBrandGrid (which runs the
* closeStrayRunningApps DOM recovery first), and re-runs runRenaultFlow — no
* close()/relaunch. GRADUATED safety: only when that cheap reset can't reach a clean
* grid does it fall back to close() + cold re-establish. OCR is mocked so the grid
* confirmation is controllable.
*/
describe("VinpinDriverService — ambiguous Renault retry is a CHEAP in-session grid reset (no relaunch)", () => {
const savedEnv = { ...process.env };
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
const RENAULT_VIN = "VF1RFE00653633190"; // the live-trace VIN that thrashed on relaunch
beforeEach(() => {
process.env.VINPIN_ENABLED = "true";
process.env.VINPIN_USER = "user";
process.env.VINPIN_PASS = "pass";
mockOcr.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...savedEnv };
});
// Present the driver as a live, authenticated session so the cheap reset's
// precheck (browser connected + context + open page + authed) passes and it never
// short-circuits to a cold re-establish.
function liveSession(driver: VinpinDriverService): AnyDriver {
const any = driver as unknown as AnyDriver;
any.browser = { isConnected: () => true };
any.context = {};
any.page = fakePage();
any.authed = true;
return any;
}
const decodeRenault = (driver: VinpinDriverService, vin: string) =>
(
(driver as unknown as AnyDriver).decodeRenaultLocked as (
v: string,
c: unknown,
d: number,
) => Promise<unknown>
).call(driver, vin, { maxAttempts: 3, budgetMs: 5 * 60_000 }, FAR_DEADLINE());
it("first ambiguous retry returns to the grid IN-SESSION (ensureBrandGrid) and re-runs runRenaultFlow WITHOUT close()/relaunch", async () => {
const driver = new VinpinDriverService();
const any = liveSession(driver);
// ensureAuthenticated is a no-op (session already up → NO relaunch, NO web login).
vi.spyOn(any as never, "ensureAuthenticated").mockResolvedValue(undefined as never);
// The cheap reset reaches a clean grid: ensureBrandGrid runs on the SAME session
// and the OCR read confirms the brand grid.
const ensureGrid = vi
.spyOn(any as never, "ensureBrandGrid")
.mockResolvedValue(undefined as never);
mockOcr.mockResolvedValue("Volkswagen Mitsubishi TecDoc"); // matches VINPIN_OCR.brandGrid
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
// attempt 1 → ambiguous (transient); attempt 2, same live session → found.
const runFlow = vi
.spyOn(any as never, "runRenaultFlow")
.mockResolvedValueOnce({ status: "ambiguous" } as never)
.mockResolvedValueOnce({
status: "found",
parsed: { brand: "RENAULT", model: "KADJAR", modelYear: null },
rawText: "RENAULT Kadjar",
via: "dialogys",
} as never);
const result = await decodeRenault(driver, RENAULT_VIN);
expect(result).toMatchObject({ brand: "RENAULT", model: "KADJAR" });
expect(runFlow).toHaveBeenCalledTimes(2); // ambiguous, then a cheap in-session re-run
expect(ensureGrid).toHaveBeenCalled(); // the in-session grid reset ran (closeStrayRunningApps path)
expect(close).not.toHaveBeenCalled(); // NO teardown/relaunch on the cheap first retry
});
it("GRADUATED fallback: when the cheap grid reset can't confirm a clean grid, it close()s + cold re-establishes", async () => {
const driver = new VinpinDriverService();
const any = liveSession(driver);
vi.spyOn(any as never, "ensureAuthenticated").mockResolvedValue(undefined as never);
// ensureBrandGrid runs but the grid is NEVER confirmed (OCR reads no grid) → the
// cheap reset returns false → the graduated hard reset (close) must fire.
const ensureGrid = vi
.spyOn(any as never, "ensureBrandGrid")
.mockResolvedValue(undefined as never);
mockOcr.mockResolvedValue(""); // no brand grid → cheap reset fails
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
const runFlow = vi
.spyOn(any as never, "runRenaultFlow")
.mockResolvedValueOnce({ status: "ambiguous" } as never)
.mockResolvedValueOnce({
status: "found",
parsed: { brand: "RENAULT", model: "MEGANE", modelYear: null },
rawText: "Megane",
via: "dialogys",
} as never);
const result = await decodeRenault(driver, RENAULT_VIN);
expect(result).toMatchObject({ brand: "RENAULT", model: "MEGANE" });
expect(ensureGrid).toHaveBeenCalled(); // the CHEAP reset was attempted first…
expect(close).toHaveBeenCalledTimes(1); // …then the graduated hard reset fired exactly once
expect(runFlow).toHaveBeenCalledTimes(2);
});
it("does NOT cheap-reset when the session is already gone — falls straight to close() (graduated)", async () => {
const driver = new VinpinDriverService();
const any = liveSession(driver);
// Session looks broken: the page is closed → the cheap reset's precheck fails and
// it returns false without touching ensureBrandGrid, so the hard reset runs.
(any.page as { isClosed: () => boolean }).isClosed = () => true;
vi.spyOn(any as never, "ensureAuthenticated").mockResolvedValue(undefined as never);
const ensureGrid = vi
.spyOn(any as never, "ensureBrandGrid")
.mockResolvedValue(undefined as never);
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
vi.spyOn(any as never, "runRenaultFlow")
.mockResolvedValueOnce({ status: "ambiguous" } as never)
.mockResolvedValueOnce({ status: "not_found" } as never);
const result = await decodeRenault(driver, RENAULT_VIN);
expect(result).toBeNull(); // 2nd attempt not_found → null
expect(ensureGrid).not.toHaveBeenCalled(); // broken session → no in-session grid work
expect(close).toHaveBeenCalledTimes(1); // graduated hard reset on the broken session
});
});
/**
* runRenaultFlow definitive-not_found gate (the budget-burn + seat-poison fix).
* When Rpartstore is UNAVAILABLE (down-cooldown, or it never loaded so `primary`
* is only the placeholder ambiguous), a CLEAN Dialogys not_found is DEFINITIVE on
* its own — the old gate required BOTH catalogs to say not_found, so every
* undecodable Renault while Rpartstore was down got downgraded to ambiguous →
* retry loop → 180s budget → sessionPoisoned. A genuinely-ambiguous Dialogys
* (couldn't be reached) still returns ambiguous so the transient-failure retry
* survives. When Rpartstore actually RAN, the both-must-agree gate is preserved.
*/
describe("VinpinDriverService — runRenaultFlow definitive not_found when Rpartstore unavailable", () => {
const savedEnv = { ...process.env };
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
const VIN = "VF1553K05TR596166"; // old (~1996) Renault 19 — the live budget-burn case
beforeEach(() => {
process.env.VINPIN_ENABLED = "true";
mockOcr.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...savedEnv };
});
const runFlow = (driver: VinpinDriverService, page: unknown) =>
(
(driver as unknown as AnyDriver).runRenaultFlow as (
p: unknown,
v: string,
c: unknown,
d: number,
) => Promise<{ status: string }>
).call(driver, page, VIN, { rpartstoreEnabled: true }, FAR_DEADLINE());
it("cooldown (Rpartstore down) + Dialogys not_found → DEFINITIVE not_found, no Rpartstore, no retry", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
any.rpartstoreCooldownUntil = Date.now() + 60_000; // Rpartstore in down-cooldown
const acquire = vi.spyOn(any as never, "acquireLoadedRpartstore");
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
const runDialogys = vi
.spyOn(any as never, "runDialogys")
.mockResolvedValue({ status: "not_found" } as never);
const outcome = await runFlow(driver, fakePage());
expect(outcome.status).toBe("not_found"); // definitive — no ambiguous downgrade
expect(acquire).not.toHaveBeenCalled(); // Rpartstore skipped during the outage
expect(runRpartstore).not.toHaveBeenCalled();
expect(runDialogys).toHaveBeenCalledTimes(1); // single shot — no budget-burn retry
});
it("Rpartstore never loaded (not cooldown) + Dialogys not_found → DEFINITIVE not_found", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
// Not in cooldown, but the acquire fails to load Rpartstore → primary never ran.
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(false as never);
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
vi.spyOn(any as never, "runDialogys").mockResolvedValue({ status: "not_found" } as never);
const outcome = await runFlow(driver, fakePage());
expect(outcome.status).toBe("not_found");
expect(runRpartstore).not.toHaveBeenCalled(); // acquire failed → primary placeholder only
});
it("Rpartstore unavailable + Dialogys AMBIGUOUS (unreachable) → still ambiguous (transient retry survives)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
any.rpartstoreCooldownUntil = Date.now() + 60_000; // down-cooldown
vi.spyOn(any as never, "runDialogys").mockResolvedValue({ status: "ambiguous" } as never);
const outcome = await runFlow(driver, fakePage());
expect(outcome.status).toBe("ambiguous"); // couldn't confirm not_found → caller retries
});
it("REGRESSION: Rpartstore RAN + ambiguous, Dialogys not_found → still ambiguous (both-must-agree preserved)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
// Rpartstore is UP: it loads and runs but is unsure (ambiguous). Dialogys then
// cleanly misses. Because Rpartstore actually ran, we must NOT treat this as a
// definitive not_found — the pre-fix both-must-agree behaviour is preserved.
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(true as never);
vi.spyOn(any as never, "runRpartstore").mockResolvedValue({ status: "ambiguous" } as never);
vi.spyOn(any as never, "runDialogys").mockResolvedValue({ status: "not_found" } as never);
const outcome = await runFlow(driver, fakePage());
expect(outcome.status).toBe("ambiguous");
});
it("REGRESSION: Rpartstore RAN + not_found, Dialogys not_found → definitive not_found (unchanged)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(true as never);
vi.spyOn(any as never, "runRpartstore").mockResolvedValue({ status: "not_found" } as never);
vi.spyOn(any as never, "runDialogys").mockResolvedValue({ status: "not_found" } as never);
const outcome = await runFlow(driver, fakePage());
expect(outcome.status).toBe("not_found");
});
});
/**
* VINPIN_RPARTSTORE_ENABLED=false kill-switch (KNOWN Rpartstore outage). Unlike the
* in-memory down-cooldown (which resets on every worker restart, so the first decode
* after each restart pays the full Rpartstore launch cost + leaves a stuck app), this
* config flag persists: when set, BOTH Renault paths (warm + cold) skip opening
* Rpartstore entirely and go straight to Dialogys, and a clean Dialogys not_found is
* DEFINITIVE (a flag-disabled Rpartstore is "unavailable" exactly like the cooldown).
* Default (unset / not "false") is TRUE — behaviour completely unchanged.
*/
describe("VinpinDriverService — VINPIN_RPARTSTORE_ENABLED=false skips Rpartstore in both Renault paths", () => {
const savedEnv = { ...process.env };
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
beforeEach(() => {
process.env.VINPIN_ENABLED = "true";
mockOcr.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...savedEnv };
});
it("COLD runRenaultFlow (flag=false): never opens Rpartstore, goes straight to Dialogys, and a Dialogys not_found is DEFINITIVE (no retry)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
// NOT in cooldown — the ONLY reason to skip is the disabled flag.
const acquire = vi.spyOn(any as never, "acquireLoadedRpartstore");
const ensureR = vi.spyOn(any as never, "ensureRpartstore");
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
const runDialogys = vi
.spyOn(any as never, "runDialogys")
.mockResolvedValue({ status: "not_found" } as never);
const outcome = await (
(any as AnyDriver).runRenaultFlow as (
p: unknown,
v: string,
c: unknown,
d: number,
) => Promise<{ status: string }>
).call(driver, fakePage(), "VF1553K05TR596166", { rpartstoreEnabled: false }, FAR_DEADLINE());
expect(acquire).not.toHaveBeenCalled(); // Rpartstore never opened
expect(ensureR).not.toHaveBeenCalled();
expect(runRpartstore).not.toHaveBeenCalled();
expect(runDialogys).toHaveBeenCalledTimes(1); // single shot — no budget-burn retry
expect(outcome.status).toBe("not_found"); // definitive — no ambiguous downgrade
});
it("WARM warmRenaultDecode (flag=false): never opens Rpartstore, decodes via Dialogys", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const acquire = vi.spyOn(any as never, "acquireLoadedRpartstore");
const ensureR = vi.spyOn(any as never, "ensureRpartstore");
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
vi.spyOn(any as never, "runDialogysSearch").mockResolvedValue({
status: "found",
parsed: { brand: "RENAULT", model: "MEGANE", modelYear: null },
rawText: "Megane",
via: "dialogys",
} as never);
const page = { keyboard: { press: vi.fn(async () => undefined) } };
const result = await (
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
).call(driver, page, "VF1LM1B0A37829019", { rpartstoreEnabled: false }, FAR_DEADLINE());
expect(acquire).not.toHaveBeenCalled(); // Rpartstore never opened
expect(ensureR).not.toHaveBeenCalled();
expect(runRpartstore).not.toHaveBeenCalled();
expect(result).toMatchObject({
brand: "RENAULT",
model: "MEGANE",
raw: { source: "vinpin-dialogys" },
});
});
it("DEFAULT (flag=true): COLD path still ATTEMPTS Rpartstore (behaviour unchanged)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const acquire = vi
.spyOn(any as never, "acquireLoadedRpartstore")
.mockResolvedValue(true as never);
vi.spyOn(any as never, "runRpartstore").mockResolvedValue({
status: "found",
parsed: { brand: "RENAULT", model: "CLIO", modelYear: null },
rawText: "Clio",
via: "rpartstore",
} as never);
const outcome = await (
(any as AnyDriver).runRenaultFlow as (
p: unknown,
v: string,
c: unknown,
d: number,
) => Promise<{ status: string }>
).call(driver, fakePage(), "VF1553K05TR596166", { rpartstoreEnabled: true }, FAR_DEADLINE());
expect(acquire).toHaveBeenCalledTimes(1); // Rpartstore still primary by default
expect(outcome.status).toBe("found");
});
});

View File

@@ -0,0 +1,707 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { VinpinDriverService, VinpinSessionDroppedError } from "./vinpin-driver.service";
import { VINPIN_COORDS, VINPIN_OCR, VINPIN_WINDOW_FOREGROUND } from "./vinpin.constants";
/**
* Livelock-breaker unit tests for the Vinpin decode driver. These exercise the
* failure/abort machinery (wall-clock budget, sessionPoisoned cold re-establish,
* VIN sanity gate) in isolation by stubbing the browser-driving privates — the
* shared Vinpin seat lives on prod and can't be driven from a test.
*/
describe("VinpinDriverService — livelock breakers", () => {
const savedEnv = { ...process.env };
const VALID_FIAT_VIN = "NM435600006H43436"; // 17 alphanumerics → fiat flow
beforeEach(() => {
// afterEach restores the full env snapshot, so VINPIN_DECODE_BUDGET_MS set by
// the budget test never leaks into the others.
process.env.VINPIN_ENABLED = "true";
process.env.VINPIN_USER = "user";
process.env.VINPIN_PASS = "pass";
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...savedEnv };
});
it("aborts a single decode that blows past the wall-clock budget, tears down and poisons the seat", async () => {
process.env.VINPIN_DECODE_BUDGET_MS = "50";
const driver = new VinpinDriverService();
const anyDriver = driver as unknown as Record<string, unknown>;
const closeSpy = vi.spyOn(anyDriver as never, "close").mockResolvedValue(undefined as never);
// ensureReady succeeds cheaply, then runVinFlow hangs forever → the budget
// timer must win the race.
vi.spyOn(anyDriver as never, "ensureReady").mockImplementation((async () => {
anyDriver.page = { isClosed: () => false };
}) as never);
vi.spyOn(anyDriver as never, "runVinFlow").mockReturnValue(
new Promise(() => {}) as never, // never resolves
);
const started = Date.now();
const result = await driver.decode(VALID_FIAT_VIN);
const elapsed = Date.now() - started;
expect(result).toBeNull();
expect(closeSpy).toHaveBeenCalled(); // torn down on abort
expect(anyDriver.sessionPoisoned).toBe(true); // seat marked poisoned
// Aborted near the budget, not after minutes / all 3 internal attempts.
expect(elapsed).toBeLessThan(2_000);
});
it("forces a full cold re-establish (close) when the seat was poisoned by a prior decode", async () => {
const driver = new VinpinDriverService();
const anyDriver = driver as unknown as Record<string, unknown>;
anyDriver.sessionPoisoned = true;
const closeSpy = vi.spyOn(anyDriver as never, "close").mockResolvedValue(undefined as never);
const fiatSpy = vi
.spyOn(anyDriver as never, "decodeFiatLocked")
.mockResolvedValue({ brand: "Fiat", model: "EGEA" } as never);
const result = await driver.decode(VALID_FIAT_VIN);
expect(closeSpy).toHaveBeenCalledTimes(1); // the poison-forced teardown
expect(anyDriver.sessionPoisoned).toBe(false); // flag consumed
expect(fiatSpy).toHaveBeenCalledTimes(1);
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
});
it("does NOT tear down a healthy warm session when the seat is not poisoned", async () => {
const driver = new VinpinDriverService();
const anyDriver = driver as unknown as Record<string, unknown>;
anyDriver.sessionPoisoned = false;
const closeSpy = vi.spyOn(anyDriver as never, "close").mockResolvedValue(undefined as never);
vi.spyOn(anyDriver as never, "decodeFiatLocked").mockResolvedValue({
brand: "Fiat",
model: "EGEA",
} as never);
const result = await driver.decode(VALID_FIAT_VIN);
expect(closeSpy).not.toHaveBeenCalled(); // healthy path untouched
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
});
it("skips obviously-junk input before it ever touches the shared seat", async () => {
const driver = new VinpinDriverService();
const anyDriver = driver as unknown as Record<string, unknown>;
const fiatSpy = vi.spyOn(anyDriver as never, "decodeFiatLocked");
expect(await driver.decode("SHORT")).toBeNull();
expect(await driver.decode("VF1RFE0065363319")).toBeNull(); // 16 chars
expect(await driver.decode("NM4356 0006H43436")).toBeNull(); // space (non-alnum)
expect(fiatSpy).not.toHaveBeenCalled();
});
it("still runs a well-formed VIN through the decode loop (sanity gate is not over-filtering)", async () => {
const driver = new VinpinDriverService();
const anyDriver = driver as unknown as Record<string, unknown>;
const fiatSpy = vi
.spyOn(anyDriver as never, "decodeFiatLocked")
.mockResolvedValue(null as never);
expect(await driver.decode(VALID_FIAT_VIN)).toBeNull();
expect(fiatSpy).toHaveBeenCalledTimes(1);
});
});
/**
* Warm-session daemon behaviours on the driver: warm-vs-cold routing, the
* keepalive that must SKIP while a decode holds the seat, and health-recovery
* (session-drop → teardown + re-warm + single retry). The browser-driving privates
* are stubbed — the real seat lives on prod.
*/
describe("VinpinDriverService — warm session", () => {
const savedEnv = { ...process.env };
const VALID_FIAT_VIN = "NM435600006H43436";
beforeEach(() => {
process.env.VINPIN_ENABLED = "true";
process.env.VINPIN_USER = "user";
process.env.VINPIN_PASS = "pass";
process.env.VINPIN_WARM_DAEMON = "true";
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...savedEnv };
});
function makeWarm(driver: VinpinDriverService) {
const any = driver as unknown as Record<string, unknown>;
any.warm = true;
any.browser = { isConnected: () => true };
any.page = { isClosed: () => false, mouse: { move: vi.fn(async () => undefined) } };
return any;
}
it("routes a decode to the WARM path when a healthy warm session is up", async () => {
const driver = new VinpinDriverService();
const any = makeWarm(driver);
const warmSpy = vi
.spyOn(any as never, "warmDecodeWithRecovery")
.mockResolvedValue({ brand: "Fiat", model: "EGEA" } as never);
const coldSpy = vi.spyOn(any as never, "decodeFiatLocked");
const result = await driver.decode(VALID_FIAT_VIN);
expect(warmSpy).toHaveBeenCalledTimes(1);
expect(coldSpy).not.toHaveBeenCalled(); // cold path bypassed while warm
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
});
it("falls back to the COLD path when VINPIN_WARM_DAEMON=false even if warm is set", async () => {
process.env.VINPIN_WARM_DAEMON = "false";
const driver = new VinpinDriverService();
const any = makeWarm(driver);
const warmSpy = vi.spyOn(any as never, "warmDecodeWithRecovery");
const coldSpy = vi
.spyOn(any as never, "decodeFiatLocked")
.mockResolvedValue({ brand: "Fiat", model: "EGEA" } as never);
await driver.decode(VALID_FIAT_VIN);
expect(warmSpy).not.toHaveBeenCalled();
expect(coldSpy).toHaveBeenCalledTimes(1);
});
it("keepalive SKIPS while a decode holds the seat (busy), and nudges when idle", async () => {
const driver = new VinpinDriverService();
const any = makeWarm(driver);
const move = (any.page as { mouse: { move: ReturnType<typeof vi.fn> } }).mouse.move;
// Busy (a decode is in flight) → no nudge, no lock contention.
any.busy = true;
await driver.keepalivePing();
expect(move).not.toHaveBeenCalled();
// Idle → the keepalive nudges the harmless in-window point.
any.busy = false;
await driver.keepalivePing();
expect(move).toHaveBeenCalledTimes(1);
});
it("on a dropped warm session, tears down + re-warms ONCE, then retries the decode once", async () => {
const driver = new VinpinDriverService();
const any = makeWarm(driver);
const warmDecode = vi
.spyOn(any as never, "warmDecode")
.mockRejectedValueOnce(new VinpinSessionDroppedError("dropped") as never)
.mockResolvedValueOnce({ brand: "Fiat", model: "EGEA" } as never);
const rewarm = vi.spyOn(any as never, "_warmUp").mockResolvedValue(true as never);
const result = await driver.decode(VALID_FIAT_VIN);
expect(warmDecode).toHaveBeenCalledTimes(2); // initial + one retry
expect(rewarm).toHaveBeenCalledTimes(1); // re-warmed exactly once
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
});
it("session-drop recovery gives up (null) when the re-warm fails — never throws", async () => {
const driver = new VinpinDriverService();
const any = makeWarm(driver);
vi.spyOn(any as never, "warmDecode").mockRejectedValue(
new VinpinSessionDroppedError("dropped") as never,
);
vi.spyOn(any as never, "_warmUp").mockResolvedValue(false as never);
const result = await driver.decode(VALID_FIAT_VIN);
expect(result).toBeNull();
expect(any.warm).toBe(false); // dropped seat marked not-warm → cold path next time
});
});
/**
* Rpartstore acquire-with-spinner-guard. A freshly-opened Rpartstore instance
* sometimes gets "born stuck" on an infinite spinner; the fix is to close the
* stuck instance and reopen a FRESH one, up to N times, inside the wall-clock
* budget, and only then fall back to Dialogys. These stub the browser-driving
* privates (real seat lives on prod) and drive the OCR-load verdict via a mocked
* pollRpartstoreState, which reads LOADED off the full frame and the HARD
* launch-error modal off an UPSCALED crop (spinner = {loaded:false,launchError:
* false}, loaded = {loaded:true}, DOWN = {launchError:true}).
*/
describe("VinpinDriverService — Rpartstore spinner guard", () => {
const savedEnv = { ...process.env };
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
beforeEach(() => {
process.env.VINPIN_ENABLED = "true";
process.env.VINPIN_USER = "user";
process.env.VINPIN_PASS = "pass";
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...savedEnv };
});
function acquire(driver: VinpinDriverService, warm: boolean, deadline: number): Promise<boolean> {
const any = driver as unknown as Record<string, unknown>;
return (
any.acquireLoadedRpartstore as (
page: unknown,
cfg: unknown,
deadline: number,
warm: boolean,
) => Promise<boolean>
).call(driver, {}, {}, deadline, warm);
}
it("reuses Rpartstore when the FIRST open already loaded (no reopen, no close)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore");
const close = vi.spyOn(any as never, "closeRpartstoreTab");
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
loaded: true,
launchError: false,
text: "Şasi no ile arama",
} as never);
expect(await acquire(driver, true, FAR_DEADLINE())).toBe(true);
expect(reopen).not.toHaveBeenCalled();
expect(close).not.toHaveBeenCalled();
});
it("closes a born-stuck spinner and reopens a FRESH instance that loads (warm)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
const raise = vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore").mockResolvedValue(true as never);
const close = vi
.spyOn(any as never, "closeRpartstoreTab")
.mockResolvedValue(undefined as never);
// open 1 → spinner (not loaded); open 2 (fresh) → loaded.
vi.spyOn(any as never, "pollRpartstoreState")
.mockResolvedValueOnce({ loaded: false, launchError: false, text: "spinner" } as never)
.mockResolvedValueOnce({ loaded: true, launchError: false, text: "Ne arıyorsunuz" } as never);
expect(await acquire(driver, true, FAR_DEADLINE())).toBe(true);
expect(raise).toHaveBeenCalledTimes(1); // only the first attempt raises
expect(close).toHaveBeenCalledTimes(1); // the stuck instance was closed
expect(reopen).toHaveBeenCalledTimes(1); // one fresh reopen, which loaded
});
it("gives up (false) after maxOpens reopen attempts all spinner → caller falls to Dialogys", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
vi.spyOn(any as never, "ensureRpartstore").mockResolvedValue(true as never); // cold open path
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore").mockResolvedValue(true as never);
const close = vi
.spyOn(any as never, "closeRpartstoreTab")
.mockResolvedValue(undefined as never);
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
loaded: false,
launchError: false,
text: "spinner",
} as never); // never loads
expect(await acquire(driver, false, FAR_DEADLINE())).toBe(false);
// maxOpens = 3: open 1 (ensureRpartstore) + 2 reopens; a close after each miss.
expect(reopen).toHaveBeenCalledTimes(2);
expect(close).toHaveBeenCalledTimes(3);
});
it("bails immediately (false) without opening when the budget is already spent (no livelock)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
const raise = vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
const poll = vi.spyOn(any as never, "pollRpartstoreState");
expect(await acquire(driver, true, Date.now() - 1)).toBe(false);
expect(raise).not.toHaveBeenCalled();
expect(poll).not.toHaveBeenCalled();
});
it("HARD launch error (Rpartstore DOWN) → returns false after ONE open, NO reopens, dismiss clicked → caller falls to Dialogys", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
const raise = vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore");
const close = vi.spyOn(any as never, "closeRpartstoreTab");
// Upscaled modal-crop OCR of "Ошибка запуска каталога" — the VERBATIM string
// captured live 2026-07-15 on seat trvinpin47828 (see rpartstoreLaunchError).
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
loaded: false,
launchError: true,
text: "Rpartstore X Owwu6ka 3anycka KaTanora",
} as never);
const page = {
mouse: { click: vi.fn(async () => undefined) },
waitForTimeout: vi.fn(async () => undefined),
};
const result = await (
any.acquireLoadedRpartstore as (
p: unknown,
c: unknown,
d: number,
w: boolean,
) => Promise<boolean>
).call(driver, page, {}, FAR_DEADLINE(), true);
expect(result).toBe(false); // → warmRenaultDecode/runRenaultFlow go straight to Dialogys
expect(raise).toHaveBeenCalledTimes(1); // opened exactly ONCE
expect(reopen).not.toHaveBeenCalled(); // NO reopens burned (~54s saved)
expect(close).not.toHaveBeenCalled(); // not treated as a spinner
// The launch-error modal's OK button was clicked to dismiss it.
expect(page.mouse.click).toHaveBeenCalledWith(
VINPIN_COORDS.rpartstoreLaunchErrorOk.x,
VINPIN_COORDS.rpartstoreLaunchErrorOk.y,
);
});
it("genuine spinner (no launch-error string) STILL triggers the close+reopen loop (unchanged)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore").mockResolvedValue(true as never);
const close = vi
.spyOn(any as never, "closeRpartstoreTab")
.mockResolvedValue(undefined as never);
// Bare chrome, no content AND no launch-error → born-stuck spinner. Reopen loop.
vi.spyOn(any as never, "pollRpartstoreState")
.mockResolvedValueOnce({ loaded: false, launchError: false, text: "RPartStore" } as never)
.mockResolvedValueOnce({ loaded: true, launchError: false, text: "Ne arıyorsunuz" } as never);
expect(await acquire(driver, true, FAR_DEADLINE())).toBe(true);
expect(close).toHaveBeenCalledTimes(1); // stuck instance closed (spinner path intact)
expect(reopen).toHaveBeenCalledTimes(1); // one fresh reopen, which loaded
});
it("launch-error OCR pattern matches the real Cyrillic and its live-captured eng-OCR transliterations", () => {
expect(VINPIN_OCR.rpartstoreLaunchError.test("Ошибка запуска каталога")).toBe(true);
// VERBATIM upscaled-crop OCR captured live 2026-07-15 (seat trvinpin47828): the
// "Ошибка" head varies (Owwu6ka/OwwbKa) but "3anycka"+"KaTanora" are stable.
expect(VINPIN_OCR.rpartstoreLaunchError.test("Rpartstore X Owwu6ka 3anycka KaTanora")).toBe(
true,
);
expect(VINPIN_OCR.rpartstoreLaunchError.test("Volvo (X) OwwbKa 3anycka KaTanora VY")).toBe(
true,
);
expect(VINPIN_OCR.rpartstoreLaunchError.test("Owwnbka 3anycka KaTanora")).toBe(true);
// Must NOT fire on a healthy loaded Rpartstore home (no false short-circuit) —
// this is the exact full-frame text a LOADED home shows.
expect(VINPIN_OCR.rpartstoreLaunchError.test("Şasi no ile arama Güncel araçlar")).toBe(false);
// Nor on the brand grid alone (the tiles behind the modal, read on a 1× frame).
expect(
VINPIN_OCR.rpartstoreLaunchError.test("Audi Fiat Renault KIA SEAT Volkswagen Toyota"),
).toBe(false);
});
it("launch error over the grid (ensureRpartstore couldn't confirm a window) → bail to Dialogys, NO reopens", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
// Cold path: ensureRpartstore returns false because the DOWN Rpartstore shows
// only the centered launch-error modal over the grid (no catalog chrome).
vi.spyOn(any as never, "ensureRpartstore").mockResolvedValue(false as never);
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore");
const close = vi.spyOn(any as never, "closeRpartstoreTab");
// The upscaled modal crop DOES read the launch error → dismiss + bail.
vi.spyOn(any as never, "rpartstoreLaunchErrorPresent").mockResolvedValue(true as never);
const page = {
mouse: { click: vi.fn(async () => undefined) },
waitForTimeout: vi.fn(async () => undefined),
};
const result = await (
any.acquireLoadedRpartstore as (
p: unknown,
c: unknown,
d: number,
w: boolean,
) => Promise<boolean>
).call(driver, page, {}, FAR_DEADLINE(), false);
expect(result).toBe(false); // → straight to Dialogys
expect(reopen).not.toHaveBeenCalled(); // no reopens burned
expect(close).not.toHaveBeenCalled();
expect(page.mouse.click).toHaveBeenCalledWith(
VINPIN_COORDS.rpartstoreLaunchErrorOk.x,
VINPIN_COORDS.rpartstoreLaunchErrorOk.y,
);
});
it("warm Renault decode: loaded Rpartstore is PRIMARY — Dialogys is not touched on a hit", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(true as never);
vi.spyOn(any as never, "runRpartstore").mockResolvedValue({
status: "found",
parsed: { brand: "RENAULT", model: "KADJAR", modelYear: null },
rawText: "RENAULT Kadjar",
via: "rpartstore",
} as never);
const dialogys = vi.spyOn(any as never, "ensureWarmWindow");
const page = { keyboard: { press: vi.fn(async () => undefined) } };
const result = await (
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
).call(driver, page, "VF1RFE00653633190", { rpartstoreEnabled: true }, FAR_DEADLINE());
expect(result).toMatchObject({
brand: "RENAULT",
model: "KADJAR",
raw: { source: "vinpin-rpartstore" },
});
expect(dialogys).not.toHaveBeenCalled(); // Rpartstore hit → Dialogys never consulted
});
it("warm Renault decode: exhausted Rpartstore → LAST-RESORT Dialogys fallback still decodes", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(false as never); // never loaded
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
vi.spyOn(any as never, "runDialogysSearch").mockResolvedValue({
status: "found",
parsed: { brand: "RENAULT", model: "MEGANE", modelYear: null },
rawText: "Megane II Classic",
via: "dialogys",
} as never);
const page = { keyboard: { press: vi.fn(async () => undefined) } };
const result = await (
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
).call(driver, page, "VF1LM1B0A37829019", { rpartstoreEnabled: true }, FAR_DEADLINE());
expect(runRpartstore).not.toHaveBeenCalled(); // never ran the search on a stuck Rpartstore
expect(result).toMatchObject({
brand: "RENAULT",
model: "MEGANE",
raw: { source: "vinpin-dialogys" },
});
});
it("acquire give-up (budget-exhausted) DEFENSIVELY dismisses a leftover modal (Escape + launch-error OK) even when the OCR branch never fires", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
// Budget already spent → the inner acquire returns false at its first budget
// check WITHOUT ever hitting the OCR error-detect branch (raise/poll not called).
const raise = vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
const poll = vi.spyOn(any as never, "pollRpartstoreState");
const page = {
keyboard: { press: vi.fn(async () => undefined) },
mouse: { click: vi.fn(async () => undefined) },
waitForTimeout: vi.fn(async () => undefined),
};
const result = await (
any.acquireLoadedRpartstore as (
p: unknown,
c: unknown,
d: number,
w: boolean,
) => Promise<boolean>
).call(driver, page, {}, Date.now() - 1, true);
expect(result).toBe(false);
expect(raise).not.toHaveBeenCalled(); // budget already gone → never opened
expect(poll).not.toHaveBeenCalled();
// The wrapper's unconditional defensive clear ran on the false path: Escape +
// click the centered launch-error OK, so no modal is left to wedge the grid-return.
expect(page.keyboard.press).toHaveBeenCalledWith("Escape");
expect(page.mouse.click).toHaveBeenCalledWith(
VINPIN_COORDS.rpartstoreLaunchErrorOk.x,
VINPIN_COORDS.rpartstoreLaunchErrorOk.y,
);
});
it("grid-return dismisses a blocking modal BEFORE each tab-✕ close (can't wedge into a relogin thrash)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
const order: string[] = [];
// ocrRegion on a fake page fails-safe to "" (never the brand grid) → the loop runs.
const dismiss = vi.spyOn(any as never, "dismissBlockingModal").mockImplementation((async () => {
order.push("dismiss");
}) as never);
vi.spyOn(any as never, "ensureBrandGrid").mockResolvedValue(undefined as never);
const page = {
mouse: {
click: vi.fn(async () => {
order.push("click");
}),
},
keyboard: { press: vi.fn(async () => undefined) },
waitForTimeout: vi.fn(async () => undefined),
};
await (any.returnToBrandGrid as (p: unknown, c: unknown) => Promise<void>).call(
driver,
page,
{},
);
expect(dismiss).toHaveBeenCalled();
// The modal-clear precedes the tab-✕ click on the very first iteration.
expect(order[0]).toBe("dismiss");
expect(order[1]).toBe("click");
expect(page.mouse.click).toHaveBeenCalledWith(
VINPIN_COORDS.catalogTabClose.x,
VINPIN_COORDS.catalogTabClose.y,
);
});
it("caps the resumed-desktop escalation (closeStrayRunningApps, NOT logout+relogin) to ONE attempt per decode (no thrash)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
// Fresh decode → escalation budget starts unused.
any.reloginUsedThisDecode = false;
vi.spyOn(any as never, "submitVinPowerLogin").mockResolvedValue(false as never);
vi.spyOn(any as never, "dismissDisconnected").mockResolvedValue(undefined as never);
// logout+relogin is retired — it must not exist as a method any more.
expect(any.logoutAndRelogin).toBeUndefined();
// The escalation itself fails — the point is it's only TRIED once across two returns.
const escalate = vi
.spyOn(any as never, "closeStrayRunningApps")
.mockResolvedValue(false as never);
const page = {
waitForTimeout: vi.fn(async () => undefined),
keyboard: { press: vi.fn(async () => undefined) },
mouse: { click: vi.fn(async () => undefined) },
};
const ensureGrid = (p: unknown, c: unknown, allow?: boolean) =>
(any.ensureBrandGrid as (p: unknown, c: unknown, a?: boolean) => Promise<void>).call(
driver,
p,
c,
allow,
);
// Two grid-returns inside ONE decode (as returnToBrandGrid's loop would do). The
// 2nd must NOT re-escalate → no repeated recovery thrash.
await ensureGrid(page, {}, true);
await ensureGrid(page, {}, true);
expect(escalate).toHaveBeenCalledTimes(1); // one escalation only — the 2nd is capped
expect(any.reloginUsedThisDecode).toBe(true);
expect(any.sessionPoisoned).toBe(true); // capped path poisons for a clean cold restart
});
});
/**
* Warm-path Fiat fixes: the disambiguated foreground regex (Defect B) and the
* warmDecode Fiat branch that must never fail silently (Defect A) — genuine
* not-found → null, wrong/garbled window → cold fallback, no Fiat warm window →
* cold fallback.
*/
describe("VinpinDriverService — warm Fiat not-found / wrong-window hardening", () => {
const savedEnv = { ...process.env };
const VALID_FIAT_VIN = "NM435600006H43436"; // 17 alphanumerics → fiat warm window
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
beforeEach(() => {
process.env.VINPIN_ENABLED = "true";
process.env.VINPIN_USER = "user";
process.env.VINPIN_PASS = "pass";
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...savedEnv };
});
function fakePage() {
return { isClosed: () => false, keyboard: { press: vi.fn(async () => undefined) } };
}
function callWarmDecode(driver: VinpinDriverService, vin: string, deadline: number) {
const any = driver as unknown as Record<string, unknown>;
return (any.warmDecode as (v: string, c: unknown, d: number) => Promise<unknown>).call(
driver,
vin,
{},
deadline,
);
}
it("FIX 1: VINPIN_WINDOW_FOREGROUND.fiat does NOT match a Dialogys 'ПОИСК' string but DOES match Fiat 'Dealer'/'ePER'/'TIPO-EGEA'", () => {
const fg = VINPIN_WINDOW_FOREGROUND.fiat;
// The shared-token bug: the Dialogys ПОИСК search button must NOT read as the
// Fiat foreground (this false-positive typed the VIN into Dialogys — Defect B).
expect(fg.test("ПОИСК Dialogys ИЗМЕНИТЬ")).toBe(false);
expect(fg.test("ПОИСК")).toBe(false);
// Fiat-window-only chrome + VIN-panel model tokens still match.
expect(fg.test("Fiat Dealer")).toBe(true);
expect(fg.test("ePER window")).toBe(true);
expect(fg.test("6J - TIPO - EGEA (2015-2021)")).toBe(true);
expect(fg.test("Spare Parts Catalogue")).toBe(true);
// Sanity: the Dialogys foreground regex STILL recognises ПОИСК (its own detection
// is unaffected — only the Fiat regex dropped the shared token).
expect(VINPIN_WINDOW_FOREGROUND.dialogys.test("ПОИСК Dialogys ИЗМЕНИТЬ")).toBe(true);
});
it("FIX 2: Fiat unusable-parse NOT-on-panel (wrong/garbled window) → falls back to decodeFiatLocked, not a silent null", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
any.warm = true;
any.page = fakePage();
any.warmWindows = { fiat: true }; // has a Fiat window → not the FIX-4 route
vi.spyOn(any as never, "assertSessionAlive").mockResolvedValue(undefined as never);
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
vi.spyOn(any as never, "runVinFlow").mockResolvedValue(null as never); // no usable parse
// Frame shows the DIALOGYS window (Defect B), not the Fiat VIN panel.
vi.spyOn(any as never, "ocrFrame").mockResolvedValue("ПОИСК Dialogys ИЗМЕНИТЬ" as never);
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
const cold = vi
.spyOn(any as never, "decodeFiatLocked")
.mockResolvedValue({ brand: "Fiat", model: "TIPO" } as never);
const result = await callWarmDecode(driver, VALID_FIAT_VIN, FAR_DEADLINE());
expect(cold).toHaveBeenCalledTimes(1); // cold fallback, NOT a silent null
expect(result).toMatchObject({ brand: "Fiat", model: "TIPO" });
expect(any.warm).toBe(false); // warm dropped (untrusted Fiat window)
expect(close).toHaveBeenCalled();
});
it("FIX 2: Fiat genuine not-found (on the VIN panel + not-found string) → returns null, no cold fallback", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
any.warm = true;
any.page = fakePage();
any.warmWindows = { fiat: true };
vi.spyOn(any as never, "assertSessionAlive").mockResolvedValue(undefined as never);
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
vi.spyOn(any as never, "runVinFlow").mockResolvedValue(null as never);
// Positively on the Fiat Spare-Parts catalogue AND a genuine "не найден" miss.
vi.spyOn(any as never, "ocrFrame").mockResolvedValue(
"Spare Parts Catalogue TIPO не найден" as never,
);
const cold = vi.spyOn(any as never, "decodeFiatLocked");
const result = await callWarmDecode(driver, VALID_FIAT_VIN, FAR_DEADLINE());
expect(result).toBeNull(); // a real miss is correctly null
expect(cold).not.toHaveBeenCalled(); // NOT re-driven cold
});
it("FIX 4: routes a Fiat VIN straight to the cold path when warmWindows.fiat is false (no Fiat warm window)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as Record<string, unknown>;
any.warm = true;
any.page = fakePage();
any.warmWindows = { fiat: false, dialogys: true }; // Renault-only partial warm
vi.spyOn(any as never, "assertSessionAlive").mockResolvedValue(undefined as never);
const ensure = vi.spyOn(any as never, "ensureWarmWindow");
const runVinFlow = vi.spyOn(any as never, "runVinFlow");
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
const cold = vi
.spyOn(any as never, "decodeFiatLocked")
.mockResolvedValue({ brand: "Fiat", model: "EGEA" } as never);
const result = await callWarmDecode(driver, VALID_FIAT_VIN, FAR_DEADLINE());
expect(cold).toHaveBeenCalledTimes(1); // straight to cold — never touched a warm window
expect(ensure).not.toHaveBeenCalled();
expect(runVinFlow).not.toHaveBeenCalled();
expect(result).toMatchObject({ brand: "Fiat", model: "EGEA" });
expect(any.warm).toBe(false);
expect(close).toHaveBeenCalled();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,328 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Clean-teardown + grid-return reliability tests for the Vinpin driver. These
* exercise the warm-establish fix: the clean-teardown routine (close resumed
* catalog windows via the corrected tab-✕, then log out of the RDS session before
* dropping the browser) and ensureBrandGrid's escalation to logout+relogin. OCR is
* mocked so the screen-state sequence is fully controllable — the real seat lives
* on prod and can't be driven from a test.
*/
vi.mock("./vinpin.ocr", () => ({
ocrRegion: vi.fn(async () => ""),
pollForText: vi.fn(async () => ({ matched: false, text: "" })),
terminateOcr: vi.fn(async () => undefined),
}));
import { VinpinDriverService } from "./vinpin-driver.service";
import { ocrRegion } from "./vinpin.ocr";
const mockOcr = vi.mocked(ocrRegion);
type AnyDriver = Record<string, unknown>;
function fakePage(sink: (x: number, y: number) => void) {
return {
isClosed: () => false,
frames: () => [] as unknown[],
mouse: { click: vi.fn(async (x: number, y: number) => sink(x, y)) },
keyboard: { press: vi.fn(async () => undefined) },
waitForTimeout: vi.fn(async () => undefined),
};
}
describe("VinpinDriverService — clean teardown", () => {
const savedEnv = { ...process.env };
beforeEach(() => {
process.env.VINPIN_ENABLED = "true";
process.env.VINPIN_USER = "user";
process.env.VINPIN_PASS = "pass";
process.env.VINPIN_WARM_DAEMON = "true";
mockOcr.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...savedEnv };
});
it("teardownWarm closes each resumed catalog window (tab-✕) then logs out BEFORE closing the browser", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const events: string[] = [];
vi.spyOn(any as never, "close").mockImplementation((async () => {
events.push("close");
}) as never);
any.browser = { isConnected: () => true };
any.context = {};
any.warm = true;
any.page = fakePage((x, y) => {
if (x === 93 && y === 45) events.push("tabClose");
if (x === 1543 && y === 877) events.push("logout");
});
// Two catalog windows open, then a clear desktop.
mockOcr
.mockResolvedValueOnce("RPartStore")
.mockResolvedValueOnce("ePER Dealer")
.mockResolvedValue("");
await driver.teardownWarm();
// Both windows closed via the corrected tab-✕, then logout, then browser close.
expect(events).toEqual(["tabClose", "tabClose", "logout", "close"]);
expect(any.warm).toBe(false);
});
it("clean-teardown still logs out when no catalog window is open (bounded, no-op close loop)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const clicks: Array<[number, number]> = [];
any.browser = { isConnected: () => true };
any.context = {};
any.page = fakePage((x, y) => clicks.push([x, y]));
mockOcr.mockResolvedValue(""); // desktop already clear
await (any.cleanTeardown as () => Promise<void>).call(driver);
expect(clicks.filter(([x, y]) => x === 93 && y === 45)).toHaveLength(0); // nothing to close
expect(clicks).toContainEqual([1543, 877]); // still logs out to end the RDS session
});
it("logout dismisses any blocking modal FIRST, and recovers a Disconnected drop by closing it + relaunching VINPIN for a fresh grid", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const events: string[] = [];
any.browser = { isConnected: () => true };
any.context = {};
any.page = fakePage((x, y) => {
if (x === 868 && y === 530) events.push("dismissModal"); // rpartstoreLaunchErrorOk
if (x === 1543 && y === 877) events.push("logout"); // Çıkış yap
if (x === 953 && y === 505) events.push("disconnectedClose");
if (x === 40 && y === 256) events.push("relaunchVinpin"); // vinpinApp
});
// (1) close-loop: desktop already clear → break. (2) after logout: a Disconnected
// dialog surfaced (dirty channel drop). (3) closing it lands on the launcher.
mockOcr
.mockResolvedValueOnce("") // close-loop: nothing open
.mockResolvedValueOnce("You have been disconnected") // after Çıkış yap
.mockResolvedValueOnce("No Running Items Available"); // launcher after Close
await (any.cleanTeardown as () => Promise<void>).call(driver);
// Modal dismissed BEFORE logout; then the disconnect is closed and VINPIN relaunched.
expect(events).toEqual(["dismissModal", "logout", "disconnectedClose", "relaunchVinpin"]);
});
it("logout does NOT relaunch VINPIN when logout was a clean log-off (no Disconnected dialog)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const clicks: Array<[number, number]> = [];
any.browser = { isConnected: () => true };
any.context = {};
any.page = fakePage((x, y) => clicks.push([x, y]));
mockOcr.mockResolvedValue(""); // clear desktop, and no Disconnected dialog after logout
await (any.cleanTeardown as () => Promise<void>).call(driver);
expect(clicks).toContainEqual([1543, 877]); // logged out
expect(clicks).not.toContainEqual([953, 505]); // no disconnect to close
expect(clicks).not.toContainEqual([40, 256]); // VINPIN not relaunched
});
it("does NOT relaunch VINPIN when logout lands on the HTML-Access launcher (clean log-off, not a drop)", async () => {
// The clean-logout launcher OCRs its "HTML Access" branding, which the broad
// sessionDropped token matches — but the launcher tokens must veto the recovery
// so a clean log-off is never mistaken for a Disconnected drop (would spuriously
// relaunch VinPower and leave a dirty resumed session).
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const clicks: Array<[number, number]> = [];
any.browser = { isConnected: () => true };
any.context = {};
any.page = fakePage((x, y) => clicks.push([x, y]));
mockOcr
.mockResolvedValueOnce("") // close-loop: nothing open
.mockResolvedValueOnce("VMware Horizon HTML Access — No Running Items Available"); // clean launcher after logout
await (any.cleanTeardown as () => Promise<void>).call(driver);
expect(clicks).toContainEqual([1543, 877]); // logged out
expect(clicks).not.toContainEqual([953, 505]); // NOT treated as a disconnect
expect(clicks).not.toContainEqual([40, 256]); // VINPIN NOT relaunched
});
it("clean-teardown is a no-op when the browser/page is already gone", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
any.browser = null;
any.context = null;
any.page = null;
// Must not throw and must not touch OCR.
await expect((any.cleanTeardown as () => Promise<void>).call(driver)).resolves.toBeUndefined();
expect(mockOcr).not.toHaveBeenCalled();
});
});
/**
* A running-app row handle for the Horizon "Running" panel. `$` resolves the
* name element (textContent) and the per-app close ✕; clicking the ✕ removes the
* app from the shared running list (models a real window teardown).
*/
function fakeRunningApp(name: string, onClose: (n: string) => void) {
return {
$: async (sel: string) => {
if (/running-app-name|focused-app-name/.test(sel)) {
return { textContent: async () => name };
}
if (/icon-close-app-image/.test(sel)) {
return { click: async () => onClose(name) };
}
return null;
},
};
}
/**
* A fake page that also exposes the Horizon client-chrome DOM (`$`, `$$`,
* `page.click`) used by closeStrayRunningApps. `apps` is the mutable running list;
* closing a stray removes it. `closed` records the order strays were terminated.
*/
function fakeDomPage(apps: string[], sink?: (x: number, y: number) => void) {
const running = [...apps];
const closed: string[] = [];
const domClicks: string[] = [];
const page = {
isClosed: () => false,
frames: () => [] as unknown[],
mouse: { click: vi.fn(async (x: number, y: number) => sink?.(x, y)) },
keyboard: { press: vi.fn(async () => undefined) },
waitForTimeout: vi.fn(async () => undefined),
$: async (sel: string) =>
sel === "#sidebar-toggler" || sel === "#available-VINPIN" ? {} : null,
$$: async (sel: string) =>
sel === "ul.running-app"
? running.map((n) =>
fakeRunningApp(n, (name) => {
closed.push(name);
const idx = running.indexOf(name);
if (idx >= 0) running.splice(idx, 1);
}),
)
: [],
click: vi.fn(async (sel: string) => {
domClicks.push(sel);
}),
};
return { page, running, closed, domClicks };
}
describe("VinpinDriverService — ensureBrandGrid stray-app (Running-panel) recovery", () => {
const savedEnv = { ...process.env };
const cfg = { url: "https://vinpin.test", user: "user", pass: "pass" };
beforeEach(() => {
process.env.VINPIN_ENABLED = "true";
mockOcr.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...savedEnv };
});
it("closeStrayRunningApps closes every non-VinPower app via the DOM ✕ and leaves only VinPower", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const { page, running, closed } = fakeDomPage([
"VinPower",
"Renault Rpartstore",
"Loading application...",
]);
// After the strays close + the sidebar collapses, the canvas is on the brand grid.
mockOcr.mockResolvedValue("Volkswagen SsangYong TecDoc");
const ok = await (
any.closeStrayRunningApps as (p: unknown, c: unknown) => Promise<boolean>
).call(driver, page, cfg);
expect(ok).toBe(true);
// Both non-VinPower apps terminated via their per-app ✕, in row order.
expect(closed).toEqual(["Renault Rpartstore", "Loading application..."]);
expect(running).toEqual(["VinPower"]); // VinPower kept
expect(any.sessionPoisoned).toBe(false); // reached a confirmed-clean grid
});
it("closeStrayRunningApps degrades gracefully (false, no throw) when the sidebar DOM is absent", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const page = fakePage(() => undefined); // no $ / $$ / page.click — canvas-only render
mockOcr.mockResolvedValue("");
await expect(
(any.closeStrayRunningApps as (p: unknown, c: unknown) => Promise<boolean>).call(
driver,
page,
cfg,
),
).resolves.toBe(false); // → caller falls back to the canvas tab-✕ / OCR path
});
it("ensureBrandGrid escalates via closeStrayRunningApps (NOT logout+relogin) + falls back to the tab-✕ (93,45), never the language-selector (1298,14)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const clicks: Array<[number, number]> = [];
const page = fakePage((x, y) => clicks.push([x, y]));
// Always a catalog window open → grid never reached → in-branch recovery + escalation.
mockOcr.mockResolvedValue("RPartStore catalog open");
// logout+relogin is retired — the method must not exist any more.
expect((any as Record<string, unknown>).logoutAndRelogin).toBeUndefined();
// DOM absent → the primary Running-panel recovery returns false → tab-✕ fallback.
const escalate = vi.spyOn(any as never, "closeStrayRunningApps");
await (any.ensureBrandGrid as (p: unknown, c: unknown, allow?: boolean) => Promise<void>).call(
driver,
page,
cfg,
true,
);
expect(escalate).toHaveBeenCalled(); // primary + escalation is the Running-panel recovery
expect(clicks).toContainEqual([93, 45]); // tab-✕ fallback still used
expect(clicks).not.toContainEqual([1298, 14]); // language-selector coord NEVER clicked
expect(any.sessionPoisoned).toBe(true); // recovery failed (DOM absent) → poison, no relogin
});
it("ensureBrandGrid clears the poison when closeStrayRunningApps reaches a clean grid", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const page = fakePage(() => undefined);
mockOcr.mockResolvedValue("RPartStore catalog open"); // never reaches grid by OCR
// The escalation succeeds (Running-panel recovery reached a fresh grid).
const escalate = vi
.spyOn(any as never, "closeStrayRunningApps")
.mockResolvedValue(true as never);
await (any.ensureBrandGrid as (p: unknown, c: unknown, allow?: boolean) => Promise<void>).call(
driver,
page,
cfg,
true,
);
expect(escalate).toHaveBeenCalled();
expect(any.sessionPoisoned).toBe(false); // recovery reached a confirmed-clean grid
});
it("with allowRelogin=false it does NOT run the end-of-loop escalation and poisons the seat (one-shot, no livelock)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const page = fakePage(() => undefined);
mockOcr.mockResolvedValue("RPartStore catalog open"); // never reaches grid
await (any.ensureBrandGrid as (p: unknown, c: unknown, allow?: boolean) => Promise<void>).call(
driver,
page,
cfg,
false,
);
expect(any.sessionPoisoned).toBe(true); // poisoned → next decode cold-restarts
});
});

View File

@@ -12,6 +12,8 @@
* coordinate if the brand-grid layout ever changes.
*/
import { getBrandFromWmi } from "@sase/shared";
/** Viewport the coordinates are calibrated for. */
export const VINPIN_VIEWPORT = { width: 1600, height: 900 } as const;
@@ -42,12 +44,106 @@ export const VINPIN_COORDS = {
vinSubmitArrow: { x: 1283, y: 189 },
/** "OK" button of the "vehicles not found" alert (genuine not-found result). */
notFoundOk: { x: 816, y: 448 },
// ─── Renault flow (Rpartstore primary + Dialogys fallback) ───
// Coordinates below are 1600x900 STARTING POINTS from the live benchmark on the
// permanent seat (trvinpin41080). They are OCR-verified+retried at runtime like
// the Fiat flow, so minor drift self-heals — but re-verify if layout changes.
// TUNE.
/** Window-close (X) button of an open catalog window. ⚠️ DO NOT USE on the
* HTML-Access desktop: this coord actually hits the LANGUAGE SELECTOR (it opens a
* dropdown that makes the brand grid unrecognizable and wedges the ensureBrandGrid
* thrash). It is NO LONGER clicked by ANY path — the browser-tab ✕ below is the
* only close action. Kept documented so the bad coordinate isn't re-introduced. */
windowClose: { x: 1298, y: 14 },
/** Browser-tab ✕ of an open catalog app — the PRIMARY window-close action. The
* tab sits just under the window title bar, e.g. "Renault Rpartstore ✕".
* Validated live: clicking (93,45) closed the catalog and returned to the clean
* brand grid (the old {135,45} missed the ✕). TUNE. */
catalogTabClose: { x: 93, y: 45 },
/** "Çıkış yap" logout button (bottom-right of the RDS/Horizon desktop). Ends the
* remote session so the NEXT warm-up starts from a fresh login/grid instead of
* resuming into the last-open dirty catalog desktop. Used by the clean-teardown
* routine. ⚠️ With a blocking modal up (e.g. the Rpartstore launch-error dialog)
* this becomes a channel DISCONNECT rather than a clean RDS log-off — so the
* teardown dismisses any modal BEFORE clicking it. TUNE. */
logoutButton: { x: 1543, y: 877 },
/** "Close" button of the Horizon "You have been disconnected" dialog. If "Çıkış
* yap" turned into a channel disconnect (dirty RDS resume), clicking this lands
* back on the HTML-Access launcher ("No Running Items") from which the VINPIN app
* is relaunched for a fresh VinPower grid. Verified @ 1600x900. TUNE. */
disconnectedClose: { x: 953, y: 505 },
/** Renault brand tile on the VinPower grid (opens the Rpartstore/Dialogys
* submenu). mousedown/up like the Fiat tile. TUNE. */
renaultBrand: { x: 450, y: 707 },
/** Rpartstore entry in the Renault submenu (PRIMARY catalog). TUNE. */
renaultRpartstore: { x: 584, y: 779 },
/** Dialogys entry in the Renault submenu (FALLBACK catalog). TUNE. */
renaultDialogys: { x: 584, y: 733 },
/** Both Renault catalogs pop a Russian-language dialog on open → its OK. TUNE. */
renaultLangOk: { x: 746, y: 454 },
/** Rpartstore "Şasi no ile arama" VIN field. Submit with Enter (NOT the yellow
* button — it moves). TUNE. */
rpartstoreVinField: { x: 735, y: 194 },
/** Rpartstore "RPartStore" home/logo (top-left) — resets to the search home
* between VINs (more reliable than close+reopen). TUNE. */
rpartstoreHome: { x: 110, y: 92 },
/** Rpartstore error-card close (✕) — dismisses the "Şasi seçilmedi" /
* "bulunamadı" overlay that otherwise covers the VIN field. TUNE. */
rpartstoreErrorClose: { x: 928, y: 167 },
/** Rpartstore yellow search button (fallback to Enter submit). TUNE. */
rpartstoreSearchBtn: { x: 975, y: 195 },
/** "OK" button of the Rpartstore hard launch-error modal ("Ошибка запуска
* каталога" / title "Renault Rpartstore") that fires when Rpartstore is DOWN
* server-side. Verified live @ 1600x900 (viewport frame) — dismisses the modal
* so the driver can fall straight back to Dialogys. TUNE. */
rpartstoreLaunchErrorOk: { x: 868, y: 530 },
/** Dialogys VIN field. TUNE. */
dialogysVinField: { x: 457, y: 258 },
/** Dialogys ПОИСК (search) button. TUNE. */
dialogysSearch: { x: 590, y: 382 },
/** Dialogys Home button (reset step 1). TUNE. */
dialogysHome: { x: 78, y: 102 },
/** Dialogys ИЗМЕНИТЬ А/М — change-vehicle (reset step 2). TUNE. */
dialogysChangeVehicle: { x: 1217, y: 160 },
} as const;
/** Screenshot clip (px) of the Renault Rpartstore/Dialogys vehicle-page HEADER
* region, fed to OCR. Rpartstore renders "RENAULT <model> (<code>)" /
* "DACIA <model>" + "Şasi: <VIN>" in x280960,y182258; Dialogys renders
* "<Model> (<platform>), <engine>" slightly higher. Captured a bit wider/taller
* than the benchmark box to tolerate drift. TUNE. */
export const VINPIN_RENAULT_HEADER_REGION = {
x: 260,
y: 150,
width: 720,
height: 140,
} as const;
/** Screenshot clip (px) of the decode-result modal body, fed to OCR. Captures
* the model line ("6J - TIPO - EGEA (2015-2021)"), SINCOM, trim, prod-date and
* the echoed VIN. Verified live. */
export const VINPIN_MODAL_REGION = { x: 250, y: 120, width: 820, height: 480 } as const;
* the echoed VIN. Verified live. Widened to ~900px for the warm-session daemon
* (some decode modals render wider on the permanent seat). */
export const VINPIN_MODAL_REGION = { x: 250, y: 120, width: 900, height: 500 } as const;
/** Screenshot clip (px) of the CENTERED Rpartstore hard launch-error modal
* ("Renault Rpartstore" / "Ошибка запуска каталога", OK ~868,530), fed to OCR
* UPSCALED (ocrRegion's default 3×). ⚠️ ROOT-CAUSE NOTE (verified live 2026-07-15
* on seat trvinpin47828): the modal is a small (~220×140px) centered Cyrillic
* dialog. OCR'ing the FULL 1600×900 frame at 1× (what the old load-poll did)
* CANNOT read it — tesseract returns the surrounding brand-grid tiles and drops
* the tiny modal text, so `rpartstoreLaunchError` never matched → the DOWN
* Rpartstore was misclassified as a "born-stuck spinner" and 3 reopens (~54s) were
* burned before Dialogys. Cropping to THIS region + 3× upscale makes the string
* legible ("Owwu6ka 3anycka KaTanora"), so the launch error is detected on the
* FIRST open and the flow bails straight to Dialogys. Sized generously around the
* screen-centered dialog to tolerate drift while excluding the right sidebar. */
export const VINPIN_RPARTSTORE_ERROR_REGION = {
x: 540,
y: 370,
width: 560,
height: 230,
} as const;
/** OCR keyword sets for state detection (case-insensitive). */
export const VINPIN_OCR = {
@@ -70,14 +166,236 @@ export const VINPIN_OCR = {
/** The decode modal actually carries a result (a model / prod-date / MVS). */
modalHit:
/MVS|найден|Prod\.?\s*date|TIPO|EGEA|DOBLO|PALIO|PANDA|PUNTO|LINEA|DUCATO|QUBO|FIORINO|ULYSSE/i,
/** Genuine "no vehicle found" outcome (the catalog has no record). */
/** Genuine "no vehicle found" outcome (the catalog has no record). Used against
* the cropped decode-modal region in runVinFlow, where the loose `Catalogue`
* token is a useful settle signal. */
notFound: /не\s*найден|not\s*found|Catalogue/i,
/** STRICT genuine not-found — requires an actual "не найден"/"not found" string,
* WITHOUT the loose `Catalogue` token. Use this against the FULL frame (where
* the "Spare Parts Catalogue" header is always present, so `notFound` would
* false-match), e.g. the warm-Fiat genuine-miss-vs-garble decision — so an
* on-panel transient garble falls through to the cold retry instead of null. */
notFoundStrict: /не\s*найден|not\s*found/i,
// ─── Renault flow OCR sets ───
/** A catalog app window (Rpartstore / Dialogys / Fiat ePER) is open OVER the
* brand grid — its chrome must be closed to fall back to the grid. Covers the
* Rpartstore/Dialogys titles + the ePER "Spare Parts" / "Son araca geri dön"
* navigation that only appear inside an open catalog. */
catalogWindowOpen:
/RPartStore|Rpartstore|Dialogys|Son\s*araca|Spare\s*Parts|Yedek\s*par|ePER|Üniversal\s*ürün/i,
/** Renault submenu is open (Rpartstore + Dialogys entries visible). */
renaultSubmenu: /Rpartstore|Rpart|Dialogys|Dialog/i,
/** Rpartstore app/window is open (home OR a vehicle page) — its chrome tokens
* ("RPartStore", "Güncel araçlar", "Ne arıyorsunuz", "Grup siparişi",
* "Şasi") appear only inside Rpartstore. */
rpartstoreOpen: /RPartStore|Rpartstore|Güncel\s*ara|Grup\s*sipariş|arıyor|Şasi|Sasi/i,
/** Rpartstore home/search page (the VIN search field is up). */
rpartstoreReady: /Güncel\s*ara|arıyor|Grup\s*sipariş|Şasi\s*no|Sasi\s*no|Ara\b/i,
/** Rpartstore search-home actually RENDERED its landing markers ("Şasi no ile
* arama" / "Ne arıyorsunuz" / "Güncel araçlar" / "Grup siparişi"). This is the
* spinner-guard signal: it distinguishes a LOADED home from the "born-stuck"
* infinite spinner, which shows only the window CHROME ("RPartStore" title —
* matched by rpartstoreOpen) with no search-home content. Kept strictly to
* content-only tokens so a spinning-but-titled window never false-passes. */
rpartstoreLoaded:
/Şasi\s*no\s*ile|Sasi\s*no\s*ile|Ne\s*arıyor|Ne\s*ariyor|Güncel\s*ara(ç|c)|Guncel\s*ara(ç|c)|Grup\s*sipariş|arıyorsunuz/i,
/** Rpartstore HARD launch-error modal — the Russian "Ошибка запуска каталога"
* (Catalog launch error) dialog that fires within ~8-10s of opening when
* Rpartstore is DOWN server-side (entitlement/backend failure). Reopening never
* helps, so detecting this must SHORT-CIRCUIT straight to Dialogys instead of
* burning the reopen budget. ⚠️ This must be read from an UPSCALED crop of
* `VINPIN_RPARTSTORE_ERROR_REGION` — on a full 1×frame the modal is illegible
* (see that region's note). The eng-OCR of the Cyrillic "Ошибка запуска
* каталога" transliterates to "Owwu6ka 3anycka KaTanora" / "OwwbKa 3anycka
* KaTanora" (captured verbatim live 2026-07-15). The `3anycka`+`KaTanora` tokens
* are the stable anchors; the "Ошибка" head transliterates variably
* (Owwu6ka/OwwbKa/Owwnbka) so all seen forms are listed. Its title alone
* ("Renault Rpartstore") false-matches rpartstoreOpen — this content string is
* what disambiguates the error. */
rpartstoreLaunchError: /запуска\s*катал|Ошибка\s*запуск|3anycka|KaTanora|Owwu6ka|OwwbKa|Owwnbka/i,
/** Rpartstore decoded a vehicle — header shows RENAULT/DACIA <model> + Şasi. */
rpartstoreHit: /RENAULT|DACIA|Şasi\s*:|Sasi\s*:/i,
/** Rpartstore genuine not-found — the error card ("İlişikli araç bulunamadı" /
* "Şasi ... bulunamadı" / "Şasi seçilmedi"). */
rpartstoreNotFound: /bulunamad|İlişikli|Ilisikli|araç\s*bulun|seçilmedi|secilmedi/i,
/** Dialogys form/vehicle page reached (Cyrillic UI, ПОИСК / ИЗМЕНИТЬ). */
dialogysReady: /ПОИСК|ПОИC|Dialogys|VIN|ИЗМЕНИТЬ/i,
// ─── Warm-session daemon OCR sets ───
/** The RDS/Horizon session is still ALIVE — the desktop status panel shows the
* host/seat identifiers ("HORIZON-RDST01"/"HORIZON-RDST02" / "trvinpin41080").
* The seat now LOAD-BALANCES across RDST01 and RDST02, so both host tags count.
* Their ABSENCE combined with a `sessionDropped` marker means the session dropped. */
sessionAlive: /HORIZON[- ]?RDST0[12]|RDST0[12]|trvinpin\d*/i,
/** The RDS/Horizon session DROPPED — a "Disconnected"/reconnect dialog or the
* "no free sessions" gate. Triggers a teardown + re-warm + single retry. */
sessionDropped:
/Disconnected|nofreesession|no\s*free\s*session|reconnect|Session\s*(has\s*)?(been\s*)?(disconnected|ended|expired|timed\s*out)|HTML\s*Access/i,
} as const;
/**
* Which taskbar button belongs to which catalog window. The warm daemon OCRs the
* bottom taskbar and matches these labels to bind brand→coordinate at runtime
* (the button ORDER depends on the open sequence, so we never hardcode the
* mapping — see `orderTaskbarWindows`). `grid` matches the VinPower brand-grid
* button used to open the next catalog without closing the current one.
*/
export const VINPIN_WINDOW_LABELS = {
fiat: /ePER|Dealer|Fiat/i,
rpartstore: /RPartStore|Rpart/i,
dialogys: /Dialogys|Dialog/i,
grid: /VinPower|VINPIN|Marka|Brand/i,
} as const;
export type VinpinWindowKey = keyof typeof VINPIN_WINDOW_LABELS;
/**
* OCR keyword that confirms a given catalog window is now in the FOREGROUND after
* a taskbar raise (reuses the existing state sets). Used to verify a raise landed
* on the right window before running a decode on it.
*/
export const VINPIN_WINDOW_FOREGROUND: Record<"fiat" | "rpartstore" | "dialogys", RegExp> = {
// ⚠️ Fiat-window-ONLY tokens. The old `Поиск` token was SHARED with the Dialogys
// "ПОИСК" search button (`/Поиск/i.test("ПОИСК") === true`), so in a partial-warm
// session where Dialogys ends up foreground the Fiat raise falsely reported success
// and the VIN was typed into Dialogys → silent garbage. `Dealer`/`ePER` are Fiat
// ePER chrome (never on Dialogys/Rpartstore); the rest are VIN-panel model tokens.
fiat: /Spare\s*Parts\s*Catalogue|GRANDE\s*PANDA|TIPO|EGEA|DOBLO|Dealer|ePER/i,
rpartstore: /RPartStore|Rpartstore|Güncel\s*ara|Grup\s*sipariş|Şasi|Sasi/i,
dialogys: /ПОИСК|ПОИC|Dialogys|ИЗМЕНИТЬ/i,
};
/**
* Horizon HTML-Access client-chrome DOM selectors (REAL DOM, outside the Blast
* canvas — hidden until the sidebar is opened). Used by the state-agnostic
* stray-app recovery (`closeStrayRunningApps`): the sidebar "Running" panel lists
* each RDS app with a per-app close ✕ that TERMINATES that specific app window
* regardless of its internal modal/spinner/loading state. Proven live to cleanly
* close both a Fiat "Fiat Dealer" window and a Renault Rpartstore launch-error
* resume, each returning to a pristine brand grid — the root fix for the
* dirty-resume failure the canvas tab-✕ (only hits a TABBED window's ✕) could not
* recover. Treated as GIVEN from the live probe; every use is guarded (try/catch +
* presence check) so a wrong/absent selector degrades to the canvas/OCR fallback
* rather than throwing. ⚠️ A Connection-Server logout+relogin is NOT a recovery
* here: the seat publishes apps-only (no desktop → no Start-menu Log Off), so the
* RDS session ends ONLY on server-side idle timeout — logout+relogin provably
* RESUMES the same dirty window. This DOM path is the recovery instead. */
export const VINPIN_DOM = {
/** Left-edge grip (~10,450) that reveals/collapses the Horizon sidebar. */
sidebarToggler: "#sidebar-toggler",
/** One <ul> per running RDS app in the sidebar "Running" panel. */
runningApp: "ul.running-app",
/** The app's display name inside a running-app row ("VinPower", "Fiat Dealer",
* "Renault Rpartstore", "Dialogys", "Loading application..."). */
runningAppName: "li.running-app-name, li.focused-app-name",
/** Per-app close ✕ inside a running-app row — terminates THAT app window. */
appCloseImage: "li.icon-close-app-image",
/** "Available → VINPIN" launch tile (relaunch VinPower if it was closed). */
availableVinpin: "#available-VINPIN",
/** App name to KEEP — every OTHER running app is a stray to terminate. */
vinPowerAppName: /VinPower/i,
/** Bounded passes over the Running panel (rows shift as apps close). */
maxClosePasses: 6,
} as const;
/** Bottom Horizon/RDS taskbar clip (px), fed to OCR to read the open windows'
* button labels left→right. TUNE against the permanent seat @ 1600x900. */
export const VINPIN_TASKBAR_REGION = { x: 0, y: 866, width: 1600, height: 34 } as const;
/** Candidate taskbar button CENTERS along the bottom bar, left→right. The daemon
* binds each open window to a slot by the OCR'd label order. Extra slots give
* headroom for drift; a raise is OCR-verified and re-tried on the next slot.
* TUNE against the permanent seat @ 1600x900. */
export const VINPIN_TASKBAR_SLOTS = [
{ x: 220, y: 884 },
{ x: 360, y: 884 },
{ x: 500, y: 884 },
{ x: 640, y: 884 },
{ x: 780, y: 884 },
] as const;
/** Global "VIN veya katalog ara" router box on the VinPower grid (~1455,96). Only
* used to OPEN a brand's window the first time (it routes, it does NOT decode and
* the VIN does not carry into the catalog). TUNE. */
export const VINPIN_ROUTER_BOX = { x: 1455, y: 96 } as const;
/** Warm-session daemon tunables. */
export const VINPIN_WARM = {
/** Business-hours window (Europe/Istanbul) the seat is held warm. */
businessStartHour: 8,
businessEndHour: 21,
/** Idle keepalive cadence — a `mouse.move` every ~75s held the RDS session
* 12.6 min with zero disconnect in the live proof. */
keepaliveIntervalMs: 75_000,
/** Harmless in-window point the keepalive nudges the cursor to. TUNE. */
keepalivePoint: { x: 800, y: 500 },
/** How often the scheduler reconciles warm-vs-hours. */
schedulerIntervalMs: 60_000,
/** Retries when verifying/re-trying a taskbar raise across slots. */
raiseVerifyRetries: 3,
/** After a taskbar-raise click, wait for the window to come forward. */
afterRaiseMs: 1_200,
/** Backoff after a FAILED warmUp: reconcile() and decode()'s warm-on-demand both
* skip re-warming until the cooldown elapses, so a failing seat is not hammered
* every ~60s (which leaves a fresh dirty window each attempt). Starts at the base
* and doubles per consecutive failure up to the max; a successful warm resets it. */
warmBackoffBaseMs: 60_000,
warmBackoffMaxMs: 300_000,
} as const;
/**
* Given the OCR'd taskbar text and the window labels, return the windows in the
* left→right order they appear in the bar. Pure + injectable so the brand→slot
* binding is unit-testable without a live seat. Windows whose label isn't found
* are omitted (the daemon then falls back to open-order for the missing ones).
*/
export function orderTaskbarWindows(
taskbarText: string,
keys: readonly ("fiat" | "rpartstore" | "dialogys")[] = ["fiat", "rpartstore", "dialogys"],
): ("fiat" | "rpartstore" | "dialogys")[] {
const found: { key: "fiat" | "rpartstore" | "dialogys"; idx: number }[] = [];
for (const key of keys) {
const label = VINPIN_WINDOW_LABELS[key];
const m = label.exec(taskbarText);
if (m) found.push({ key, idx: m.index });
}
return found.sort((a, b) => a.idx - b.idx).map((f) => f.key);
}
/**
* Pick which warm catalog WINDOW a VIN should be decoded on. Mirrors
* `selectVinpinBrandFlow`: Renault/Dacia → the Rpartstore window (Dialogys is the
* in-flow fallback), everything else (incl. Fiat) → the ePER window. Pure/testable.
*/
export function selectVinpinWarmWindow(vin: string): "fiat" | "rpartstore" {
return selectVinpinBrandFlow(vin) === "renault" ? "rpartstore" : "fiat";
}
/** Current hour (023) in Europe/Istanbul. Injectable clock for tests. */
export function vinpinIstanbulHour(now: Date = new Date()): number {
const hourStr = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Istanbul",
hour: "numeric",
hour12: false,
}).format(now);
// Some runtimes emit "24" for midnight with hour12:false — normalise to 023.
return Number.parseInt(hourStr, 10) % 24;
}
/** Whether the seat should be held warm right now (08:0021:00 Europe/Istanbul). */
export function isVinpinBusinessHours(now: Date = new Date()): boolean {
const h = vinpinIstanbulHour(now);
return h >= VINPIN_WARM.businessStartHour && h < VINPIN_WARM.businessEndHour;
}
/** Wait budgets (ms) for each step. Verified live @ 1600x900. */
export const VINPIN_WAITS = {
afterGoto: 7_000,
afterLogin: 20_000,
/** After submitting the web login: wait for the post-login screen (Horizon
* launcher or VinPower brand grid) to render. The real grid render measured
* ~3246s on the permanent seat, so the old 20s always timed out (noise). */
afterLogin: 45_000,
/** After clicking the launcher's VINPIN app: wait for VinPower to connect and
* raise its login dialog (permanent seat only). */
afterVinpinLaunch: 14_000,
@@ -87,9 +405,91 @@ export const VINPIN_WAITS = {
afterLangDismiss: 2_500,
afterSearchOpen: 5_000,
afterVinSubmit: 7_000,
afterAlertDismiss: 700,
afterAlertDismiss: 250,
/** After toggling the Horizon sidebar open/closed (real DOM chrome reveal). */
afterSidebarToggle: 1_000,
/** Between per-app closes in the Horizon "Running" panel (window teardown settles). */
afterRunningAppClose: 2_000,
/** OCR poll cadence when waiting for a detectable end-state. Each poll is
* CAPPED at the corresponding fixed-wait budget above (kept as a fallback), so
* if OCR never catches the transition the total wait == the old fixed sleep and
* worst-case behaviour is unchanged. */
pollIntervalMs: 700,
// ─── Renault flow waits ───
/** After clicking the window-close (X) of an open catalog window. */
afterWindowClose: 3_500,
/** After clicking the Renault brand tile (submenu animates in). */
afterRenaultTile: 2_500,
/** After picking Rpartstore/Dialogys from the submenu (catalog window opens). */
afterRenaultCatalogOpen: 12_000,
/** After a Rpartstore (re)open — poll for the search-home landing markers to
* confirm it actually LOADED (vs the "born-stuck" infinite spinner). Short so
* a stuck instance is detected fast and reopened fresh within the budget. */
afterRpartstoreLoad: 12_000,
/** After submitting the Rpartstore VIN (Enter) — wait for the vehicle page. */
afterRpartstoreSubmit: 8_000,
/** After submitting the Dialogys VIN (ПОИСК) — wait for the vehicle page. */
afterDialogysSubmit: 8_000,
} as const;
/**
* Hard wall-clock budget (ms) for a SINGLE decode(vin) call, spanning all of its
* internal attempts. A single Vinpin seat is shared by every decode, so one VIN
* that gets stuck in a re-establish/relaunch livelock must never grind for
* minutes and starve real decodes. When the budget is exceeded the driver aborts,
* tears the browser down, poisons the seat (forcing the next decode to cold
* re-establish) and returns null. Kept well above the healthy p90 (a warm decode
* is seconds; a full cold re-establish is ~60-80s) so it only ever fires on a
* genuine stall. Bumped 150s→180s to leave a cheap safety margin for a slow cold
* establish stacking on the Rpartstore launch-error probe + the Dialogys fallback
* decode (~25s) — the Rpartstore launch-error short-circuit means we no longer
* waste ~54s of reopens, but the extra headroom covers a cold-establish edge.
* Override with VINPIN_DECODE_BUDGET_MS. */
export const VINPIN_DECODE_BUDGET_MS = 180_000;
/**
* Rpartstore acquire-with-spinner-guard tunables. A freshly-opened Rpartstore
* instance sometimes gets "born stuck" on an infinite spinner (survives
* raise/maximize); the only reliable fix is to CLOSE the stuck instance and
* reopen a FRESH one. `maxOpens` bounds the initial-open-plus-reopen attempts;
* `acquireBudgetMs` sub-caps the whole acquire loop so a hopeless Rpartstore
* still leaves wall-clock headroom (inside VINPIN_DECODE_BUDGET_MS) to fall back
* to Dialogys rather than burning the entire decode budget on reopens.
*/
export const VINPIN_RPARTSTORE = {
// Rpartstore has two failure modes: a genuine born-stuck spinner (reopen can
// recover it) and a hard server-side launch error "Ошибка запуска каталога"
// (reopen NEVER helps — Rpartstore is DOWN). Detecting the tiny centered error
// modal by OCR proved unreliable across renderings, and burning reopens on a
// DOWN Rpartstore (a) wastes ~54s and (b) dirties the RDS desktop so the
// Dialogys fallback then can't finish inside the budget.
// `maxOpens` still bounds reopen attempts (a real transient spinner can recover
// on a reopen), but `acquireBudgetMs` is the hard lever: one open+load-poll is
// ~22s, so a 14s wall-clock sub-cap makes the loop bail after the FIRST open if
// it hasn't loaded — straight to Dialogys on a still-clean seat (~2552s decode)
// — instead of grinding 3 reopens. A healthy Rpartstore loads within the first
// open's poll and is used as before; the cap only prevents wasted reopens.
maxOpens: 3,
acquireBudgetMs: 14_000,
/** In-memory cooldown after a Rpartstore HARD launch-error (or repeated
* load-failure across all reopens): Rpartstore-down is server-side and
* PERSISTENT, so reopening it on every Renault VIN just re-dirties the RDS
* desktop (each failed open leaves a resumed window / launch-error modal). While
* the cooldown is active, Renault decodes route STRAIGHT to the proven ~30s
* Dialogys path (Rpartstore is never opened). A successful Rpartstore load clears
* it immediately, so a transient blip self-heals on the next decode. */
launchErrorCooldownMs: 10 * 60_000,
} as const;
/** Per-key delay (ms) when typing a VIN into a focused canvas field. A focused
* field keeps up at ~20ms; the old 45-50ms was conservative padding (17-char VIN
* ≈ 340ms vs ≈ 850ms). */
export const VINPIN_TYPE_DELAY_MS = 20;
/** Backspaces used to clear a VIN field after Ctrl+A + Delete. A few for
* insurance on a focused field — the old 40 was overkill. */
export const VINPIN_FIELD_CLEAR_BACKSPACES = 5;
/**
* Known Fiat model tokens used to extract the model name from the decode modal
* text. The modal interleaves Cyrillic boilerplate, a platform code, the model
@@ -125,11 +525,92 @@ export const FIAT_MODEL_TOKENS = [
] as const;
/**
* Brands for which the Vinpin fallback is enabled. Fiat-only for now (the
* Egea/NM4356 no-catalog cluster); a Set so it's trivially extensible later.
* Compared case-insensitively against the canonical browse brand.
* Known Renault/Dacia model tokens used to extract the model name from the
* Rpartstore/Dialogys vehicle-page header. Same token-matching strategy as the
* Fiat parser (far more robust than positional parsing against OCR noise).
* Generation numbers / roman numerals in the header are ignored — PL24 catalog
* rows carry their own generation suffixes and matching is best-effort.
*/
export const VINPIN_BRAND_ALLOWLIST = new Set<string>(["fiat"]);
export const RENAULT_MODEL_TOKENS = [
"CLIO",
"LUTECIA", // Clio's JP/TR export name — appears in PL24 rows ("CLIO 4 / LUTECIA 4")
"MEGANE",
"SCENIC",
"KANGOO",
"LAGUNA",
"LATITUDE",
"SAFRANE",
"FLUENCE",
"SYMBOL",
"THALIA",
"KADJAR",
"CAPTUR",
"DUSTER",
"SANDERO",
"LOGAN",
"TALISMAN",
"ESPACE",
"TWINGO",
"MODUS",
"KOLEOS",
"TRAFIC",
"MASTER",
"TALIANT",
"ARKANA",
"AUSTRAL",
"VELSATIS",
"TWIZY",
"ZOE",
"EXPRESS",
// Dacia
"DOKKER",
"LODGY",
"SPRING",
// Old R-number platforms + TR-market badges (pre-2000). These genuinely need
// Rpartstore to VIN-resolve, so this mostly future-proofs the header parse for
// an old Renault (e.g. VF1553… R19). The parser tokenises on non-alphanumerics,
// so a BARE "19" would collide with year/engine digits — the R-prefixed form
// ("R19") is required so it only matches the actual model badge, never a number.
"R5",
"R9",
"R11",
"R12",
"R19",
"R21",
"R25",
"EUROPA", // R19 Europa (TR)
"BROADWAY", // R9 Broadway (TR)
"TOROS", // R12 Toros (TR)
"FLASH", // R11 Flash (TR)
] as const;
/** WMIs that route to the Renault (Rpartstore/Dialogys) flow. VF1/VF2 are the
* common Renault WMIs; VF6/VF7 are included per the benchmark (some Renault/
* Dacia LCVs and older platforms). Dacia-badged cars often carry a Renault WMI
* (e.g. VF1 Duster) — the RENAULT-vs-DACIA distinction is read from the decode
* header, not the WMI. UU1 is the dedicated Dacia WMI. */
const RENAULT_FLOW_WMIS = new Set<string>(["VF1", "VF2", "VF6", "VF7", "UU1"]);
/**
* Pick the Vinpin catalog flow for a VIN by brand. Renault/Dacia → the new
* Rpartstore/Dialogys flow; everything else (including Fiat) → the existing ePER
* flow, so Fiat behaviour is byte-identical. Derived from the WMI: trusts
* `getBrandFromWmi` first (Renault/Dacia), then a Renault-WMI allowlist.
*/
export function selectVinpinBrandFlow(vin: string): "renault" | "fiat" {
const wmi = (vin || "").toUpperCase().slice(0, 3);
const brand = getBrandFromWmi(wmi);
if (brand === "Renault" || brand === "Dacia") return "renault";
if (RENAULT_FLOW_WMIS.has(wmi)) return "renault";
return "fiat";
}
/**
* Brands for which the Vinpin fallback is enabled. Fiat (Egea/NM4356 no-catalog
* cluster) + Renault/Dacia (Rpartstore/Dialogys flow). A Set so it's trivially
* extensible. Compared case-insensitively against the canonical browse brand.
*/
export const VINPIN_BRAND_ALLOWLIST = new Set<string>(["fiat", "renault", "dacia"]);
export function isVinpinBrandAllowed(brand: string | null | undefined): boolean {
if (!brand) return false;

View File

@@ -12,6 +12,18 @@ describe("modelTokens", () => {
expect(modelTokens("Tipo-Egea MCA (2020-....)")).toEqual(["TIPO", "EGEA", "MCA"]);
expect(modelTokens("DOBLO REST. 2005 (2005-2016)")).toEqual(["DOBLO", "REST"]);
});
it("keeps engine displacements as tokens (only 1950-2039 count as years)", () => {
// The 4-digit engine sizes must survive — dropping them made the ancient
// "TIPO 1100-1370-1580" collapse to just ["TIPO"] and win the tiebreak.
expect(modelTokens("TIPO 1100-1370-1580 (1987-1993)")).toEqual([
"TIPO",
"1100",
"1370",
"1580",
]);
expect(modelTokens("TIPO 1750-2000 (1990-1993)")).toEqual(["TIPO", "1750"]); // 2000 = year-range
});
});
describe("parseYearRange", () => {
@@ -52,4 +64,124 @@ describe("pickBestCatalogMatch", () => {
it("returns null for an empty model", () => {
expect(pickBestCatalogMatch({ model: null, modelYear: "2018" }, candidates)).toBeNull();
});
it("never falls back onto an ancient catalog for a bare, year-less model", () => {
const withAncient: CatalogCandidate[] = [
...candidates,
{ id: "tipo-1987", model: "TIPO 1100-1370-1580 (1987-1993)", year: "1987-1993" },
];
// Bare "TIPO", no year (the exact shape of the NM4131 false positive that
// used to land on the 1987 Tipo). Must resolve to a modern Egea instead.
const id = pickBestCatalogMatch({ model: "TIPO", modelYear: null }, withAncient);
expect(id).not.toBe("tipo-1987");
expect(["egea", "egea-mca"]).toContain(id);
});
it("breaks a score tie toward the newer catalog", () => {
const puntos: CatalogCandidate[] = [
{ id: "punto-old", model: "PUNTO (1993-1999)", year: "1993-1999" },
{ id: "punto-new", model: "PUNTO (2012-2018)", year: "2012-2018" },
];
// Identical tokens + no year → equal score → recency decides.
expect(pickBestCatalogMatch({ model: "PUNTO", modelYear: null }, puntos)).toBe("punto-new");
});
it("breaks a score tie toward the richer catalog (more categories)", () => {
const koleos: CatalogCandidate[] = [
{ id: "koleos-thin", model: "KOLEOS", year: null, categoryCount: 12 },
{ id: "koleos-full", model: "KOLEOS", year: null, categoryCount: 44 },
];
// Identical tokens + no year → equal score → category count decides.
expect(pickBestCatalogMatch({ model: "KOLEOS", modelYear: null }, koleos)).toBe("koleos-full");
});
});
describe("pickBestCatalogMatch — market-qualifier precision (Renault/Dacia)", () => {
it("prefers the EXACT model over a wrong-market superset (KADJAR beats KADJAR ÇİN)", () => {
// The confirmed prod bug: a European VF1 Renault decoded as "KADJAR" landed
// on the China-market "KADJAR ÇİN" catalog (fewer categories, wrong parts).
const candidates: CatalogCandidate[] = [
{ id: "kadjar-cn", model: "KADJAR ÇİN", year: null, categoryCount: 22 },
{ id: "kadjar-eu", model: "KADJAR", year: null, categoryCount: 44 },
];
expect(pickBestCatalogMatch({ model: "KADJAR", modelYear: "2018" }, candidates)).toBe(
"kadjar-eu",
);
});
it("still picks the exact model even when the market candidate is richer", () => {
// Market penalty must dominate — an exact match wins regardless of category
// count or ordering.
const candidates: CatalogCandidate[] = [
{ id: "kadjar-cn", model: "KADJAR ÇİN", year: null, categoryCount: 999 },
{ id: "kadjar-eu", model: "KADJAR", year: null, categoryCount: 1 },
];
expect(pickBestCatalogMatch({ model: "KADJAR", modelYear: null }, candidates)).toBe(
"kadjar-eu",
);
});
it("penalizes a market qualifier but NOT a generation token (CLIO)", () => {
const candidates: CatalogCandidate[] = [
{ id: "clio-cn", model: "CLIO ÇİN", year: null },
{ id: "clio", model: "CLIO", year: null },
];
// Exact "CLIO" beats the China-market superset.
expect(pickBestCatalogMatch({ model: "CLIO", modelYear: null }, candidates)).toBe("clio");
});
it("keeps generations matchable — a generation extra token is not a market penalty", () => {
// Decoded "CLIO" with only generation-qualified catalogs (no exact bare CLIO)
// and one China catalog. The generation candidate must win over the market one,
// proving the market penalty doesn't collateral-damage generations.
const candidates: CatalogCandidate[] = [
{ id: "clio-cn", model: "CLIO ÇİN", year: null },
{
id: "clio-4",
model: "CLIO 4 / LUTECIA 4 (2012-2019)",
year: "2012-2019",
categoryCount: 40,
},
{
id: "clio-3",
model: "CLIO 3 / LUTECIA 3 (2005-2014)",
year: "2005-2014",
categoryCount: 30,
},
];
const id = pickBestCatalogMatch({ model: "CLIO", modelYear: "2015" }, candidates);
// Year-overlap picks the 2012-2019 gen-4; the China catalog is penalized out.
expect(id).toBe("clio-4");
});
it("matches an exact generation model and penalizes only the market superset (DUSTER II)", () => {
// Roman-numeral generations survive tokenization (single digits are dropped by
// the length>=2 filter, an existing behavior). Decoded "DUSTER II" is exact on
// duster-2; "DUSTER II ÇİN" carries an extra market token and is penalized out.
const candidates: CatalogCandidate[] = [
{ id: "duster-2", model: "DUSTER II", year: null, categoryCount: 30 },
{ id: "duster-cn", model: "DUSTER II ÇİN", year: null, categoryCount: 30 },
];
expect(pickBestCatalogMatch({ model: "DUSTER II", modelYear: null }, candidates)).toBe(
"duster-2",
);
});
it("falls back to a market catalog only when it is the sole candidate", () => {
// If the ONLY catalog for a decoded model is a regional one, still match it —
// the penalty lowers the score but does not disqualify.
const candidates: CatalogCandidate[] = [{ id: "x62-cn", model: "X62 CHINE", year: null }];
expect(pickBestCatalogMatch({ model: "X62", modelYear: null }, candidates)).toBe("x62-cn");
});
});
describe("modelTokens — diacritic folding", () => {
it("folds Turkish diacritics so market qualifiers survive as ASCII tokens", () => {
// Without folding, ÇİN loses Ç/İ to the ASCII strip and collapses to a
// dropped 1-char "N", making KADJAR ÇİN tokenize identically to KADJAR.
expect(modelTokens("KADJAR ÇİN")).toEqual(["KADJAR", "CIN"]);
expect(modelTokens("ARKANA RUSYA")).toEqual(["ARKANA", "RUSYA"]);
// The single-digit "2" is dropped by the length>=2 filter (existing behavior).
expect(modelTokens("KOLEOS 2 - ÇİN")).toEqual(["KOLEOS", "CIN"]);
});
});

View File

@@ -18,26 +18,96 @@ export interface CatalogCandidate {
id: string;
model: string | null;
year: string | null;
/** How many catalog categories this vehicle has fetched. Used only as a final
* tiebreak among otherwise-equal candidates — the fuller catalog is the
* mainstream one. Optional so the pure picker can be unit-tested without it. */
categoryCount?: number | null;
}
/**
* Market/region qualifier tokens (diacritic-folded, upper-case). A catalog model
* that carries one of these tokens BEYOND the decoded model targets a specific
* regional market — e.g. "KADJAR ÇİN" is the China-market Kadjar catalog (source
* XZH, 22 categories) vs the mainstream European "KADJAR" (XFE, 44 categories).
* For a European (VF1…) VIN the regional catalog serves the wrong parts, so a
* candidate whose EXTRA tokens are region qualifiers is heavily penalized — but
* only when the token is extra (the decode itself doesn't carry a market today).
*
* Generation tokens (roman numerals, digits, platform codes like "II"/"3"/"XM3")
* are deliberately NOT here, so multi-generation models stay fully matchable;
* only MARKET qualifiers get the penalty.
*
* Grounded in prod data — the real Renault/Dacia catalog models that carry a
* region qualifier are: KADJAR ÇİN, CAPTUR II ÇİN, KOLEOS 2 - ÇİN, ARKANA RUSYA
* and X62 CHINE. ("EUROPE" appears too — ARKANA EUROPE, CAPTUR II EUROPE — but
* that is the mainstream market and is never penalized.)
*/
export const MARKET_QUALIFIERS = new Set<string>([
// China — "ÇİN" folds to CIN via diacritic normalization; CHINE is the French
// spelling seen in "X62 CHINE".
"CIN",
"CHINA",
"CHINE",
"CN",
// Russia
"RUSYA",
"RUSSIA",
"RU",
// Brazil
"BREZILYA",
"BRAZIL",
"BR",
// India
"HINDISTAN",
"INDIA",
"IN",
// Korea
"KORE",
"KOREA",
// Mexico / Mercosur
"MEKSIKA",
"MEXICO",
"MERCOSUR",
// Other regional
"GCC",
"USA",
"AMERIKA",
]);
export interface DecodedForMatch {
model: string | null;
modelYear: string | null;
/** Brand to match against (catalog_vehicles.brand_name). Defaults to Fiat when
* omitted, preserving the original Fiat-only behaviour. */
brand?: string | null;
}
/** Tokenize a model string into upper-case model tokens, dropping parenthetical
* groups (year ranges) and bare 4-digit years. "TIPO - EGEA (2015-2021)" →
* ["TIPO","EGEA"]. Keeps "500" / "500X" (not 4-digit years). */
* groups (year ranges) and bare 4-digit *years*. "TIPO - EGEA (2015-2021)" →
* ["TIPO","EGEA"]. Keeps "500"/"500X" AND engine displacements like 1100 / 1370
* / 1580 / 2400 — only 1950-2039 (plausible car years) are treated as years.
* Dropping displacements made ancient catalogs ("TIPO 1100-1370-1580") collapse
* to just ["TIPO"], looking falsely specific and winning the fewest-extra-tokens
* tiebreak over the modern Egea. */
export function modelTokens(s: string | null | undefined): string[] {
if (!s) return [];
return s
.toUpperCase()
.replace(/\([^)]*\)/g, " ") // drop parenthetical year ranges
.replace(/\b\d{4}\b/g, " ") // drop bare years (keeps 3-digit "500")
.replace(/[^A-Z0-9]+/g, " ")
.split(" ")
.map((t) => t.trim())
.filter((t) => t.length >= 2);
return (
s
.toUpperCase()
// Fold diacritics to ASCII so market qualifiers survive tokenization. Without
// this the Turkish "ÇİN" (China) loses its Ç/İ to the [^A-Z0-9] strip below
// and collapses to a dropped 1-char "N" — making "KADJAR ÇİN" tokenize
// IDENTICALLY to "KADJAR" and defeating both the specificity tiebreak and the
// market penalty. NFD + combining-mark removal maps ÇİN→CIN, Ş→S, Ğ→G, Ö→O …
.normalize("NFD")
.replace(/\p{M}/gu, "") // strip the combining marks NFD split off
.replace(/\([^)]*\)/g, " ") // drop parenthetical year ranges
.replace(/\b(19[5-9]\d|20[0-3]\d)\b/g, " ") // drop bare YEARS 1950-2039; keep displacements
.replace(/[^A-Z0-9]+/g, " ")
.split(" ")
.map((t) => t.trim())
.filter((t) => t.length >= 2)
);
}
/** Parse a year range from free text. Returns {start, end} where end===null
@@ -70,8 +140,18 @@ function yearInRange(year: number, range: { start: number; end: number | null })
/**
* Pick the best catalog_vehicles row for a decoded model + year. A candidate
* qualifies only when EVERY decoded model token appears in the candidate's
* model tokens (subset match). Among qualifiers, prefer year-range overlap,
* then the most specific (fewest extra tokens). Returns the id or null.
* model tokens (subset match). Scoring, strongest signal first:
* 1. Year-range overlap — the strongest signal; routes multi-generation models
* (a 2022 "TIPO-EGEA" → the 2020+ MCA catalog, not the 2015-2021 one).
* 2. EXACT normalized model match (candidate tokens ≡ decoded tokens) — a
* smaller bonus that breaks the tie in favour of a plain "KADJAR" over the
* superset "KADJAR ÇİN" when no year distinguishes them.
* 3. Market-qualifier penalty — extra tokens that are region qualifiers (ÇİN,
* RUSYA, …) make this a WRONG-market catalog; penalized hard so it only ever
* wins as the sole candidate. Generation / other extra tokens keep only the
* mild "fewer extras = more specific" penalty, so generations stay matchable.
* 4. Tiebreaks on equal score: richer catalog (more categories), then newer.
* Returns the id or null.
*/
export function pickBestCatalogMatch(
decoded: DecodedForMatch,
@@ -81,7 +161,12 @@ export function pickBestCatalogMatch(
if (decTokens.length === 0) return null;
const decYear = decoded.modelYear ? Number.parseInt(decoded.modelYear, 10) : null;
let best: { id: string; score: number } | null = null;
let best: {
id: string;
score: number;
categoryCount: number;
startYear: number;
} | null = null;
for (const cand of candidates) {
const candText = `${cand.model ?? ""} ${cand.year ?? ""}`;
@@ -91,17 +176,47 @@ export function pickBestCatalogMatch(
// Subset requirement — all decoded model tokens must be present.
if (!decTokens.every((t) => candSet.has(t))) continue;
const extraTokens = candTokens.filter((t) => !decTokens.includes(t));
let score = 1000; // base for any qualifying candidate
// (1) EXACT normalized model-name match: candidate carries NO tokens beyond
// the decoded model. This breaks the tie in favour of the plain model over a
// superset (e.g. "KADJAR" over "KADJAR ÇİN" when neither has a distinguishing
// year). Kept SMALLER than the year-range swing (±700) on purpose, so a
// decisive model-year still routes generations correctly — a 2022 "TIPO-EGEA"
// must still land on the 2020+ MCA catalog, not the exact-named 2015-2021 one.
if (extraTokens.length === 0) score += 300;
// (2) Year-range overlap.
const range = parseYearRange(candText);
if (decYear && Number.isFinite(decYear) && range) {
score += yearInRange(decYear, range) ? 500 : -200;
}
// Tiebreak: fewer extra tokens = more specific match.
const extra = candTokens.filter((t) => !decTokens.includes(t)).length;
score -= extra;
if (!best || score > best.score) {
best = { id: cand.id, score };
// (3) Extra-token penalties. A region-qualifier extra token means the
// candidate is a wrong-market catalog for a decode that carries no market —
// penalize hard so it can only win when it is the ONLY candidate. Everything
// else (generation numerals, platform codes) keeps the mild specificity
// penalty, leaving multi-generation models fully matchable.
const marketExtras = extraTokens.filter((t) => MARKET_QUALIFIERS.has(t)).length;
const otherExtras = extraTokens.length - marketExtras;
score -= otherExtras;
score -= marketExtras * 5000;
// (4) Tiebreaks on equal score: prefer the richer catalog (more categories —
// the fuller catalog is the mainstream one), then the newer generation. A
// bare, year-less model (e.g. "TIPO") must never fall back onto an ancient
// generation — modern is the overwhelmingly likelier intent.
const categoryCount = cand.categoryCount ?? 0;
const startYear = range?.start ?? 0;
const better =
!best ||
score > best.score ||
(score === best.score && categoryCount > best.categoryCount) ||
(score === best.score && categoryCount === best.categoryCount && startYear > best.startYear);
if (better) {
best = { id: cand.id, score, categoryCount, startYear };
}
}
@@ -115,25 +230,35 @@ export class VinpinCatalogMatcher {
constructor(@Inject(DATABASE) private readonly db: Database) {}
/**
* Find the best PL24 Fiat catalog_vehicle for a decoded model + year.
* Returns the catalog_vehicle id, or null when nothing matches.
* Find the best PL24 catalog_vehicle (of the decoded brand — Fiat by default,
* or Renault/Dacia) for a decoded model + year. Returns the id, or null.
*/
async match(decoded: DecodedForMatch): Promise<string | null> {
if (!decoded.model) return null;
const brand = (decoded.brand ?? "Fiat").toLowerCase();
const rows = await this.db
.select({
id: catalogVehicles.id,
model: catalogVehicles.model,
year: catalogVehicles.year,
// Category count feeds the richer-catalog tiebreak (more categories = the
// mainstream catalog). Correlated subquery keeps the row shape flat.
categoryCount: sql<number>`(
SELECT count(*)::int FROM categories
WHERE categories.catalog_vehicle_id = ${catalogVehicles.id}
)`,
})
.from(catalogVehicles)
.where(
and(sql`lower(${catalogVehicles.brandName}) = 'fiat'`, eq(catalogVehicles.source, "pl24")),
and(
sql`lower(${catalogVehicles.brandName}) = ${brand}`,
eq(catalogVehicles.source, "pl24"),
),
);
const id = pickBestCatalogMatch(decoded, rows as CatalogCandidate[]);
this.logger.log(
`match model="${decoded.model}" year=${decoded.modelYear ?? "?"}${id ?? "null"} (over ${rows.length} Fiat catalog vehicles)`,
`match brand=${brand} model="${decoded.model}" year=${decoded.modelYear ?? "?"}${id ?? "null"} (over ${rows.length} ${brand} catalog vehicles)`,
);
return id;
}

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { pollForText } from "./vinpin.ocr";
/** Deterministic fake clock: `sleep` advances virtual time; `now` reads it. */
function makeClock() {
let t = 0;
const slept: number[] = [];
return {
now: () => t,
sleep: async (ms: number) => {
slept.push(ms);
t += ms;
},
slept,
total: () => slept.reduce((a, b) => a + b, 0),
};
}
describe("pollForText", () => {
it("returns early as soon as the predicate matches (fast path)", async () => {
const clock = makeClock();
const reads = ["no", "no", "hit"];
let i = 0;
const res = await pollForText({
read: async () => reads[i++] ?? "",
predicate: (t) => t === "hit",
capMs: 10_000,
intervalMs: 700,
sleep: clock.sleep,
now: clock.now,
});
expect(res.matched).toBe(true);
expect(res.text).toBe("hit");
// Stopped at the 3rd read — well under the cap.
expect(i).toBe(3);
expect(clock.total()).toBe(2100);
expect(clock.total()).toBeLessThan(10_000);
});
it("caps total sleep at capMs when the predicate never matches (fallback == old fixed wait)", async () => {
const clock = makeClock();
let reads = 0;
const res = await pollForText({
read: async () => {
reads++;
return "no";
},
predicate: (t) => t === "hit",
capMs: 2_000,
intervalMs: 700,
sleep: clock.sleep,
now: clock.now,
});
expect(res.matched).toBe(false);
expect(res.text).toBe("no");
// Total sleep is exactly the cap — never longer than the old fixed wait.
expect(clock.total()).toBe(2_000);
// The final interval was clamped to the remaining budget (no overshoot).
expect(Math.max(...clock.slept)).toBeLessThanOrEqual(700);
expect(reads).toBeGreaterThan(0);
});
it("matches on the very first read without over-sleeping", async () => {
const clock = makeClock();
const res = await pollForText({
read: async () => "ready",
predicate: (t) => t.includes("ready"),
capMs: 12_000,
intervalMs: 750,
sleep: clock.sleep,
now: clock.now,
});
expect(res.matched).toBe(true);
expect(clock.total()).toBe(750); // one interval, then matched
});
});

View File

@@ -133,6 +133,43 @@ export async function ocrRegion(page: Page, clip?: OcrClip, scale = 3): Promise<
}
}
/**
* Poll a text `read` until `predicate(text)` holds, or the `capMs` budget is
* exhausted. Returns as soon as the predicate matches (the common, fast case);
* otherwise the total elapsed sleep equals `capMs` — the SAME fallback wait the
* old fixed `waitForTimeout(capMs)` used, so worst-case behaviour is unchanged.
*
* Pure/injectable (no Playwright/OCR deps) so it can be unit-tested directly:
* pass a fake `read`, `sleep` and `now`. The driver wraps it with an OCR read.
*/
export interface PollForTextOptions {
read: () => Promise<string>;
predicate: (text: string) => boolean;
/** Fallback cap — total sleep never exceeds this (== the old fixed wait). */
capMs: number;
/** Cadence between reads. */
intervalMs: number;
sleep: (ms: number) => Promise<void>;
/** Injectable clock (defaults to Date.now) — for deterministic tests. */
now?: () => number;
}
export async function pollForText(
opts: PollForTextOptions,
): Promise<{ matched: boolean; text: string }> {
const now = opts.now ?? (() => Date.now());
const interval = Math.max(1, opts.intervalMs);
const deadline = now() + opts.capMs;
let text = "";
while (now() < deadline) {
const remaining = deadline - now();
await opts.sleep(Math.min(interval, remaining));
text = await opts.read();
if (opts.predicate(text)) return { matched: true, text };
}
return { matched: false, text };
}
/** Tear down the shared tesseract worker (best-effort). */
export async function terminateOcr(): Promise<void> {
if (!workerPromise) return;

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { isUsableParse, parseVinpinModal } from "./vinpin.parser";
import {
isUsableParse,
isUsableRenaultParse,
parseRenaultHeader,
parseVinpinModal,
} from "./vinpin.parser";
const SAMPLE =
"MVS, найденные по vin NM435600006H43436 6J - TIPO - EGEA (2015-2021) 356G37001162 - 3V HIGH PLUS 1.6 120CV D5 CM E6 GS TR Двигатель: 8165300 Автомобиль: 279476 Prod. date: 2/8/2017 Vin: NM435600006H43436";
@@ -35,4 +40,71 @@ describe("parseVinpinModal", () => {
expect(isUsableParse(p)).toBe(false);
expect(isUsableParse(parseVinpinModal(null))).toBe(false);
});
it("rejects a stale-breadcrumb model with no SINCOM (vehicle-not-found false positive)", () => {
// Real garbled OCR of a failed NM4131 decode: the "не найден" alert is
// mangled ("He HaaeHs") so it slips the not-found check, and the screen
// still shows the operator's last catalog ("FIAT » TIPO - EGEA"). A model
// token is present but there is NO SINCOM → must be treated as unusable so
// it is not false-mapped onto the ancient "TIPO 1987-1993" catalog.
const p = parseVinpinModal(
"Fiat Dealer Spare Parts FIAT TIPO - EGBA 500 HYBRID TIPO-EGEA MCA (2020) DOBLO FREEMONT LINEA IDEA To yKasaHHeIM N3paMETpaM 3BTOMOBHMN He HaaeHs",
);
expect(p.model).not.toBeNull(); // breadcrumb model still extracted…
expect(p.sincom).toBeNull(); // …but no SINCOM
expect(isUsableParse(p)).toBe(false); // → not a real decode
});
});
describe("parseRenaultHeader", () => {
it("parses an Rpartstore RENAULT header (model + brand)", () => {
const p = parseRenaultHeader("RENAULT KADJAR (RFE) Şasi: VF1RFE00653633190");
expect(p.model).toBe("KADJAR");
expect(p.brand).toBe("RENAULT");
expect(isUsableRenaultParse(p)).toBe(true);
});
it("reads DACIA as the brand when the header carries it", () => {
const p = parseRenaultHeader("DACIA DUSTER (HSA) Şasi: VF1HJD40865644941");
expect(p.model).toBe("DUSTER");
expect(p.brand).toBe("DACIA");
});
it("extracts the base model and ignores the generation numeral (CLIO IV)", () => {
const p = parseRenaultHeader("RENAULT CLIO IV (R0G) Şasi: VF15R0G0H49335605");
expect(p.model).toBe("CLIO");
expect(p.brand).toBe("RENAULT");
});
it("handles a Dialogys header with no explicit brand (defaults RENAULT)", () => {
const p = parseRenaultHeader("Clio II (X65), 1.5 dCi");
expect(p.model).toBe("CLIO");
expect(p.brand).toBe("RENAULT");
});
it("captures a model year when the header carries one", () => {
const p = parseRenaultHeader("RENAULT LATITUDE (2011-2015)");
expect(p.model).toBe("LATITUDE");
expect(p.modelYear).toBe("2011");
});
it("does NOT mis-grab the status-bar license-expiry date as the model year", () => {
// The full-frame OCR fallback sweeps in the Rpartstore/Dialogys status bar,
// whose license EXPIRY date ("…14.07.2026") used to be read as the model year.
const p = parseRenaultHeader("RENAULT KADJAR (RFE) Şasi: VF1RFE00653633190 Lisans: 14.07.2026");
expect(p.model).toBe("KADJAR");
expect(p.modelYear).toBeNull(); // NOT "2026" from the expiry date
});
it("still prefers a genuine generation range even when an expiry date is present", () => {
const p = parseRenaultHeader("RENAULT LATITUDE (2011-2015) Lisans: 14.07.2026");
expect(p.modelYear).toBe("2011"); // the (2011-2015) range wins over the date
});
it("returns no model / unusable for headers without a known token", () => {
const p = parseRenaultHeader("İlişikli araç bulunamadı");
expect(p.model).toBeNull();
expect(isUsableRenaultParse(p)).toBe(false);
expect(isUsableRenaultParse(parseRenaultHeader(""))).toBe(false);
});
});

View File

@@ -13,7 +13,7 @@
* Kept dependency-free and side-effect-free so it can be unit-tested directly.
*/
import { FIAT_MODEL_TOKENS } from "./vinpin.constants";
import { FIAT_MODEL_TOKENS, RENAULT_MODEL_TOKENS } from "./vinpin.constants";
export interface VinpinParsed {
model: string | null;
@@ -91,7 +91,83 @@ export function parseVinpinModal(rawText: string | null | undefined): VinpinPars
return { model, sincom, trim, modelYear, engine };
}
/** A parse is usable if we got at least a model or a SINCOM code. */
/**
* A parse is usable only when we got a SINCOM code — the trim identifier that a
* genuine ePER decode always emits ("356G37001162"). A model *alone* is a stale
* breadcrumb: when the VIN search returns "vehicle not found" (a TR NM4 VIN ePER
* can't resolve), the screen still shows whatever catalog the operator was last
* browsing (e.g. "FIAT » TIPO - EGEA"), and the OCR of the Cyrillic "не найден"
* alert garbles enough to slip past the not-found check. Grabbing that
* breadcrumb model ("TIPO"/"PANDA") and matching it to a catalog is a false
* positive that lands users on the wrong (often ancient) catalog. Requiring the
* SINCOM cleanly separates the two: every genuine decode carries one; the
* breadcrumb false positives never do.
*/
export function isUsableParse(p: VinpinParsed): boolean {
return Boolean(p.model || p.sincom);
return Boolean(p.sincom);
}
// ─── Renault (Rpartstore / Dialogys) header parser ───────────────
export interface VinpinRenaultParsed {
/** Extracted model, e.g. "KADJAR", "CLIO", "DUSTER". */
model: string | null;
/** "RENAULT" | "DACIA" — read from the header, else defaulted to "RENAULT". */
brand: "RENAULT" | "DACIA";
/** Model year if the header carried one (best-effort). */
modelYear: string | null;
}
const RENAULT_TOKEN_SET = new Set<string>(RENAULT_MODEL_TOKENS as readonly string[]);
/**
* Extract the Renault/Dacia model from an OCR'd vehicle-page header.
*
* Rpartstore header (primary): "RENAULT KADJAR (RFE)" / "DACIA DUSTER (...)" +
* "Şasi: VF1RFE00653633190".
* Dialogys header (fallback): "Clio II (X65), 1.5 dCi" (no explicit brand).
*
* Strategy (mirrors the Fiat token-run parser): collect the contiguous run of
* KNOWN Renault/Dacia model tokens (so "GRAND KOLEOS" → "KOLEOS", "CLIO II" →
* "CLIO"), and read the brand from a leading RENAULT/DACIA token (Dialogys omits
* it → default RENAULT). Generation numbers / roman numerals are intentionally
* dropped — PL24 rows carry their own generation suffixes and matching is
* best-effort.
*/
export function parseRenaultHeader(rawText: string | null | undefined): VinpinRenaultParsed {
const text = (rawText ?? "").replace(/\s+/g, " ").trim();
const upper = text.toUpperCase();
const brand: "RENAULT" | "DACIA" = /\bDACIA\b/.test(upper) ? "DACIA" : "RENAULT";
const tokens = upper.split(/[^A-Z0-9]+/).filter(Boolean);
const collected: string[] = [];
for (const tok of tokens) {
if (RENAULT_TOKEN_SET.has(tok)) {
collected.push(tok);
} else if (collected.length > 0) {
// Stop at the first non-model token after the model run has started.
break;
}
}
// De-dup consecutive repeats (e.g. "CLIO CLIO" OCR doubling) preserving order.
const deduped = collected.filter((t, i) => i === 0 || t !== collected[i - 1]);
const model = deduped.length > 0 ? deduped.join("-") : null;
// Model year: prefer an explicit generation range "(2015-2021)" from the header.
// A BARE year is only taken as a fallback AND only after stripping full calendar
// dates — the Rpartstore/Dialogys STATUS BAR carries the license EXPIRY date
// ("…14.07.2026") which the full-frame OCR fallback would otherwise mis-grab as
// the model year (the "2026" bug). Stripping dd.mm.yyyy / dd/mm/yyyy / dd-mm-yyyy
// first constrains the bare-year scan to real header text.
const withoutDates = text.replace(/\b\d{1,2}[./-]\d{1,2}[./-]\d{4}\b/g, " ");
const modelYear =
text.match(YEAR_RANGE_RE)?.[1] ?? withoutDates.match(/\b(19|20)\d{2}\b/)?.[0] ?? null;
return { model, brand, modelYear };
}
/** A Renault parse is usable once we have a model token. */
export function isUsableRenaultParse(p: VinpinRenaultParsed): boolean {
return Boolean(p.model);
}

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import {
isVinpinBrandAllowed,
isVinpinBusinessHours,
orderTaskbarWindows,
selectVinpinBrandFlow,
selectVinpinWarmWindow,
} from "./vinpin.constants";
describe("selectVinpinBrandFlow", () => {
it("routes Renault WMIs (VF1/VF2) to the renault flow", () => {
expect(selectVinpinBrandFlow("VF1RFE00653633190")).toBe("renault"); // Kadjar
expect(selectVinpinBrandFlow("VF15R0G0H49335605")).toBe("renault"); // Clio IV
expect(selectVinpinBrandFlow("VF1LB030523661167")).toBe("renault"); // Clio II
expect(selectVinpinBrandFlow("VF2ABCDEFGH123456")).toBe("renault");
});
it("routes a Dacia-badged VF1 VIN to the renault flow (brand read from header)", () => {
expect(selectVinpinBrandFlow("VF1HJD40865644941")).toBe("renault"); // Dacia Duster
});
it("routes the dedicated Dacia WMI (UU1) to the renault flow", () => {
expect(selectVinpinBrandFlow("UU1ABCDEFGH123456")).toBe("renault");
});
it("keeps Fiat VINs on the fiat flow (byte-identical behaviour)", () => {
expect(selectVinpinBrandFlow("NM435600006H43436")).toBe("fiat"); // Tofaş Egea
expect(selectVinpinBrandFlow("ZFA31200000000000")).toBe("fiat");
});
it("defaults unknown/other WMIs to the fiat flow", () => {
expect(selectVinpinBrandFlow("WVWZZZ1JZ3W597935")).toBe("fiat"); // VW
expect(selectVinpinBrandFlow("")).toBe("fiat");
});
});
describe("isVinpinBrandAllowed", () => {
it("allows Fiat, Renault and Dacia (case-insensitive)", () => {
expect(isVinpinBrandAllowed("Fiat")).toBe(true);
expect(isVinpinBrandAllowed("renault")).toBe(true);
expect(isVinpinBrandAllowed("DACIA")).toBe(true);
expect(isVinpinBrandAllowed(" Renault ")).toBe(true);
});
it("rejects other brands and empty input", () => {
expect(isVinpinBrandAllowed("Peugeot")).toBe(false);
expect(isVinpinBrandAllowed(null)).toBe(false);
expect(isVinpinBrandAllowed(undefined)).toBe(false);
});
});
describe("selectVinpinWarmWindow — brand → taskbar window routing", () => {
it("routes Renault/Dacia VINs to the Rpartstore window (Dialogys is the in-flow fallback)", () => {
expect(selectVinpinWarmWindow("VF1RFE00653633190")).toBe("rpartstore"); // Renault Kadjar
expect(selectVinpinWarmWindow("UU1ABCDEFGH123456")).toBe("rpartstore"); // Dacia
});
it("routes Fiat (and everything else) to the ePER window", () => {
expect(selectVinpinWarmWindow("NM435600006H43436")).toBe("fiat"); // Tofaş Egea
expect(selectVinpinWarmWindow("WVWZZZ1JZ3W597935")).toBe("fiat"); // VW → fiat default
});
});
describe("orderTaskbarWindows — OCR-order taskbar binding", () => {
it("returns the windows in their left→right OCR order", () => {
const bar = "VinPower Fiat Dealer ePER Renault RPartStore Renault Dialogys";
expect(orderTaskbarWindows(bar)).toEqual(["fiat", "rpartstore", "dialogys"]);
});
it("respects a different open order (order is read, not hardcoded)", () => {
const bar = "Dialogys | RPartStore | ePER";
expect(orderTaskbarWindows(bar)).toEqual(["dialogys", "rpartstore", "fiat"]);
});
it("omits windows whose label isn't on the bar", () => {
const bar = "some noise ePER more noise RPartStore";
expect(orderTaskbarWindows(bar)).toEqual(["fiat", "rpartstore"]);
expect(orderTaskbarWindows("nothing recognizable")).toEqual([]);
});
});
describe("isVinpinBusinessHours — 08:0021:00 Europe/Istanbul (UTC+3, no DST)", () => {
it("is true from 08:00 up to (not including) 21:00 local", () => {
expect(isVinpinBusinessHours(new Date("2026-07-14T05:00:00Z"))).toBe(true); // 08:00
expect(isVinpinBusinessHours(new Date("2026-07-14T12:00:00Z"))).toBe(true); // 15:00
expect(isVinpinBusinessHours(new Date("2026-07-14T17:59:00Z"))).toBe(true); // 20:59
});
it("is false before 08:00 and at/after 21:00 local", () => {
expect(isVinpinBusinessHours(new Date("2026-07-14T04:59:00Z"))).toBe(false); // 07:59
expect(isVinpinBusinessHours(new Date("2026-07-14T18:00:00Z"))).toBe(false); // 21:00
expect(isVinpinBusinessHours(new Date("2026-07-14T22:00:00Z"))).toBe(false); // 01:00
});
});

View File

@@ -8,16 +8,18 @@ import {
Inject,
NotFoundException,
Param,
Patch,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { desc, eq } from "drizzle-orm";
import { desc, eq, inArray } from "drizzle-orm";
import { getApiKeyApi } from "../auth/auth";
import { Public } from "../common/decorators/public.decorator";
import { InternalTokenGuard } from "../common/guards/internal-token.guard";
import { DATABASE, type Database } from "../database/database.provider";
import { apikeys, users } from "../database/schema/core";
import { PublicApiQuotaService } from "../public-api/public-api-quota.service";
// better-auth createApiKey `expiresIn`'i SANİYE bekler (ms değil).
const DAY_SECONDS = 24 * 60 * 60;
@@ -31,7 +33,10 @@ const DAY_SECONDS = 24 * 60 * 60;
@Public()
@UseGuards(InternalTokenGuard)
export class ApiKeysAdminController {
constructor(@Inject(DATABASE) private readonly db: Database) {}
constructor(
@Inject(DATABASE) private readonly db: Database,
private readonly quota: PublicApiQuotaService,
) {}
/**
* Anahtar üret. Kullanıcı `userId` veya `email` ile seçilir (anahtar o
@@ -107,10 +112,14 @@ export class ApiKeysAdminController {
};
}
/** Kullanıcının anahtarlarını listele (hash asla dönmez). */
/**
* Anahtarları listele (hash asla dönmez). userId/email verilirse o
* kullanıcıya filtrelenir; verilmezse TÜM anahtarlar döner (Süper Panel
* genel görünümü). Her satırda sahibi kullanıcı da bulunur.
*/
@Get()
async list(@Query("userId") userId?: string, @Query("email") email?: string) {
const user = await this.resolveUser({ userId, email });
const user = userId || email ? await this.resolveUser({ userId, email }) : null;
const rows = await this.db
.select({
id: apikeys.id,
@@ -118,16 +127,140 @@ export class ApiKeysAdminController {
start: apikeys.start,
prefix: apikeys.prefix,
enabled: apikeys.enabled,
rateLimitMax: apikeys.rateLimitMax,
requestCount: apikeys.requestCount,
lastRequest: apikeys.lastRequest,
expiresAt: apikeys.expiresAt,
createdAt: apikeys.createdAt,
metadata: apikeys.metadata,
userId: apikeys.userId,
userEmail: users.email,
})
.from(apikeys)
.where(eq(apikeys.userId, user.id))
.innerJoin(users, eq(apikeys.userId, users.id))
.where(user ? eq(apikeys.userId, user.id) : undefined)
.orderBy(desc(apikeys.createdAt));
return { user: { id: user.id, email: user.email }, keys: rows };
return {
...(user ? { user: { id: user.id, email: user.email } } : {}),
keys: rows,
};
}
/**
* Günlük decode-kotası anlık durumu (Redis sayaçları). `ids` virgüllü
* liste; verilmezse tüm anahtarlar. Kota günü TR gecesi döndüğü için
* `used` yalnız bugünü yansıtır — tarihçe tutulmaz.
*/
@Get("usage")
async usage(@Query("ids") ids?: string) {
const idList = (ids ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const rows = await this.db
.select({ id: apikeys.id, metadata: apikeys.metadata, enabled: apikeys.enabled })
.from(apikeys)
.where(idList.length ? inArray(apikeys.id, idList) : undefined);
const items = await Promise.all(
rows.map(async (row) => {
const limit = this.quota.resolveLimit(parseKeyMetadata(row.metadata));
const state = await this.quota.getState(row.id, limit);
return { id: row.id, enabled: row.enabled, ...state };
}),
);
return { items };
}
/**
* Anahtar ayarlarını güncelle. Alan verilmezse dokunulmaz; `null`
* geçilen alan temizlenir (dailyDecodeLimit → env varsayılanına döner,
* expiresInDays → süresiz, rateLimitMax → plugin varsayılanı 120/dk).
*/
@Patch(":id")
async update(
@Param("id") id: string,
@Body()
body: {
name?: string;
dailyDecodeLimit?: number | null;
domains?: string[];
rateLimitMax?: number | null;
expiresInDays?: number | null;
},
) {
const [row] = await this.db.select().from(apikeys).where(eq(apikeys.id, id)).limit(1);
if (!row) throw new NotFoundException("API anahtarı bulunamadı");
const metadata = parseKeyMetadata(row.metadata);
let metadataTouched = false;
const set: Partial<typeof apikeys.$inferInsert> = { updatedAt: new Date() };
if (body.name !== undefined) {
const name = String(body.name).trim();
if (!name || name.length > 32) {
throw new BadRequestException("name 1-32 karakter olmalı");
}
set.name = name;
}
if (body.dailyDecodeLimit !== undefined) {
if (body.dailyDecodeLimit === null) {
// JSON.stringify undefined alanı düşürür → env varsayılanına döner.
metadata.dailyDecodeLimit = undefined;
} else if (typeof body.dailyDecodeLimit !== "number" || body.dailyDecodeLimit <= 0) {
throw new BadRequestException("dailyDecodeLimit pozitif bir sayı veya null olmalı");
} else {
metadata.dailyDecodeLimit = Math.floor(body.dailyDecodeLimit);
}
metadataTouched = true;
}
if (body.domains !== undefined) {
if (!metadata.publishable) {
throw new BadRequestException("domains yalnız publishable anahtarlarda kullanılır");
}
if (!Array.isArray(body.domains)) {
throw new BadRequestException('domains bir dizi olmalı (ör. ["musteri.com"])');
}
metadata.domains = body.domains.filter((d) => typeof d === "string" && d.trim());
metadataTouched = true;
}
if (body.rateLimitMax !== undefined) {
if (body.rateLimitMax === null) {
set.rateLimitMax = null;
set.rateLimitTimeWindow = null;
} else if (typeof body.rateLimitMax !== "number" || body.rateLimitMax <= 0) {
throw new BadRequestException("rateLimitMax pozitif bir sayı veya null olmalı");
} else {
set.rateLimitEnabled = true;
set.rateLimitTimeWindow = 60_000;
set.rateLimitMax = Math.floor(body.rateLimitMax);
}
}
if (body.expiresInDays !== undefined) {
if (body.expiresInDays === null) {
set.expiresAt = null;
} else if (typeof body.expiresInDays !== "number" || body.expiresInDays <= 0) {
throw new BadRequestException("expiresInDays pozitif bir sayı veya null olmalı");
} else {
set.expiresAt = new Date(Date.now() + body.expiresInDays * DAY_SECONDS * 1000);
}
}
// better-auth şema transform'u tek JSON.stringify yazar — aynı format.
if (metadataTouched) set.metadata = JSON.stringify(metadata);
const [updated] = await this.db.update(apikeys).set(set).where(eq(apikeys.id, id)).returning({
id: apikeys.id,
name: apikeys.name,
enabled: apikeys.enabled,
rateLimitMax: apikeys.rateLimitMax,
expiresAt: apikeys.expiresAt,
metadata: apikeys.metadata,
});
return updated;
}
/** Anahtarı devre dışı bırak (geri açmak için enable). */
@@ -166,3 +299,16 @@ export class ApiKeysAdminController {
return user;
}
}
/** apikeys.metadata text kolonu — better-auth tek JSON.stringify ile yazar. */
function parseKeyMetadata(raw: string | null): Record<string, unknown> {
if (!raw) return {};
try {
let value: unknown = JSON.parse(raw);
// Bazı better-auth sürümleri çift stringify yapabiliyor; toleranslı ol.
if (typeof value === "string") value = JSON.parse(value);
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
} catch {
return {};
}
}

View File

@@ -71,10 +71,7 @@ export class BillingController {
@Post(":id/cancel")
@HttpCode(HttpStatus.OK)
async cancel(
@Param("id") id: string,
@Body() body: { reason?: string; founderId?: string },
) {
async cancel(@Param("id") id: string, @Body() body: { reason?: string; founderId?: string }) {
this.requireFounder(body);
this.requireReason(body, "cancel");
return this.billing.cancel({
@@ -86,10 +83,7 @@ export class BillingController {
@Post(":id/resume")
@HttpCode(HttpStatus.OK)
async resume(
@Param("id") id: string,
@Body() body: { reason?: string; founderId?: string },
) {
async resume(@Param("id") id: string, @Body() body: { reason?: string; founderId?: string }) {
this.requireFounder(body);
this.requireReason(body, "resume");
return this.billing.resume({
@@ -128,3 +122,53 @@ export class BillingController {
}
}
}
/**
* User-scoped billing admin: start a fresh subscription for a user whose
* previous one is expired/cancelled (the panel's "Yeni abonelik başlat").
*/
@Controller("internal/admin/users")
@Public()
@UseGuards(InternalTokenGuard)
export class UserBillingController {
constructor(private billing: BillingService) {}
@Post(":id/subscriptions")
@HttpCode(HttpStatus.OK)
async startSubscription(
@Param("id") id: string,
@Body()
body: {
planId?: string;
billingPeriod?: string;
days?: number;
brandIds?: string[];
reason?: string;
founderId?: string;
},
) {
if (!body.founderId) throw new BadRequestException("founderId required");
if (!body.reason || body.reason.trim().length < 5) {
throw new BadRequestException("start-subscription requires reason (min 5 chars)");
}
if (!body.planId) throw new BadRequestException("planId required");
if (body.billingPeriod !== "monthly" && body.billingPeriod !== "yearly") {
throw new BadRequestException("billingPeriod must be monthly|yearly");
}
if (body.days !== undefined && (typeof body.days !== "number" || body.days <= 0)) {
throw new BadRequestException("days must be a positive number");
}
if (body.brandIds !== undefined && !Array.isArray(body.brandIds)) {
throw new BadRequestException("brandIds must be an array");
}
return this.billing.startSubscription({
userId: id,
planId: body.planId,
billingPeriod: body.billingPeriod,
days: body.days,
brandIds: body.brandIds,
reason: body.reason,
founderId: body.founderId,
});
}
}

View File

@@ -0,0 +1,181 @@
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
import { describe, expect, it, vi } from "vitest";
import { BillingService } from "./billing.service";
/**
* Drizzle-shaped mock: every `db.select()` pops the next row-set from `selects`
* (order = order of queries in the service), `db.insert()` returns
* `insertRows` from `.returning()` and records `.values()` payloads.
*/
function createMockDb(selects: unknown[][], insertRows: unknown[] = []) {
const queue = [...selects];
const inserted: unknown[] = [];
function chain(terminal: unknown, onValues?: (v: unknown) => void) {
const c: Record<string, unknown> = {};
for (const m of [
"select",
"from",
"where",
"limit",
"insert",
"values",
"returning",
"update",
"set",
]) {
c[m] = vi.fn().mockReturnValue(c);
}
c.values = vi.fn().mockImplementation((v: unknown) => {
onValues?.(v);
return c;
});
// `await db.select()...where(...)` (no limit) resolves via thenable.
// biome-ignore lint/suspicious/noThenProperty: mimics Drizzle's thenable query builder
c.then = (res: (v: unknown) => unknown, rej?: (e: unknown) => unknown) =>
Promise.resolve(terminal).then(res, rej);
c.limit = vi.fn().mockReturnValue(Promise.resolve(terminal));
c.returning = vi.fn().mockReturnValue(Promise.resolve(terminal));
return c;
}
return {
inserted,
select: vi.fn().mockImplementation(() => chain(queue.shift() ?? [])),
insert: vi.fn().mockImplementation(() => chain(insertRows, (v) => inserted.push(v))),
update: vi.fn().mockImplementation(() => chain([])),
};
}
const USER = { id: "user-1" };
const FULL = { id: "plan-full", name: "Full Paket", brandCount: 0, isActive: true };
const ONE = { id: "plan-1", name: "1 Marka", brandCount: 1, isActive: true };
const BRANDS = [{ id: "b1" }, { id: "b2" }, { id: "b3" }];
const base = { userId: "user-1", reason: "manuel tanım", founderId: "founder-1" } as const;
function svc(db: unknown) {
return new BillingService(db as any, {} as any);
}
describe("BillingService.startSubscription", () => {
it("creates an active Full-plan subscription with every active brand and the given days", async () => {
const created = {
id: "sub-new",
startDate: new Date("2026-09-19T00:00:00Z"),
endDate: new Date("2029-09-19T00:00:00Z"),
};
const db = createMockDb([[USER], [], [FULL], BRANDS], [created]);
const res = await svc(db).startSubscription({
...base,
planId: FULL.id,
billingPeriod: "yearly",
days: 1095,
});
expect(res.success).toBe(true);
expect(res.subscriptionId).toBe("sub-new");
expect(res.brandCount).toBe(3);
// 1st insert = subscription row, 2nd = brand rows
expect(db.inserted).toHaveLength(2);
const subRow = db.inserted[0] as {
status: string;
billingPeriod: string;
startDate: Date;
endDate: Date;
};
expect(subRow.status).toBe("active");
expect(subRow.billingPeriod).toBe("yearly");
const diffDays = Math.round(
(subRow.endDate.getTime() - subRow.startDate.getTime()) / 86_400_000,
);
expect(diffDays).toBe(1095);
expect(db.inserted[1]).toEqual(
BRANDS.map((b) => ({ userId: "user-1", subscriptionId: "sub-new", brandId: b.id })),
);
});
it("defaults the period from billingPeriod when days is omitted", async () => {
const db = createMockDb([[USER], [], [FULL], BRANDS], [{ id: "sub-new" }]);
await svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "monthly" });
const subRow = db.inserted[0] as { startDate: Date; endDate: Date };
const expected = new Date(subRow.startDate);
expected.setMonth(expected.getMonth() + 1);
expect(subRow.endDate.getTime()).toBe(expected.getTime());
});
it("uses the requested brands for a brand-limited plan", async () => {
const db = createMockDb([[USER], [], [ONE], BRANDS], [{ id: "sub-new" }]);
const res = await svc(db).startSubscription({
...base,
planId: ONE.id,
billingPeriod: "monthly",
brandIds: ["b2"],
});
expect(res.brandCount).toBe(1);
expect(db.inserted[1]).toEqual([
{ userId: "user-1", subscriptionId: "sub-new", brandId: "b2" },
]);
});
it("rejects wrong brand count / unknown brands for a brand-limited plan", async () => {
await expect(
svc(createMockDb([[USER], [], [ONE], BRANDS])).startSubscription({
...base,
planId: ONE.id,
billingPeriod: "monthly",
brandIds: [],
}),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
svc(createMockDb([[USER], [], [ONE], BRANDS])).startSubscription({
...base,
planId: ONE.id,
billingPeriod: "monthly",
brandIds: ["nope"],
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it("refuses when the user already has an active or trial subscription", async () => {
const db = createMockDb([[USER], [{ id: "sub-live", status: "trial" }]]);
await expect(
svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly" }),
).rejects.toBeInstanceOf(ConflictException);
expect(db.insert).not.toHaveBeenCalled();
});
it("404s on unknown user / plan and rejects inactive plans", async () => {
await expect(
svc(createMockDb([[]])).startSubscription({
...base,
planId: FULL.id,
billingPeriod: "yearly",
}),
).rejects.toBeInstanceOf(NotFoundException);
await expect(
svc(createMockDb([[USER], [], []])).startSubscription({
...base,
planId: "missing",
billingPeriod: "yearly",
}),
).rejects.toBeInstanceOf(NotFoundException);
await expect(
svc(createMockDb([[USER], [], [{ ...FULL, isActive: false }]])).startSubscription({
...base,
planId: FULL.id,
billingPeriod: "yearly",
}),
).rejects.toBeInstanceOf(ConflictException);
});
it("validates days range and billingPeriod before touching the db", async () => {
const db = createMockDb([]);
await expect(
svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly", days: 0 }),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly", days: 3651 }),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "weekly" as any }),
).rejects.toBeInstanceOf(BadRequestException);
expect(db.select).not.toHaveBeenCalled();
});
});

View File

@@ -6,9 +6,9 @@ import {
Logger,
NotFoundException,
} from "@nestjs/common";
import { eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { brands, plans, userBrands, userSubscriptions } from "../database/schema/core";
import { brands, plans, userBrands, userSubscriptions, users } from "../database/schema/core";
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
@Injectable()
@@ -31,8 +31,8 @@ export class BillingService {
reason: string;
founderId: string;
}) {
if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 90) {
throw new BadRequestException("days must be 1..90");
if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 3650) {
throw new BadRequestException("days must be 1..3650");
}
const [sub] = await this.db
.select()
@@ -74,6 +74,133 @@ export class BillingService {
};
}
/**
* Start a brand-new ACTIVE subscription for a user whose previous one is
* expired/cancelled (or who has none). Mirrors what activateSubscription does
* for a paid checkout — status/dates/plan-based brand assignment — but does
* NOT emit revenue events (PostHog subscription_activated / Meta Purchase) or
* consume referral credit: this is a founder grant, no money moved here.
* `days` overrides the default period (monthly → +1 ay, yearly → +1 yıl).
*/
async startSubscription(input: {
userId: string;
planId: string;
billingPeriod: "monthly" | "yearly";
days?: number;
brandIds?: string[];
reason: string;
founderId: string;
}) {
if (input.billingPeriod !== "monthly" && input.billingPeriod !== "yearly") {
throw new BadRequestException("billingPeriod must be monthly|yearly");
}
if (input.days !== undefined) {
if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 3650) {
throw new BadRequestException("days must be 1..3650");
}
}
const [user] = await this.db
.select({ id: users.id })
.from(users)
.where(eq(users.id, input.userId))
.limit(1);
if (!user) throw new NotFoundException("Kullanıcı bulunamadı");
const [live] = await this.db
.select({ id: userSubscriptions.id, status: userSubscriptions.status })
.from(userSubscriptions)
.where(
and(
eq(userSubscriptions.userId, input.userId),
inArray(userSubscriptions.status, ["active", "trial"]),
),
)
.limit(1);
if (live) {
throw new ConflictException(
`Kullanıcının zaten '${live.status}' bir subscription'ı var (${live.id}) — activate / plan değiştir / uzat kullanın`,
);
}
const [plan] = await this.db.select().from(plans).where(eq(plans.id, input.planId)).limit(1);
if (!plan) throw new NotFoundException("Plan bulunamadı");
if (!plan.isActive) throw new ConflictException("Plan aktif değil");
const activeBrands = await this.db.select().from(brands).where(eq(brands.isActive, true));
let brandIds: string[];
if (plan.brandCount === 0) {
// Full plan → every active brand, whatever the caller sent.
brandIds = activeBrands.map((b) => b.id);
} else {
const requested = input.brandIds ?? [];
if (requested.length !== plan.brandCount) {
throw new BadRequestException(
`Bu plan tam olarak ${plan.brandCount} marka gerektirir, ${requested.length} seçildi`,
);
}
if (new Set(requested).size !== requested.length) {
throw new BadRequestException("Marka listesi tekrar içeriyor");
}
const valid = new Set(activeBrands.map((b) => b.id));
for (const id of requested) {
if (!valid.has(id)) throw new BadRequestException(`Geçersiz veya pasif marka: ${id}`);
}
brandIds = requested;
}
const now = new Date();
const endDate = new Date(now);
if (input.days !== undefined) {
endDate.setDate(endDate.getDate() + input.days);
} else if (input.billingPeriod === "yearly") {
endDate.setFullYear(endDate.getFullYear() + 1);
} else {
endDate.setMonth(endDate.getMonth() + 1);
}
const [created] = await this.db
.insert(userSubscriptions)
.values({
userId: input.userId,
planId: input.planId,
status: "active",
billingPeriod: input.billingPeriod,
startDate: now,
endDate,
})
.returning();
if (brandIds.length > 0) {
await this.db.insert(userBrands).values(
brandIds.map((brandId) => ({
userId: input.userId,
subscriptionId: created.id,
brandId,
})),
);
}
this.logger.log(
`start: user=${input.userId} subscription=${created.id} plan=${plan.name} ` +
`${input.billingPeriod}${input.days !== undefined ? ` ${input.days}d` : ""}${endDate.toISOString()} ` +
`brands=${brandIds.length} founder=${input.founderId} reason="${input.reason.slice(0, 80)}"`,
);
return {
success: true,
subscriptionId: created.id,
userId: input.userId,
planId: plan.id,
planName: plan.name,
billingPeriod: input.billingPeriod,
status: "active",
startDate: created.startDate?.toISOString() ?? null,
endDate: created.endDate?.toISOString() ?? null,
brandCount: brandIds.length,
};
}
async manuallyActivate(input: {
subscriptionId: string;
reason: string;
@@ -126,9 +253,7 @@ export class BillingService {
.limit(1);
if (!sub) throw new NotFoundException("Subscription bulunamadı");
if (sub.status !== "active" && sub.status !== "trial") {
throw new ConflictException(
`'${sub.status}' subscription için plan değişimi yapılamaz`,
);
throw new ConflictException(`'${sub.status}' subscription için plan değişimi yapılamaz`);
}
if (sub.planId === input.newPlanId) {
throw new ConflictException("Subscription zaten bu planda");
@@ -234,16 +359,10 @@ export class BillingService {
.limit(1);
if (!sub) throw new NotFoundException("Subscription bulunamadı");
if (sub.status !== "active" && sub.status !== "trial") {
throw new ConflictException(
`'${sub.status}' subscription için brand atama yapılamaz`,
);
throw new ConflictException(`'${sub.status}' subscription için brand atama yapılamaz`);
}
const [plan] = await this.db
.select()
.from(plans)
.where(eq(plans.id, sub.planId))
.limit(1);
const [plan] = await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1);
if (!plan) throw new NotFoundException("Plan bulunamadı");
// brandCount 0 = "all brands" (Full plan): don't accept a custom list;
@@ -282,9 +401,7 @@ export class BillingService {
.from(userBrands)
.where(eq(userBrands.subscriptionId, input.subscriptionId));
await this.db
.delete(userBrands)
.where(eq(userBrands.subscriptionId, input.subscriptionId));
await this.db.delete(userBrands).where(eq(userBrands.subscriptionId, input.subscriptionId));
if (input.brandIds.length > 0) {
await this.db.insert(userBrands).values(

View File

@@ -1,3 +1,4 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import {
Body,
Controller,
@@ -15,7 +16,6 @@ import {
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Request, Response } from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
import { Public } from "../common/decorators/public.decorator";
import { InternalTokenGuard } from "../common/guards/internal-token.guard";
import { ImpersonationService } from "./impersonation.service";
@@ -66,9 +66,8 @@ export class ImpersonationController {
ttlMinutes: body.ttlMinutes ?? 15,
reason,
ipAddress: this.getIp(req),
userAgent: typeof req.headers["user-agent"] === "string"
? req.headers["user-agent"]
: undefined,
userAgent:
typeof req.headers["user-agent"] === "string" ? req.headers["user-agent"] : undefined,
});
const baseUrl = this.configService.get<string>("auth.url") ?? "";
@@ -149,8 +148,7 @@ export class ImpersonationController {
private consumeSecret(): string {
const secret =
process.env.INTERNAL_IMPERSONATION_SECRET ??
this.configService.get<string>("auth.secret");
process.env.INTERNAL_IMPERSONATION_SECRET ?? this.configService.get<string>("auth.secret");
if (!secret) throw new Error("INTERNAL_IMPERSONATION_SECRET or auth.secret required");
return secret;
}

View File

@@ -32,10 +32,7 @@ export class ImpersonationService {
expiresAt: Date;
sessionId: string;
}> {
const ttl = Math.min(
Math.max(params.ttlMinutes || DEFAULT_TTL_MINUTES, 1),
MAX_TTL_MINUTES,
);
const ttl = Math.min(Math.max(params.ttlMinutes || DEFAULT_TTL_MINUTES, 1), MAX_TTL_MINUTES);
// Verify target exists (and isn't an admin — defense in depth)
const [target] = await this.db
@@ -78,9 +75,7 @@ export class ImpersonationService {
const cookieValue = encodeURIComponent(signedValue);
const isHttps = (this.configService.get<string>("auth.url") ?? "").startsWith("https://");
const cookieName = isHttps
? SESSION_COOKIE_NAME_SECURE
: SESSION_COOKIE_NAME_INSECURE;
const cookieName = isHttps ? SESSION_COOKIE_NAME_SECURE : SESSION_COOKIE_NAME_INSECURE;
this.logger.log(
`Impersonation session created: founder=${params.founderId} target=${params.targetUserId} ttl=${ttl}m reason="${params.reason.slice(0, 80)}" sessionId=${sessionId.slice(0, 8)}`,

View File

@@ -1,8 +1,9 @@
import { Module } from "@nestjs/common";
import { StripeModule } from "../payments/stripe/stripe.module";
import { PublicApiQuotaService } from "../public-api/public-api-quota.service";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
import { ApiKeysAdminController } from "./api-keys.controller";
import { BillingController } from "./billing.controller";
import { BillingController, UserBillingController } from "./billing.controller";
import { BillingService } from "./billing.service";
import { ImpersonationController } from "./impersonation.controller";
import { ImpersonationService } from "./impersonation.service";
@@ -19,6 +20,7 @@ import { InternalVehiclesService } from "./vehicles.service";
ImpersonationController,
LifecycleController,
BillingController,
UserBillingController,
PaymentsAdminController,
InternalVehiclesController,
],
@@ -27,6 +29,9 @@ import { InternalVehiclesService } from "./vehicles.service";
LifecycleService,
BillingService,
InternalVehiclesService,
// Redis kota sayaçlarını /internal/admin/api-keys/usage için okur
// (RedisModule @Global olduğundan bağımlılığı hazır).
PublicApiQuotaService,
],
})
export class InternalAdminModule {}

View File

@@ -0,0 +1,276 @@
import { describe, expect, it } from "vitest";
import { PRIORITY, classifyNode, mapToCanonical } from "../canonical-lexicon";
// Inputs are already NORMALIZE_SQL-folded (ASCII, lowercase, collapsed spaces).
describe("classifyNode", () => {
it("flags Opel model-code selector nodes as structural", () => {
expect(classifyNode("j82 ascona-c 1982 - 1988")).toBe("structural");
expect(classifyNode("c06 bls 2006 - 2009")).toBe("structural");
expect(classifyNode("b16 astra-k 2016 - 2021")).toBe("structural");
});
it("flags bare year-range nodes as structural", () => {
expect(classifyNode("astra-h 2004 - 2014")).toBe("structural");
});
it("flags MAN truck model descriptors as structural", () => {
expect(classifyNode("12.155 star")).toBe("structural");
expect(classifyNode("10/11.150 hocl/r")).toBe("structural");
expect(classifyNode("18t hocl-sd")).toBe("structural");
// a real category starting with digits+space (not a truck code) stays part
expect(classifyNode("4 silindirli motor")).toBe("part");
});
it("flags MAN bus/coachbuilder model names as structural", () => {
expect(classifyNode("lion's city 12m")).toBe("structural");
expect(classifyNode("nl 202 muenchen")).toBe("structural");
expect(classifyNode("ng 242/272")).toBe("structural");
expect(classifyNode("man 292/332/362 h")).toBe("structural");
expect(classifyNode("n 1216 hd cityliner")).toBe("structural");
expect(classifyNode("aufbau caetano")).toBe("structural");
// "LPG" bus models (NL 205 M11 LPG) had wrongly mapped to Yakıt — now caught
expect(classifyNode("nl 205 m11 lpg")).toBe("structural");
// guard: real electrical part still maps, not swallowed by "n ?\d" etc.
expect(mapToCanonical("elektr. sistemler, gost. cih.").slug).toBe("elektrik");
});
it("flags bare Mercedes catalog codes structural and index pages junk", () => {
expect(classifyNode("c442")).toBe("structural");
expect(classifyNode("c105011")).toBe("structural");
expect(classifyNode("sa listesi")).toBe("junk");
expect(classifyNode("ilk on resim aciklama sayfasi")).toBe("junk");
// descriptor codes that carry a part word must stay part (map via keyword)
expect(classifyNode("d765300(22h), ls 7 f, mb servo direksiyon")).toBe("part");
expect(classifyNode("conta takimi")).toBe("part");
expect(classifyNode("debriyaj balatasi")).toBe("part");
});
it("flags empty / doc nodes as junk", () => {
expect(classifyNode("")).toBe("junk");
expect(classifyNode("ab")).toBe("junk");
expect(classifyNode("teknik literatur talimatlar")).toBe("junk");
});
it("flags VIN markers and bare engine/order codes as structural", () => {
expect(classifyNode("wvwzzz9nz5-180000")).toBe("structural");
expect(classifyNode("z16dt[lfu]")).toBe("structural");
});
it("keeps a real part that merely carries a date-validity marker", () => {
// ">>" alone must not reclassify a genuine part name
expect(classifyNode("pencere camlari d >> - 14.07.2019")).toBe("part");
expect(classifyNode("gosterge tablosu d - 30.05.2011>> - 27.05.2012")).toBe("part");
});
it("keeps real part-category names as part", () => {
expect(classifyNode("fren balata on")).toBe("part");
expect(classifyNode("eksantrik mili, supaplar (1,4/1,6ltr.)")).toBe("part");
expect(classifyNode("yag pompasi yag filtresi")).toBe("part");
});
});
describe("mapToCanonical", () => {
it("maps clear names to the expected bucket", () => {
expect(mapToCanonical("fren balata on").slug).toBe("fren");
expect(mapToCanonical("eksantrik mili supaplar").slug).toBe("motor");
expect(mapToCanonical("far temizleme sistemi").slug).toBe("aydinlatma");
expect(mapToCanonical("5-vitesli duz sanziman").slug).toBe("sanziman");
expect(mapToCanonical("koltuk sirtlik dolgu").slug).toBe("ic-doseme");
});
it("maps expanded-lexicon terms (egzoz, karoseri, bcm)", () => {
expect(mapToCanonical("kat konvertorlu egz borusu").slug).toBe("yakit-sistemi");
expect(mapToCanonical("pikap karoserisi").slug).toBe("panjur-panel");
expect(mapToCanonical("merkezi elektrik kontrol unitesi bcm").slug).toBe("elektrik");
});
it("maps short-stem inflections (yağı, mili, kardan)", () => {
expect(mapToCanonical("hps yagi").slug).toBe("yaglar-sivilar");
expect(mapToCanonical("kardan mili").slug).toBe("sanziman");
expect(mapToCanonical("araba akusu").slug).toBe("elektrik");
// regression: a strong term still wins over the newly-added weak inflection
expect(mapToCanonical("eksantrik mili supaplar").slug).toBe("motor");
expect(mapToCanonical("yag filtresi").slug).toBe("filtreler");
});
it("maps a root named exactly like its canonical bucket", () => {
expect(mapToCanonical("suspansiyon, sasi").slug).toBe("suspansiyon");
expect(mapToCanonical("elektrik").slug).toBe("elektrik");
expect(mapToCanonical("aydinlatma").slug).toBe("aydinlatma");
expect(mapToCanonical("direksiyon").slug).toBe("direksiyon");
expect(mapToCanonical("motor").slug).toBe("motor");
});
it("maps pcat shared-taxonomy roots", () => {
expect(mapToCanonical("jenerator").slug).toBe("elektrik");
expect(mapToCanonical("yaglama sistemi").slug).toBe("yaglar-sivilar");
expect(mapToCanonical("karo, arac cami").slug).toBe("panjur-panel");
expect(mapToCanonical("guc aktarim organlari").slug).toBe("sanziman");
expect(mapToCanonical("cerceve").slug).toBe("panjur-panel");
// regression: şasi stays under süspansiyon when paired with it
expect(mapToCanonical("suspansiyon, sasi").slug).toBe("suspansiyon");
expect(mapToCanonical("sasi kabugu ve plakalar").slug).toBe("panjur-panel");
});
it("maps the new wheel + accessory buckets and the resolved ambiguous roots", () => {
expect(mapToCanonical("jantlar, lastikler").slug).toBe("jant-lastik");
expect(mapToCanonical("gobek ve tekerlekler").slug).toBe("jant-lastik");
expect(mapToCanonical("aksesuarlar ve cesitli").slug).toBe("aksesuar");
expect(mapToCanonical("hava sistemi").slug).toBe("sogutma");
expect(mapToCanonical("bakim parcalari").slug).toBe("filtreler");
// "hava yastığı" (airbag) must beat sogutma's weak "hava"
expect(mapToCanonical("hava yastigi surucu tarafi").slug).toBe("direksiyon");
// "hava filtresi" still filtreler; "…Yağlar Araç Bakım Ürünleri" still yakıt
expect(mapToCanonical("hava filtresi").slug).toBe("filtreler");
expect(mapToCanonical("yakitlar ve yaglar arac bakim urunleri").slug).toBe("yakit-sistemi");
// a real part with an "aksesuar" mention still wins over the catch-all
expect(mapToCanonical("fren aksesuarlari").slug).toBe("fren");
});
it("maps English category names (pl24 Asian brands / untranslated leaves)", () => {
expect(mapToCanonical("engine").slug).toBe("motor");
expect(mapToCanonical("transmission").slug).toBe("sanziman");
expect(mapToCanonical("brake").slug).toBe("fren");
expect(mapToCanonical("chassis").slug).toBe("panjur-panel");
expect(mapToCanonical("axle & suspension").slug).toBe("suspansiyon");
expect(mapToCanonical("floor covering").slug).toBe("ic-doseme");
expect(mapToCanonical("body seats").slug).toBe("ic-doseme");
// system word beats the weak location word in a phrase
expect(mapToCanonical("body electrical").slug).toBe("elektrik");
expect(mapToCanonical("engine mechanical").slug).toBe("motor");
});
it("maps Opel spelling variants", () => {
expect(mapToCanonical("otomatik sanzuman").slug).toBe("sanziman");
expect(mapToCanonical("on dingil ve suspensiyon").slug).toBe("suspansiyon");
expect(mapToCanonical("disli secici kolu").slug).toBe("sanziman");
});
it("maps the Mercedes long-tail (English + Turkish part names)", () => {
expect(mapToCanonical("propeller shaft").slug).toBe("sanziman");
expect(mapToCanonical("radiator").slug).toBe("sogutma");
expect(mapToCanonical("sliding roof").slug).toBe("panjur-panel");
expect(mapToCanonical("driver's door").slug).toBe("dis-kaporta");
expect(mapToCanonical("windows").slug).toBe("dis-kaporta");
expect(mapToCanonical("timing").slug).toBe("triger-zincir-rulman");
expect(mapToCanonical("carburetor").slug).toBe("yakit-sistemi");
expect(mapToCanonical("pedal sistemi").slug).toBe("fren");
expect(mapToCanonical("arka kapak").slug).toBe("dis-kaporta");
expect(mapToCanonical("ortu").slug).toBe("ic-doseme");
});
it("maps BMW electronics + lighting and Ford trans/engine variants", () => {
expect(mapToCanonical("audio, navigasyon, elektronik sistem").slug).toBe("elektrik");
expect(mapToCanonical("iletisim sistemi").slug).toBe("elektrik");
expect(mapToCanonical("distance systems, cruise control").slug).toBe("elektrik");
expect(mapToCanonical("isik verme").slug).toBe("aydinlatma");
expect(mapToCanonical("tvmt6 - 6 speed manual trans").slug).toBe("sanziman");
expect(mapToCanonical("t20dd0j - 2.0l diesel panther").slug).toBe("motor");
expect(mapToCanonical("govde ve boya").slug).toBe("panjur-panel");
});
it("flags BMW service/doc pseudo-pages as junk", () => {
expect(classifyNode("teknik dokuman")).toBe("junk");
expect(classifyNode("servis ve onarim kapsamlari")).toBe("junk");
expect(classifyNode("value parts&packages servisi ve onarim")).toBe("junk");
});
it("maps small-brand terms (English + Turkish) added for max coverage", () => {
expect(mapToCanonical("diagnostic devices").slug).toBe("elektrik");
expect(mapToCanonical("in car entertainment").slug).toBe("elektrik");
expect(mapToCanonical("genel arac elektrigi").slug).toBe("elektrik");
expect(mapToCanonical("heater,a/c & ventilation").slug).toBe("sogutma");
expect(mapToCanonical("braking system").slug).toBe("fren");
expect(mapToCanonical("exterior fittings and sunroof").slug).toBe("dis-kaporta");
expect(mapToCanonical("occupant restraints").slug).toBe("ic-doseme");
expect(mapToCanonical("aracta konfor ve yasam").slug).toBe("ic-doseme");
expect(mapToCanonical("yapistiricilar / onarim kitleri").slug).toBe("yaglar-sivilar");
});
it("maps Kollar / Ön Takım to süspansiyon without breaking stronger matches", () => {
expect(mapToCanonical("kollar").slug).toBe("suspansiyon");
expect(mapToCanonical("on takim").slug).toBe("suspansiyon");
expect(mapToCanonical("arka takim").slug).toBe("suspansiyon");
// strong words still win over the weak axle terms
expect(mapToCanonical("conta takimi").slug).toBe("motor");
expect(mapToCanonical("vites kollari").slug).toBe("sanziman");
// "kol demiri" (armrest) must NOT become süspansiyon (we didn't add bare "kol")
expect(mapToCanonical("kol demirleri").slug).not.toBe("suspansiyon");
});
it("maps emex high-frequency terms", () => {
expect(mapToCanonical("stabilizer / baglanti elemanlari").slug).toBe("suspansiyon");
expect(mapToCanonical("sok absorber").slug).toBe("suspansiyon");
expect(mapToCanonical("isi esanjoru, ic isitma").slug).toBe("sogutma");
expect(mapToCanonical("kondansator").slug).toBe("sogutma");
expect(mapToCanonical("ventilator").slug).toBe("sogutma");
expect(mapToCanonical("uflec / parcalari").slug).toBe("sogutma");
expect(mapToCanonical("yer halilari ve halilar").slug).toBe("ic-doseme");
expect(mapToCanonical("dis kilitler").slug).toBe("dis-kaporta");
expect(mapToCanonical("stepne donanimlari").slug).toBe("jant-lastik");
expect(mapToCanonical("yagmurluklar").slug).toBe("dis-kaporta");
});
it("maps emex low-brand terms (deep-dive additions)", () => {
expect(mapToCanonical("buharlastirici").slug).toBe("sogutma");
expect(mapToCanonical("kurutucu").slug).toBe("sogutma");
expect(mapToCanonical("aktuatorler").slug).toBe("elektrik");
expect(mapToCanonical("selenoid anahtar").slug).toBe("elektrik");
expect(mapToCanonical("anten").slug).toBe("elektrik");
expect(mapToCanonical("lambda kontrol").slug).toBe("yakit-sistemi");
expect(mapToCanonical("rolanti kontrolu").slug).toBe("yakit-sistemi");
expect(mapToCanonical("kanat parcalari").slug).toBe("panjur-panel");
expect(mapToCanonical("yan capraz kiris").slug).toBe("panjur-panel");
expect(mapToCanonical("burkma cubugu").slug).toBe("suspansiyon");
});
it("phrase overrides fix adjective+head mis-buckets", () => {
// "<system> yağı" = fluid, not the system
expect(mapToCanonical("motor yagi").slug).toBe("yaglar-sivilar");
expect(mapToCanonical("sanziman yagi").slug).toBe("yaglar-sivilar");
// but the PARTS keep their bucket (end-anchor excludes them)
expect(mapToCanonical("motor yagi filtresi").slug).toBe("filtreler");
expect(mapToCanonical("motor yagi pompasi").slug).toBe("motor");
// body electric motors → elektrik, not engine
expect(mapToCanonical("cam silecek motoru").slug).toBe("elektrik");
expect(mapToCanonical("cam motoru").slug).toBe("elektrik");
// timing gear → triger, not şanzıman
expect(mapToCanonical("zamanlama dislileri").slug).toBe("triger-zincir-rulman");
// engine cooling → soğutma; turbocharger → motor; drive belt → triger
expect(mapToCanonical("ic motor sogutma sistemi").slug).toBe("sogutma");
expect(mapToCanonical("emme manifoldu ve turbo sarjli").slug).toBe("motor");
expect(mapToCanonical("ic motor tahrik kayisi").slug).toBe("triger-zincir-rulman");
});
it("returns null when no keyword matches", () => {
expect(mapToCanonical("romork duzenegi cikarilabilir").slug).toBeNull();
// genuinely generic Mercedes names stay unmapped (better than wrong bucket)
expect(mapToCanonical("ayar").slug).toBeNull();
expect(mapToCanonical("genel parcalar").slug).toBeNull();
});
it("a strong head-noun beats a coincidental weak term (not ambiguous)", () => {
// 'filtre' (strong, filtreler) outweighs 'yag' (weak, yaglar-sivilar)
const m = mapToCanonical("yag filtresi");
expect(m.slug).toBe("filtreler");
expect(m.ambiguous).toBe(false);
// 'fren' (strong) outweighs 'hidrolik' (weak) — the old logic flagged this ambiguous
const f = mapToCanonical("fren hidrolik deposu");
expect(f.slug).toBe("fren");
expect(f.ambiguous).toBe(false);
});
it("flags a genuine two-strong tie as ambiguous", () => {
// fren(fren+balata) vs debriyaj(debriyaj+baski) — both score 6
const m = mapToCanonical("fren balata debriyaj baski");
expect(m.ambiguous).toBe(true);
expect(m.confidence).toBeLessThan(0.6);
});
it("only returns slugs present in the priority list", () => {
const m = mapToCanonical("motor blok");
expect(m.slug).not.toBeNull();
expect(PRIORITY).toContain(m.slug);
});
});

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { SUB_LEXICON, allSubLeaves, mapToSubcategory } from "../canonical-sublexicon";
describe("mapToSubcategory", () => {
it("maps Fren part names to the right sub-leaf (incl. Turkish variants)", () => {
expect(mapToSubcategory("on fren balatasi", "fren")).toBe("fren-balata");
expect(mapToSubcategory("fren diski on", "fren")).toBe("fren-disk");
expect(mapToSubcategory("fren kaliyeri", "fren")).toBe("fren-kaliper"); // kaliyer variant
expect(mapToSubcategory("fren kampanasi", "fren")).toBe("fren-kampana");
expect(mapToSubcategory("abs sensoru", "fren")).toBe("fren-abs");
expect(mapToSubcategory("fren hidroligi", "fren")).toBe("fren-hidrolik"); // hidroliği variant
expect(mapToSubcategory("el fren teli", "fren")).toBe("fren-elfren");
});
it("catches assembly hardware in the donanım leaf, not a part leaf", () => {
expect(mapToSubcategory("baglanti civatasi", "fren")).toBe("fren-donanim");
expect(mapToSubcategory("klips", "fren")).toBe("fren-donanim");
// a real part keyword still wins over hardware words in the same name
expect(mapToSubcategory("fren balatasi klipsi", "fren")).toBe("fren-balata");
});
it("maps across other high-value buckets", () => {
expect(mapToSubcategory("yag filtresi", "filtreler")).toBe("filtre-yag");
expect(mapToSubcategory("hava filtresi", "filtreler")).toBe("filtre-hava");
expect(mapToSubcategory("on far", "aydinlatma")).toBe("ayd-far");
expect(mapToSubcategory("stop lambasi", "aydinlatma")).toBe("ayd-stop");
expect(mapToSubcategory("on amortisor", "suspansiyon")).toBe("susp-amortisor");
expect(mapToSubcategory("silindir kapak contasi", "motor")).toBe("motor-silindirkapak");
expect(mapToSubcategory("debriyaj baski balata seti", "debriyaj")).toBe("debriyaj-balata");
});
it("maps queue-shrink additions (2026-07-06)", () => {
expect(mapToSubcategory("sok absorber", "suspansiyon")).toBe("susp-amortisor");
expect(mapToSubcategory("guneslik", "ic-doseme")).toBe("ic-tavan");
expect(mapToSubcategory("gosterge paneli", "elektrik")).toBe("elektrik-modul");
expect(mapToSubcategory("anten", "elektrik")).toBe("elektrik-modul");
expect(mapToSubcategory("korna", "elektrik")).toBe("elektrik-korna");
expect(mapToSubcategory("silecek lastigi", "elektrik")).toBe("elektrik-silecek");
expect(mapToSubcategory("silecek motoru", "elektrik")).toBe("elektrik-motor"); // motor wins
expect(mapToSubcategory("havalandirma ve isitma", "sogutma")).toBe("sogutma-kalorifer");
expect(mapToSubcategory("isi esanjoru", "sogutma")).toBe("sogutma-kalorifer");
expect(mapToSubcategory("yag tavasi", "yaglar-sivilar")).toBe("yag-sistemi");
expect(mapToSubcategory("kisa dingil", "suspansiyon")).toBe("susp-aks");
});
it("returns null for unknown bucket or unmatched name (→ SP manual)", () => {
expect(mapToSubcategory("balata", "aksesuar")).toBeNull(); // bucket not sub-split
expect(mapToSubcategory("belirtilmemis", "fren")).toBeNull();
expect(mapToSubcategory("", "fren")).toBeNull();
});
it("every sub-leaf slug is globally unique and bucket-prefixed", () => {
const leaves = allSubLeaves();
const slugs = leaves.map((l) => l.slug);
expect(new Set(slugs).size).toBe(slugs.length); // no dupes
for (const l of leaves) {
// slug starts with a stem of its bucket (fren-*, motor-*, ayd-*, susp-*, …)
expect(l.slug).toMatch(/^[a-z]+-/);
}
expect(Object.keys(SUB_LEXICON).length).toBeGreaterThanOrEqual(15);
});
});

View File

@@ -25,9 +25,16 @@ export const QUEUE_NAMES = {
SUBSCRIPTION_EXPIRY: "subscription-expiry",
QUERY_CLEANUP: "query-cleanup",
CATALOG_PREFETCH: "catalog-prefetch",
// Dedicated lane for freshly-decoded vehicles: the main queue's deep-drill
// backlog runs in a production>consumption regime for hours at a time, where
// lifo tail-insertion starves ANY job pushed a moment earlier — including new
// vehicles' init chains (BMW 320i sat 58min at 0 parts). A separate queue +
// worker gives fresh decodes their own consumption budget.
CATALOG_PREFETCH_FAST: "catalog-prefetch-fast",
TRANSLATION: "translation",
LIFECYCLE_EMAIL: "lifecycle-email",
EXPERT_REWARDS: "expert-rewards",
PART_PRICE_REFRESH: "part-price-refresh",
VINPIN_DECODE: "vinpin-decode",
CANONICAL_BACKFILL: "canonical-backfill",
} as const;

View File

@@ -0,0 +1,367 @@
// Canonical taxonomy mapping — shared logic for the canonical-backfill job.
//
// Two-stage mapping (proven on the VW+Opel dev pilot 2026-07-04):
// 1. classifyNode — is this raw OEM node an actual part-category, or a
// vehicle-selector (model/year/VIN/engine-code) node, or junk? Opel's pl24
// tree is ~87% model-code nodes ("j82 ascona-c 1982-1988"); those must NOT
// be mapped.
// 2. mapToCanonical — WEIGHTED keyword-match the surviving part-categories to
// one of the 20 canonical top buckets (migration 0024 seed). Strong
// head-nouns weigh 3, supporting terms 1; the score margin decides the
// winner and flags near-ties as ambiguous (rather than the old equal-count
// tie-break, which over-flagged).
//
// Names arrive already folded by NORMALIZE_SQL (Turkish → ASCII, lowercased,
// whitespace collapsed) so the same expression can drive the SELECT that builds
// the map and the UPDATE that materializes categories.canonical_category_id.
// Keep in exact sync with the UPDATE join in canonical-backfill.processor.ts —
// both sides must fold identically or the join misses.
export const NORMALIZE_SQL =
"btrim(regexp_replace(translate(lower(name),'çşğıöü','csgiou'),'\\s+',' ','g'))";
const STRONG_WEIGHT = 3;
const WEAK_WEIGHT = 1;
// JS equivalent of NORMALIZE_SQL, for on-the-fly serving-side mapping of
// categories whose canonical_category_id isn't materialized yet (freshly
// decoded vehicles between backfills). Must fold like the SQL side.
const TR_FOLD: Record<string, string> = {
ç: "c",
ş: "s",
ğ: "g",
ı: "i",
ö: "o",
ü: "u",
};
export function foldName(name: string): string {
return name
.toLowerCase()
.replace(/[çşğıöü]/g, (c) => TR_FOLD[c] ?? c)
.replace(/\s+/g, " ")
.trim();
}
interface Bucket {
strong: string[];
weak: string[];
}
// slug → { strong head-nouns, weak supporting terms }. slug MUST match a
// canonical_categories.slug seeded in migration 0024. Weak terms are the ones
// that recur across buckets (yag, hidrolik, cam, panel, rot, kayis…) so they
// only tip the balance, never carry a match alone.
export const LEXICON: Record<string, Bucket> = {
// English keywords (engine/body/brake…) are mixed in per bucket: many pl24
// Asian-brand catalogs (Hyundai/Kia/Nissan/Toyota) and untranslated emex/pcat
// leaves ship English category names. System words are strong, location words
// (body/floor/axle) weak so "BODY ELECTRICAL" → elektrik, not panjur.
motor: {
// "conta" (gasket) strong: mostly engine, and beats the weak "takim" so
// "conta takımı" → motor (not süspansiyon).
strong:
"motor silindir krank piston segman biyel supap subap eksantrik karter kulbutor conta engine".split(
" ",
),
// "diesel"/"dohc" Ford engine variants; "mekanik" (Turkish mechanical);
// "aggregate" (MAN engine/genset unit).
weak: "blok manifold yatak kompresyon bolmesi kam turbo mechanical mekanik diesel dohc aggregate".split(
" ",
),
},
"yakit-sistemi": {
// 'depo' is weak, not strong: "deposu" (reservoir) recurs in fren-hidrolik-
// deposu / cam-suyu-deposu etc.; "yakit" carries the fuel-tank signal.
strong:
"yakit enjektor enjeksiyon karburator carburetor egzoz egz konvertor katalitik susturucu fuel exhaust".split(
" ",
),
weak: "benzin mazot lpg rail dagitici depo lambda rolanti".split(" "),
},
"atesleme-sistemi": {
strong: "buji bobin atesleme distributor kizdirma ignition".split(" "),
weak: [],
},
"triger-zincir-rulman": {
strong: "triger timing".split(" "),
weak: "zincir gergi kayis rulman".split(" "),
},
sogutma: {
// emex adds: esanjor (ısı eşanjörü), kondansator, ventilator, kompresor
// (AC), sogutucu; "uflec" (blower) weak.
strong:
"radyator termostat klima kalorifer intercooler evaporator buharlastirici kurutucu devirdaim fan cooling heater ventilation ventilator esanjor kondansator kompresor sogutucu".split(
" ",
),
// "hava" weak so "Hava Sistemi" → soğutma (user decision) but "hava filtresi"
// (filtre strong) / "hava yastığı" (yastigi strong, direksiyon) still win.
weak: "sogutma isitici hortum hava heating radiator uflec".split(" "),
},
filtreler: {
strong: "filtre filter".split(" "),
// "bakım parçaları / rutin bakım" → filtreler (user decision); weak so a
// stronger term (yakıt/yağ) still wins on "…Araç Bakım Ürünleri" roots.
weak: "bakim".split(" "),
},
fren: {
strong: "fren balata kaliper kampana disk abs brake braking".split(" "),
// "pedal" weak → Mercedes "PEDAL SİSTEMİ" (pedal cluster) lands in fren.
weak: "mustur diskli pedal".split(" "),
},
debriyaj: {
strong: "debriyaj volan baski clutch".split(" "),
weak: "kavrama".split(" "),
},
sanziman: {
// "sanzuman"/"disli" are Opel spelling variants (şanzıman / dişli).
strong: "sanziman sanzuman vites diferansiyel kardan aktarim transmission gearbox".split(" "),
// 3-char stems (mil…) don't prefix-match their inflections, so list them.
// "speed"/"spd" for Ford trans variants ("6 Speed Manual Trans", "8 Spd Auto").
weak: "flans kavrama saft mil mili miller gear disli propeller tahrik speed spd".split(" "),
},
suspansiyon: {
// "suspensiyon" Opel variant; "dingil"=axle; "stabilizator/stabilizer"=anti-
// roll bar; "absorber"=(şok) absorber (emex).
strong:
"suspansiyon suspensiyon amortisor salincak rotil helezon viraj mafsal suspension stabilizator stabilizer absorber".split(
" ",
),
// "takoz/takozu"=strut/engine mount; "kollar"=(salıncak) control arms;
// "takim"=(ön/arka) axle assembly. Weak so strong words win: "conta takımı"
// → motor, "vites kolları" → şanzıman, but standalone "Kollar"/"Ön Takım" →
// süspansiyon. NOT "kol" (singular): "kol demiri" (armrest) is ic-doseme.
weak: "rot aks dingil burc yay bilya rulman tasiyici zemberek denge axle takoz takozu kollar takim burkma".split(
" ",
),
},
direksiyon: {
// "yastigi" strong so "hava yastığı" (airbag) beats the sogutma "hava" weak.
strong: "direksiyon kremayer airbag yastigi steering".split(" "),
weak: "rot hidrolik".split(" "),
},
elektrik: {
strong:
"elektrik elektr elektrigi alternator jenerator mars sarj role sigorta sensor korna bcm ecu beyin modul aku akusu enstruman gosterge electric electrical electricity audio ses navigasyon elektronik iletisim multimedya diagnostic entertainment bilgisayar aktuator selenoid anten".split(
" ",
),
weak: "kablo sinyal silecek tesisat salter salteri verici vericisi cruise".split(" "),
},
aydinlatma: {
// far/farlar/fari — 3-char "far" only matches exactly; add inflections.
// "isik" = BMW "Işık verme" (lighting).
strong: "aydinlatma isik far farlar fari stop lamba ampul sis reflektor lighting lamp".split(
" ",
),
weak: "sinyal".split(" "),
},
"dis-kaporta": {
strong: "ayna".split(" "),
weak: "cam camlar camlari fitil krikosu kapi kapak kapaklar mentese cikma bagaj camlik mirror glass door window sunroof exterior locking kilit yagmurluk".split(
" ",
),
},
tampon: {
strong: "tampon izgara".split(" "),
weak: "grill cekme".split(" "),
},
"panjur-panel": {
strong: "panjur kaput kaporta davlumbaz marspiyel camurluk karoser karo cerceve".split(" "),
// "sasi"/"body"/"chassis" weak so system words (electrical…) win in phrases
// like "BODY ELECTRICAL"; "süspansiyon, şasi" also stays a clean süspansiyon.
weak: "panel tavan sac canak direk sasi govde kanat kiris body chassis roof wall underpan".split(
" ",
),
},
"ic-doseme": {
// "koltugu" = seat with possessive suffix (koltuk→koltuğu, k→ğ mutation
// breaks the prefix match, so list it explicitly).
strong:
"koltuk koltugu doseme sirtlik arkalik guneslik minder torpido konsol konfor hali seat seats trim restraint occupant".split(
" ",
),
weak: "kaplama zemin baslik kemer emniyet panel bardaklik dayama dolgu floor covering interior ortu halilar".split(
" ",
),
},
"yaglar-sivilar": {
strong: "antifriz gres adblue".split(" "),
// "yag" (3 chars) misses "yagi/yaglar/yagli" under the >=4 prefix rule.
weak: "yag yagi yaglar yagli yaglama hidrolik sprey katki madeni sivi oil yapistirici".split(
" ",
),
},
"jant-lastik": {
strong: "jant lastik tekerlek gobek stepne wheel tyre tire".split(" "),
weak: "hub".split(" "),
},
aksesuar: {
strong: "aksesuar accessory accessories".split(" "),
weak: [],
},
};
// Deterministic tie-break for exactly-equal scores: earlier = more specific.
export const PRIORITY: string[] = [
"atesleme-sistemi",
"triger-zincir-rulman",
"filtreler",
"fren",
"debriyaj",
"sanziman",
"aydinlatma",
"ic-doseme",
"sogutma",
"yakit-sistemi",
"yaglar-sivilar",
"direksiyon",
"suspansiyon",
"jant-lastik",
"tampon",
"panjur-panel",
"dis-kaporta",
"elektrik",
"motor",
// Catch-all last: a name with any real part keyword wins over "aksesuar".
"aksesuar",
];
// Model-code prefix: Opel j82/c06/tf0/b16. 1-3 letters + 2 digits at start.
const MODEL_CODE = /^[a-z]{1,3}\d{2}\b/;
// VW/OEM VIN prefixes (wvwzzz…, wvgzzz…) — decode markers, not categories.
const VIN_LIKE = /\b[a-z]{2,3}zzz|w[a-z]{2}\d{3,}/;
// Pure "YYYY - YYYY" range with little else = a year/model selector node.
const YEAR_RANGE_ONLY = /^\D*(19|20)\d{2}\s*-\s*(19|20)\d{2}\D*$/;
const YEAR_RANGE_ANY = /\b(19|20)\d{2}\s*-\s*(19|20)\d{2}\b/;
// Bare engine/order code like "z16dt", "z16dt[lfu]" — a single token with digits.
const CODE_TOKEN = /^[a-z]{1,3}\d{1,3}[a-z]{0,4}(\[[a-z]+\])?$/;
// Mercedes BARE catalog codes only ("C442", "C105011"). Anchored so descriptor
// nodes that also carry a part name ("D765300(22H), LS 7 F, MB SERVO DİREKSİYON"
// → direksiyon) still map via keyword instead of being wrongly dropped.
const MB_CATALOG_CODE = /^[cd]\d{3,}$/;
// MAN truck model descriptors: "12.155 STAR", "10/11.150 HOCL/R", "18T HOCL-SD".
// (Bare "14250"-style codes are NOT matched — ^\d{4,}/^\d{4,5} would hit 362 pl24
// + 2800 emex mapped rows.)
const TRUCK_MODEL_CODE = /^\d{1,2}[./]\d|^\d{2}t[ -]/i;
// MAN bus/truck model + coachbuilder names (LION'S CITY, SKYLINER, CITYLINER,
// NL 202, ND 313, NG 242, MAN 292, AUFBAU CAETANO). Vehicle selectors, not part
// categories — verified 0 emex/pcat mapped rows collide; the few pl24 hits are
// LION'S/LPG models that had wrongly matched a part keyword.
const MAN_MODEL_CODE =
/^(nd|ng|nl|ngt|man|el|gs|le|sg|sl|sr|sue|uel|rh|nm|nmt|nue|sd) \d|^(maz|mftpl)\b|^266m|lion|skyliner|starliner|cityliner|tourliner|trendliner|jetliner|centroliner|intercity|neoplan|sinotruk|doromat|aufbau|goeppel|gorica|joskin|neoman|caetano|newcar|new flyer|gelenk|maribor|slovenien/i;
const DOC_JUNK = new Set([
"teknik literatur talimatlar",
"aletler",
"kargo tahditi",
"kriko alet",
"preliminary remarks",
]);
// Catalog index / service / doc pseudo-pages — not part categories. Mercedes
// "SA LİSTESİ"/"…resim açıklama sayfası"; BMW "Teknik doküman", "Servis ve
// onarım kapsamları", "Value Parts&Packages servisi", "Parça Onarım Servisi".
const DOC_PAGE =
/resim aciklama|^sa listesi$|genel bakis tablosu|teknik dokuman|servis ve onarim|onarim servisi|onarim kapsam|value parts|servis aralik|ek onarim/;
export type NodeKind = "part" | "structural" | "junk";
function wordTokens(folded: string): string[] {
return folded.split(" ").filter((w) => w.length > 1);
}
/** folded = output of NORMALIZE_SQL. */
export function classifyNode(folded: string): NodeKind {
const f = folded.trim();
if (!f || f.length < 3) return "junk";
if (DOC_JUNK.has(f) || DOC_PAGE.test(f)) return "junk";
if (MB_CATALOG_CODE.test(f) || TRUCK_MODEL_CODE.test(f) || MAN_MODEL_CODE.test(f))
return "structural";
// NOTE: don't treat a bare ">>" marker as structural — real VW part names
// carry validity-date markers ("pencere camlari d >> - 14.07.2019"); VIN_LIKE
// below catches the actual VIN nodes ("wvwzzz…") without dropping real parts.
if (VIN_LIKE.test(f)) return "structural";
if (MODEL_CODE.test(f)) return "structural";
if (YEAR_RANGE_ONLY.test(f)) return "structural";
const words = wordTokens(f);
// Single bare code token (engine/order code) → selector, not a category.
if (words.length <= 1 && CODE_TOKEN.test(f)) return "structural";
// year range + very few real words → still a selector ("astra-h 2004 - 2014")
if (YEAR_RANGE_ANY.test(f) && words.length <= 4) return "structural";
return "part";
}
function tokens(folded: string): string[] {
return folded.split(/[^a-z0-9]+/).filter((t) => t.length > 1);
}
function hitCount(toks: string[], keywords: string[]): number {
let c = 0;
for (const t of toks) {
for (const k of keywords) {
if (t === k || (k.length >= 4 && t.startsWith(k))) {
c++;
break;
}
}
}
return c;
}
export interface CanonicalMatch {
slug: string | null;
ambiguous: boolean;
confidence: number;
}
/**
* Weighted keyword-map a part-category folded name to a canonical top slug.
* Score = 3·(strong hits) + 1·(weak hits) per bucket. The winner is the top
* score; a runner-up within 1 point (no strong keyword decisively separated the
* two) is flagged ambiguous. Confidence scales with the winning margin.
*/
// High-confidence PHRASE overrides, checked before the bag-of-words score.
// They fix "adjective + head-noun" cases where a strong keyword wins on the
// modifier instead of the head: "motor yağı" scores motor(3) but the head noun
// is the oil. Order doesn't matter (first match wins); keep each tight.
const OVERRIDES: [RegExp, string][] = [
// "<system> yağı/yağları" (head = oil) → fluid. END-ANCHORED so the PARTS
// "motor yağı filtresi / pompası / soğutucusu" are NOT captured (they keep
// scoring to filtre / motor / soğutma).
[/(motor|sanziman|diferansiyel|direksiyon|hidrolik|fren|torpido) yag(i|lari)$/, "yaglar-sivilar"],
// body/electric motors (window/wiper/blower), not the engine.
[/(cam|silecek|kalorifer|kaput) motor/, "elektrik"],
// timing gear/chain/belt → triger, not şanzıman via the weak "dişli".
[/zamanlama (disli|zincir|kayis|seti|gergi|kasnak)/, "triger-zincir-rulman"],
// "(IC) Motor Soğutma …" = engine COOLING; head is soğutma, "motor" is the
// subsystem qualifier (pcat's "IC Motor <system>" prefix inflates motor).
[/motor sogutma/, "sogutma"],
// "türbo şarj(lı)" = turbocharger → engine, not elektrik via "şarj" (charge).
[/turbo sarj/, "motor"],
// "(tahrik) kayışı" = drive/accessory belt → triger (belt bucket), not motor.
[/tahrik kayis/, "triger-zincir-rulman"],
];
export function mapToCanonical(folded: string): CanonicalMatch {
for (const [re, slug] of OVERRIDES) {
if (re.test(folded)) return { slug, ambiguous: false, confidence: 0.95 };
}
const toks = tokens(folded);
let bestSlug: string | null = null;
let best = 0;
let second = 0;
for (const slug of PRIORITY) {
const b = LEXICON[slug];
const score = STRONG_WEIGHT * hitCount(toks, b.strong) + WEAK_WEIGHT * hitCount(toks, b.weak);
if (score > best) {
second = best;
best = score;
bestSlug = slug;
} else if (score > second) {
second = score;
}
}
if (best === 0) return { slug: null, ambiguous: false, confidence: 0 };
const margin = best - second;
if (second === 0) return { slug: bestSlug, ambiguous: false, confidence: 0.95 };
const ambiguous = margin < 2;
const confidence = margin >= 3 ? 0.9 : margin >= 2 ? 0.8 : margin >= 1 ? 0.65 : 0.5;
return { slug: bestSlug, ambiguous, confidence };
}

View File

@@ -0,0 +1,399 @@
// Second-level (sub-category) mapping — one step finer than the 20 top buckets.
// Target leaves are curated from the otoparcasan.com retail tree (564 leaves)
// down to the high-signal, sellable part types a B2C API consumer browses by.
//
// Design (proven by the 5-bucket pilot 2026-07-05):
// • A source category name is first mapped to a TOP bucket (canonical-lexicon).
// • Within that bucket we run mapToSubcategory: ordered substring match, first
// hit wins (most-specific sub-leaf listed first). Turkish spelling variants
// matter — "kaliper" ships as "kaliyer", "hidrolik" as "hidroliği" — so both
// forms are listed.
// • A shared "donanım" (fasteners: klips/cıvata/braket…) sub catches assembly
// hardware that has no retail leaf; genuinely unresolved names stay null and
// are completed manually in the super-panel (SP).
//
// slug is globally unique (canonical_categories.slug unique index) → every sub
// slug is prefixed with its bucket ("fren-balata"). Names arrive folded by
// foldName (Turkish→ASCII, lowercased) exactly like the top lexicon.
import { foldName } from "./canonical-lexicon";
export interface SubLeaf {
slug: string; // globally-unique, bucket-prefixed
name: string; // display
kw: string[]; // folded substrings; first matching leaf (in order) wins
}
// Shared hardware leaf appended to every mechanical bucket. Kept LAST so a real
// part keyword always wins ("fren balatasi klipsi" → balata, not donanım).
const HW_KW =
"klips civata vida somun saplama perno rondela kelepce baglanti civatasi montaj kiti tespit civatasi".split(
" ",
);
function hw(bucket: string): SubLeaf {
return { slug: `${bucket}-donanim`, name: "Bağlantı ve Donanım", kw: HW_KW };
}
// bucket slug → ordered sub-leaves (most specific first). Only buckets we can
// meaningfully sub-split are listed; others serve at top level only.
export const SUB_LEXICON: Record<string, SubLeaf[]> = {
fren: [
{ slug: "fren-abs", name: "ABS Sensörü / Ünitesi", kw: ["abs", "esp"] },
{ slug: "fren-balata", name: "Balata", kw: ["balata", "pabuc", "disk balata", "pabuclu"] },
{ slug: "fren-disk", name: "Fren Diski / Ayna", kw: ["disk", "ayna", "rotor"] },
{
slug: "fren-kaliper",
name: "Kaliper",
kw: ["kaliper", "kaliyer", "kundak", "kaliper kundagi"],
},
{ slug: "fren-kampana", name: "Kampana", kw: ["kampana"] },
{
slug: "fren-merkez",
name: "Ana Merkez / Silindir",
kw: ["ana merkez", "merkez", "silindir", "master"],
},
{ slug: "fren-guclendirici", name: "Fren Güçlendirici (Servo)", kw: ["guclendirici", "servo"] },
{
slug: "fren-hidrolik",
name: "Hortum / Boru / Hidrolik",
kw: ["hortum", "hidrol", "boru", "borusu"],
},
{ slug: "fren-elfren", name: "El Freni", kw: ["el fren", "el-fren", "park fren"] },
{
slug: "fren-musur",
name: "Fren Müşürü / İkaz",
kw: ["musur", "ikaz", "limitor", "fren lambasi anahtari"],
},
{ slug: "fren-vakum", name: "Vakum Pompası", kw: ["vakum"] },
{ slug: "fren-pedal", name: "Fren Pedalı", kw: ["pedal"] },
hw("fren"),
],
debriyaj: [
{ slug: "debriyaj-balata", name: "Debriyaj Balatası", kw: ["balata"] },
{ slug: "debriyaj-baski", name: "Baskı", kw: ["baski"] },
{ slug: "debriyaj-set", name: "Debriyaj Seti", kw: ["set", "seti", "kit"] },
{ slug: "debriyaj-rulman", name: "Debriyaj Rulmanı", kw: ["rulman", "bilya"] },
{
slug: "debriyaj-merkez",
name: "Debriyaj Merkezi / Hidrolik",
kw: ["merkez", "silindir", "serbest birakma", "hidrol"],
},
{ slug: "debriyaj-volan", name: "Volan", kw: ["volan"] },
{ slug: "debriyaj-catal", name: "Çatal / Bilya", kw: ["catal", "catali"] },
{ slug: "debriyaj-tel", name: "Debriyaj Teli / Pedalı", kw: ["tel", "teli", "pedal", "musur"] },
hw("debriyaj"),
],
motor: [
{
slug: "motor-silindirkapak",
name: "Silindir Kapağı",
kw: ["silindir kapak", "kapak contasi", "silindir kapagi"],
},
{ slug: "motor-piston", name: "Piston / Segman / Gömlek", kw: ["piston", "segman", "gomlek"] },
{
slug: "motor-krank",
name: "Krank / Biyel",
kw: ["krank", "biyel", "kol yatak", "ana yatak"],
},
{
slug: "motor-supap",
name: "Supap / Külbütör",
kw: ["supap", "subap", "kulbutor", "eksantrik", "tapet", "gayd"],
},
{ slug: "motor-conta", name: "Conta", kw: ["conta"] },
{ slug: "motor-kece", name: "Keçe", kw: ["kece"] },
{ slug: "motor-karter", name: "Karter", kw: ["karter"] },
{ slug: "motor-yagpompa", name: "Yağ Pompası", kw: ["yag pompa", "yaglama", "yag musur"] },
{ slug: "motor-takoz", name: "Motor Takozu", kw: ["takoz"] },
{ slug: "motor-turbo", name: "Turbo", kw: ["turbo"] },
{ slug: "motor-manifold", name: "Manifold", kw: ["manifold"] },
{
slug: "motor-komple",
name: "Motor Komple / Blok / Takım",
kw: ["komple", "blok", "motor bloku", "motor takimi"],
},
hw("motor"),
],
"yakit-sistemi": [
{ slug: "yakit-enjektor", name: "Enjektör", kw: ["enjektor", "enjeksiyon"] },
{ slug: "yakit-pompa", name: "Yakıt Pompası", kw: ["pompa", "pompasi", "otomatik"] },
{ slug: "yakit-kelebek", name: "Gaz Kelebeği", kw: ["gaz kelebegi", "kelebek"] },
{ slug: "yakit-depo", name: "Yakıt Deposu / Şamandıra", kw: ["depo", "deposu", "samandira"] },
{ slug: "yakit-boru", name: "Yakıt Borusu", kw: ["boru", "borusu", "rail"] },
{ slug: "yakit-egr", name: "EGR", kw: ["egr"] },
{ slug: "yakit-karburator", name: "Karbüratör", kw: ["karburator", "karbur"] },
{
slug: "yakit-egzoz",
name: "Egzoz / Lambda",
kw: ["egzoz", "susturucu", "katalitik", "konvertor", "lambda"],
},
hw("yakit-sistemi"),
],
"atesleme-sistemi": [
{ slug: "atesleme-buji", name: "Buji", kw: ["buji", "isitma buji", "kizdirma"] },
{ slug: "atesleme-bobin", name: "Ateşleme Bobini", kw: ["bobin"] },
{ slug: "atesleme-kablo", name: "Buji Kablosu", kw: ["kablo", "kablosu"] },
{ slug: "atesleme-distributor", name: "Distribütör", kw: ["distributor", "distribu"] },
],
"triger-zincir-rulman": [
{ slug: "triger-kayis", name: "Triger Kayışı / Seti", kw: ["kayis", "kayisi", "seti"] },
{ slug: "triger-zincir", name: "Triger Zinciri", kw: ["zincir", "zinciri"] },
{ slug: "triger-gergi", name: "Gergi / Rulman", kw: ["gergi", "rulman", "bilya"] },
],
sogutma: [
{
slug: "sogutma-radyator",
name: "Radyatör / Soğutucu",
kw: ["su radyator", "radyator", "radyatoru", "sogutucu"],
},
{ slug: "sogutma-termostat", name: "Termostat", kw: ["termostat"] },
{
slug: "sogutma-supompa",
name: "Devirdaim / Su Pompası",
kw: ["su pompa", "devirdaim", "devir daim"],
},
{
slug: "sogutma-klima",
name: "Klima (Kompresör/Radyatör)",
kw: ["klima", "kompresor", "kondansator", "evaporator", "buharlastirici", "kurutucu"],
},
{
slug: "sogutma-kalorifer",
name: "Kalorifer / Havalandırma / Isıtma",
kw: ["kalorifer", "isitici", "heater", "havalandirma", "isitma", "hava kanal", "esanjor"],
},
{ slug: "sogutma-fan", name: "Fan / Pervane", kw: ["fan", "pervane", "vantilator", "uflec"] },
{ slug: "sogutma-hortum", name: "Su Hortumu", kw: ["hortum", "hortumu"] },
{ slug: "sogutma-musur", name: "Hararet / Fan Müşürü", kw: ["musur", "hararet", "sicaklik"] },
{ slug: "sogutma-intercooler", name: "Intercooler / Turbo Radyatör", kw: ["intercooler"] },
hw("sogutma"),
],
filtreler: [
{ slug: "filtre-yag", name: "Yağ Filtresi", kw: ["yag filtre", "yag filtresi"] },
{ slug: "filtre-hava", name: "Hava Filtresi", kw: ["hava filtre", "hava filtresi"] },
{
slug: "filtre-yakit",
name: "Yakıt Filtresi",
kw: ["yakit filtre", "mazot filtre", "yakit filtresi"],
},
{ slug: "filtre-polen", name: "Polen / Kabin Filtresi", kw: ["polen", "kabin", "partikul"] },
{ slug: "filtre-diger", name: "Diğer Filtre", kw: ["filtre", "filter"] },
],
suspansiyon: [
{
slug: "susp-amortisor",
name: "Amortisör",
kw: ["amortisor", "amortisor takoz", "amortisor rulman", "koruk", "absorber"],
},
{ slug: "susp-salincak", name: "Salıncak", kw: ["salincak", "kontrol kol"] },
{ slug: "susp-rotil", name: "Rotil", kw: ["rotil"] },
{ slug: "susp-rot", name: "Rot / Rot Başı", kw: ["rot basi", "rot mili", "rot"] },
{
slug: "susp-viraj",
name: "Viraj Demiri / Z Rot / Burkma",
kw: ["viraj", "stabiliz", "z rot", "denge", "burkma"],
},
{ slug: "susp-helezon", name: "Helezon Yay", kw: ["helezon", "yay"] },
{ slug: "susp-rulman", name: "Aks / Porya Rulmanı", kw: ["rulman", "porya", "bilya"] },
{ slug: "susp-aks", name: "Aks / Aktarma", kw: ["aks", "koru", "kafasi", "mafsal", "dingil"] },
{ slug: "susp-burc", name: "Burç / Takoz / Pim", kw: ["burc", "takoz", "pim"] },
{ slug: "susp-bijon", name: "Bijon", kw: ["bijon"] },
hw("suspansiyon"),
],
direksiyon: [
{ slug: "direksiyon-simit", name: "Direksiyon Simidi", kw: ["simit", "simidi"] },
{
slug: "direksiyon-pompa",
name: "Direksiyon Pompası / Hidrolik",
kw: ["pompa", "pompasi", "hidrol"],
},
{
slug: "direksiyon-kutu",
name: "Direksiyon Kutusu / Dişli / Kremayer",
kw: ["kutu", "kremayer", "disli", "dislisi"],
},
{ slug: "direksiyon-airbag", name: "Airbag", kw: ["airbag", "hava yastigi", "yastigi"] },
{ slug: "direksiyon-koruk", name: "Direksiyon Körüğü", kw: ["koruk", "korugu"] },
{
slug: "direksiyon-elektronik",
name: "Kumanda / Sensör",
kw: ["kumanda", "sensor", "sensorleri", "unite"],
},
{
slug: "direksiyon-mil",
name: "Direksiyon Mili / Boğaz / Göbek",
kw: ["mil", "kol", "rot", "bogaz", "gobek", "dingil"],
},
],
sanziman: [
{
slug: "sanziman-vites",
name: "Vites / İç Kontroller",
kw: ["vites", "kol korugu", "topuz", "ic kontrol"],
},
{ slug: "sanziman-takoz", name: "Şanzıman Takozu", kw: ["takoz"] },
{ slug: "sanziman-saft", name: "Şaft / Mafsal", kw: ["saft", "mafsal", "istavroz", "kaplin"] },
{ slug: "sanziman-diferansiyel", name: "Diferansiyel", kw: ["diferansiyel", "difransiyel"] },
{
slug: "sanziman-sensor",
name: "Hız / Km Sensörü",
kw: ["hiz sensor", "kilometre", "km", "geri vites musur"],
},
{ slug: "sanziman-karter", name: "Şanzıman Karteri / Yağ", kw: ["karter", "yag"] },
hw("sanziman"),
],
elektrik: [
{ slug: "elektrik-aku", name: "Akü", kw: ["aku", "akusu"] },
{ slug: "elektrik-alternator", name: "Alternatör", kw: ["alternator", "sarj dinamo"] },
{ slug: "elektrik-mars", name: "Marş Motoru", kw: ["mars", "mars dinamo", "mars otomat"] },
{ slug: "elektrik-sensor", name: "Sensör", kw: ["sensor", "verici", "sender", "pulse"] },
{ slug: "elektrik-sigorta", name: "Sigorta / Röle", kw: ["sigorta", "role"] },
{
slug: "elektrik-motor",
name: "Cam / Silecek Motoru",
kw: ["cam motor", "silecek motor", "kalorifer motor", "cam kolu", "cam kaldirma"],
},
{ slug: "elektrik-silecek", name: "Silecek", kw: ["silecek"] },
{ slug: "elektrik-korna", name: "Korna", kw: ["korna"] },
{ slug: "elektrik-kontak", name: "Kontak / Marş Anahtarı", kw: ["kontak", "anahtar", "dugme"] },
{
slug: "elektrik-modul",
name: "Modül / Gösterge / Elektronik",
kw: ["modul", "beyin", "ecu", "kontrol unite", "kumanda", "gosterge", "anten"],
},
{
slug: "elektrik-kablo",
name: "Kablo / Tesisat",
kw: ["kablo", "tesisat", "soket", "demeti"],
},
hw("elektrik"),
],
aydinlatma: [
{ slug: "ayd-far", name: "Far", kw: ["far", "gunduz far", "xenon", "led far"] },
{ slug: "ayd-stop", name: "Stop Lambası", kw: ["stop", "arka lamba", "geri vites lamba"] },
{ slug: "ayd-sinyal", name: "Sinyal", kw: ["sinyal", "flasor"] },
{ slug: "ayd-sis", name: "Sis Farı", kw: ["sis"] },
{ slug: "ayd-plaka", name: "Plaka Lambası", kw: ["plaka"] },
{ slug: "ayd-ic", name: "İç / Tavan Lambası", kw: ["tavan", "ic aydinlatma", "harita"] },
{ slug: "ayd-ampul", name: "Ampul", kw: ["ampul"] },
],
"dis-kaporta": [
{ slug: "disk-kapi", name: "Kapı / Menteşe / Kilit", kw: ["kapi", "mentese", "kilit"] },
{ slug: "disk-ayna", name: "Dikiz Aynası", kw: ["ayna", "dikiz"] },
{ slug: "disk-cam", name: "Cam / Kriko / Fitil", kw: ["cam", "kriko", "fitil"] },
{ slug: "disk-bagaj", name: "Bagaj Kapağı", kw: ["bagaj"] },
{ slug: "disk-kaput", name: "Motor Kaputu", kw: ["kaput", "kaputu"] },
{ slug: "disk-kol", name: "Kapı Kolu", kw: ["kol", "kolu"] },
],
"panjur-panel": [
{ slug: "panjur-camurluk", name: "Çamurluk / Kanat", kw: ["camurluk", "kanat"] },
{ slug: "panjur-kaput", name: "Kaput / Kaporta", kw: ["kaput", "kaporta"] },
{ slug: "panjur-tavan", name: "Tavan", kw: ["tavan"] },
{ slug: "panjur-sasi", name: "Şasi / Kiriş / Alt Panel", kw: ["sasi", "kiris", "alt panel"] },
{ slug: "panjur-cam", name: "Cam / Karo", kw: ["cam", "karo"] },
{ slug: "panjur-panjur", name: "Panjur / Ön Panel", kw: ["panjur", "panel", "izgara"] },
{ slug: "panjur-marspiyel", name: "Marşpiyel", kw: ["marspiyel"] },
{
slug: "panjur-farcerceve",
name: "Far Çerçevesi / Sacı",
kw: ["far cerceve", "far kasi", "far saci", "far yuva"],
},
{ slug: "panjur-travers", name: "Travers / Takviye", kw: ["travers", "takviye"] },
{
slug: "panjur-etek",
name: "Etek Sacı / Muhafaza",
kw: ["etek", "muhafaza", "pacalik", "davlumbaz"],
},
],
tampon: [
{ slug: "tampon-tampon", name: "Tampon", kw: ["tampon"] },
{ slug: "tampon-demir", name: "Tampon Demiri / Travers", kw: ["demir", "demiri", "travers"] },
{ slug: "tampon-izgara", name: "Izgara / Panjur", kw: ["izgara", "panjur"] },
{ slug: "tampon-braket", name: "Braket / Sac", kw: ["braket", "sac", "muhafaza"] },
{ slug: "tampon-cita", name: ıta / Kaplama", kw: ["cita", "kaplama", "nikelaj"] },
],
"ic-doseme": [
{ slug: "ic-koltuk", name: "Koltuk / Kılıf", kw: ["koltuk", "kilif", "minder", "sirtlik"] },
{ slug: "ic-torpido", name: "Torpido / Konsol", kw: ["torpido", "konsol"] },
{ slug: "ic-tavan", name: "Tavan Döşeme / Güneşlik", kw: ["tavan", "guneslik"] },
{ slug: "ic-kemer", name: "Emniyet Kemeri", kw: ["kemer", "emniyet"] },
{ slug: "ic-kolcak", name: "Kolçak / Bardaklık", kw: ["kolcak", "bardaklik"] },
{ slug: "ic-paspas", name: "Paspas / Halı", kw: ["paspas", "hali"] },
],
"yaglar-sivilar": [
{ slug: "yag-motor", name: "Motor Yağı", kw: ["motor yag"] },
{
slug: "yag-sanziman",
name: "Şanzıman / Dif. Yağı",
kw: ["sanziman yag", "diferansiyel yag", "torpido yag"],
},
{ slug: "yag-fren", name: "Fren Hidroliği", kw: ["fren hidrolik", "hidrolik yag"] },
{ slug: "yag-direksiyon", name: "Direksiyon Yağı", kw: ["direksiyon yag"] },
{ slug: "yag-antifriz", name: "Antifriz", kw: ["antifriz"] },
{ slug: "yag-gres", name: "Gres", kw: ["gres"] },
{ slug: "yag-adblue", name: "AdBlue", kw: ["adblue", "ad blue"] },
{ slug: "yag-katki", name: "Katkı / Sprey", kw: ["katki", "sprey", "temizleyici", "cila"] },
// oil-SYSTEM hardware that lands in this bucket via "yağ" (pump/dipstick/
// line/lubrication) — no fluid leaf fits, so its own leaf.
{
slug: "yag-sistemi",
name: "Yağlama Sistemi (Pompa/Tava/Keçe)",
kw: [
"yaglama",
"yag pompa",
"yag cubugu",
"yag boru",
"yag karter",
"yag tava",
"yag kece",
"yag basinc",
"gonderici",
"pnomatik",
"hidrolik",
],
},
],
"jant-lastik": [
{ slug: "jant-jant", name: "Jant", kw: ["jant"] },
// slug "jant-tire" (not "jant-lastik") — the latter collides with the top
// bucket slug and the seed's ON CONFLICT would drop it.
{ slug: "jant-tire", name: "Lastik", kw: ["lastik", "tyre", "tire"] },
{ slug: "jant-kapak", name: "Jant Kapağı / Bijon", kw: ["kapak", "bijon", "logo"] },
{ slug: "jant-sibop", name: "Sibop", kw: ["sibop"] },
{ slug: "jant-rulman", name: "Teker Rulmanı / Göbek", kw: ["rulman", "gobek", "porya", "hub"] },
{ slug: "jant-zincir", name: "Kar Zinciri", kw: ["zincir", "kar corabi"] },
],
};
const NORMALIZE = (s: string) => foldName(s);
// Map a folded category name to a sub-leaf within its top bucket. Returns the
// sub-leaf slug or null (→ served at top level, completed manually in SP).
export function mapToSubcategory(rawName: string, bucketSlug: string): string | null {
const leaves = SUB_LEXICON[bucketSlug];
if (!leaves) return null;
const n = NORMALIZE(rawName);
if (!n) return null;
for (const leaf of leaves) {
for (const k of leaf.kw) {
if (n.includes(k)) return leaf.slug;
}
}
return null;
}
// All sub-leaves flattened, with their parent bucket slug — used by the seed
// migration builder and the backfill materializer.
export function allSubLeaves(): Array<{
bucket: string;
slug: string;
name: string;
sort: number;
}> {
const out: Array<{ bucket: string; slug: string; name: string; sort: number }> = [];
for (const [bucket, leaves] of Object.entries(SUB_LEXICON)) {
leaves.forEach((l, i) => out.push({ bucket, slug: l.slug, name: l.name, sort: i + 1 }));
}
return out;
}

View File

@@ -4,7 +4,13 @@ import { CategoriesModule } from "../categories/categories.module";
import { isLifecycleEmailEnabled } from "./prefetch-utils";
import { PrefetchWorkerService } from "./prefetch-worker.service";
import {
CANONICAL_BACKFILL_QUEUE,
CanonicalBackfillQueueProvider,
} from "./queues/canonical-backfill.queue";
import {
CATALOG_PREFETCH_FAST_QUEUE,
CATALOG_PREFETCH_QUEUE,
CatalogPrefetchFastQueueProvider,
CatalogPrefetchQueueProvider,
} from "./queues/catalog-prefetch.queue";
import { EMEX_SCRAPE_QUEUE, EmexScrapeQueueProvider } from "./queues/emex-scrape.queue";
@@ -28,10 +34,12 @@ import { VINPIN_DECODE_QUEUE, VinpinDecodeQueueProvider } from "./queues/vinpin-
SubscriptionExpiryQueueProvider,
QueryCleanupQueueProvider,
CatalogPrefetchQueueProvider,
CatalogPrefetchFastQueueProvider,
LifecycleEmailQueueProvider,
ExpertRewardsQueueProvider,
PartPriceRefreshQueueProvider,
VinpinDecodeQueueProvider,
CanonicalBackfillQueueProvider,
PrefetchWorkerService,
],
exports: [
@@ -39,10 +47,12 @@ import { VINPIN_DECODE_QUEUE, VinpinDecodeQueueProvider } from "./queues/vinpin-
SUBSCRIPTION_EXPIRY_QUEUE,
QUERY_CLEANUP_QUEUE,
CATALOG_PREFETCH_QUEUE,
CATALOG_PREFETCH_FAST_QUEUE,
LIFECYCLE_EMAIL_QUEUE,
EXPERT_REWARDS_QUEUE,
PART_PRICE_REFRESH_QUEUE,
VINPIN_DECODE_QUEUE,
CANONICAL_BACKFILL_QUEUE,
],
})
export class JobsModule implements OnModuleInit, OnModuleDestroy {
@@ -53,6 +63,7 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
@Inject(LIFECYCLE_EMAIL_QUEUE) private lifecycleEmailQueue: Queue,
@Inject(EXPERT_REWARDS_QUEUE) private expertRewardsQueue: Queue,
@Inject(PART_PRICE_REFRESH_QUEUE) private partPriceRefreshQueue: Queue,
@Inject(CANONICAL_BACKFILL_QUEUE) private canonicalBackfillQueue: Queue,
) {}
async onModuleInit() {
@@ -162,6 +173,31 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
},
);
console.log("[jobs] Registered part-price-refresh cron: 30 19 * * * (Europe/Istanbul)");
// Kanonik taksonomi tazeleme: her gün 05:00 İstanbul (gecelik trafik
// sonrası, decode edilen yeni araçların kategorileri materyalize edilsin).
// incremental=true → yalnız o günün YENİ kategori adları sınıflandırılır
// (tüm ~113k değil); materialize adımı differential, inheritance NULL-only,
// is_manual satırları korunur. Serving zaten anlık lexicon fallback yapıyor,
// bu cron materyalizasyonu güncel tutar (istek-başı hesap yükünü ve DB
// doğruluğunu iyileştirir). Dev + prod'da güvenle çalışır (harici yan-etki
// yok, kendi DB'sine yazar). Lexicon iyileştirmesi sonrası tam yeniden
// eşleme hâlâ ELLE `{ reset: true }` global run'la yapılır.
await this.canonicalBackfillQueue.upsertJobScheduler(
"canonical-backfill-daily",
{ pattern: "0 5 * * *", tz: "Europe/Istanbul" },
{
name: "canonical-backfill-run",
data: { incremental: true },
opts: {
removeOnComplete: { count: 14 },
removeOnFail: { count: 50 },
},
},
);
console.log(
"[jobs] Registered canonical-backfill cron: 0 5 * * * (Europe/Istanbul, incremental)",
);
}
async onModuleDestroy() {
@@ -172,6 +208,7 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
this.lifecycleEmailQueue.close(),
this.expertRewardsQueue.close(),
this.partPriceRefreshQueue.close(),
this.canonicalBackfillQueue.close(),
]);
}
}

View File

@@ -17,17 +17,30 @@ function makeDb(limitResults: unknown[][]) {
}
function makeDeps(opts: { waiting: number; limitResults: unknown[][] }) {
const queue = {
// A queue double: getJob returns null (nothing deduped) and the ioredis client
// stub answers the delayed-ZSET zcount the pressure gate takes.
const makeQueue = (name: string, waiting: number) => ({
name,
// typed args so `.mock.calls[i]` is `unknown[]` (not a 0-length tuple) — the
// production `nest build` compiles spec files and rejects tuple-index access.
add: vi.fn((..._args: unknown[]) => Promise.resolve(undefined)),
getJobCounts: vi.fn(async () => ({ waiting: opts.waiting, delayed: 0, active: 0 })),
};
getJob: vi.fn(async (..._args: unknown[]): Promise<unknown> => null),
getJobCounts: vi.fn(async (..._args: unknown[]) => ({
waiting,
delayed: 0,
active: 0,
prioritized: 0,
})),
toKey: (t: string) => `bull:${name}:${t}`,
client: Promise.resolve({ zcount: vi.fn(async (..._args: unknown[]) => 0) }),
});
const queue = makeQueue("catalog-prefetch", opts.waiting);
const redis = {
// typed args (like queue.add) so mockImplementation((k)=>…) type-checks under
// the production nest build, which compiles spec files.
exists: vi.fn(async (..._args: unknown[]) => false), // no cooldown / guard / complete marker
get: vi.fn(async () => null), // no no-result residue
// null → no no-result residue AND daily budget counters read as 0 (unspent).
get: vi.fn(async (..._args: unknown[]): Promise<string | null> => null),
set: vi.fn(async (..._args: unknown[]) => undefined),
del: vi.fn(async (..._args: unknown[]) => undefined),
incr: vi.fn(async (..._args: unknown[]) => 1), // per-source rate window counter
@@ -39,14 +52,17 @@ function makeDeps(opts: { waiting: number; limitResults: unknown[][] }) {
};
const posthog = { payload: vi.fn(async () => ({})) }; // compiled-in defaults
const db = makeDb(opts.limitResults);
const fastQueue = makeQueue("catalog-prefetch-fast", 0);
const service = new PrefetchWorkerService(
queue as never,
fastQueue as never,
{} as never, // categoriesService — unused by the scan
redis as never,
posthog as never,
db as never,
);
return { service, queue, redis, posthog, db };
return { service, queue, fastQueue, redis, posthog, db };
}
describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
@@ -59,12 +75,12 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
});
describe("enqueueInit", () => {
it("sets lifo + data.fast when fast", async () => {
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
it("sets lifo + data.fast when fast (fast queue)", async () => {
const { service, fastQueue } = makeDeps({ waiting: 0, limitResults: [] });
await (
service as never as { enqueueInit: (a: string, b: string, c: boolean) => Promise<void> }
).enqueueInit("v1", "emex", true);
const [name, data, jobOpts] = queue.add.mock.calls[0];
const [name, data, jobOpts] = fastQueue.add.mock.calls[0];
expect(name).toBe("prefetch-init");
expect(data).toMatchObject({ vehicleId: "v1", source: "emex", fast: true });
expect(jobOpts).toMatchObject({ lifo: true });
@@ -82,8 +98,8 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
});
describe("addJob (child jobs)", () => {
it("sets lifo when data.fast is true", async () => {
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
it("sets lifo when data.fast is true (fast queue)", async () => {
const { service, fastQueue: queue } = makeDeps({ waiting: 0, limitResults: [] });
await (service as never as { addJob: (n: string, d: unknown) => Promise<void> }).addJob(
"prefetch-children",
{
@@ -114,7 +130,11 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
it("runs Phase-1 in the fast lane even when the backlog is far over the ceiling", async () => {
// waiting 50k ≫ default maxBacklog 1000 → Phase-2 must be suspended, but a
// zero-parts vehicle must still be onboarded with lifo.
const { service, queue, redis } = makeDeps({
const {
service,
fastQueue: queue,
redis,
} = makeDeps({
waiting: 50_000,
limitResults: [[{ id: "empty1", source: "emex" }]], // Phase-1 query result
});
@@ -183,13 +203,17 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
describe("runBackfillScan (in-process timer entry, NX-locked)", () => {
it("runs the scan when the lock is acquired", async () => {
const { service, queue, redis } = makeDeps({
const {
service,
fastQueue: queue,
redis,
} = makeDeps({
waiting: 50_000,
limitResults: [[{ id: "empty1", source: "emex" }]],
});
await (service as never as { runBackfillScan: () => Promise<void> }).runBackfillScan();
expect(redis.setNx).toHaveBeenCalledWith("prefetch:backfill:lock", "1", expect.any(Number));
expect(queue.add).toHaveBeenCalledTimes(1); // Phase-1 init enqueued
expect(queue.add).toHaveBeenCalledTimes(1); // Phase-1 init enqueued (fast lane)
});
it("skips the scan when the lock is already held", async () => {
@@ -227,11 +251,10 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
describe("poison guard (Faz 6: category cap — brand-agnostic anti-explosion)", () => {
type PC = { processChildren: (j: unknown) => Promise<void> };
it("processChildren marks poison once progress.total exceeds CATEGORY_CAP", async () => {
const { service, queue, redis } = makeDeps({ waiting: 0, limitResults: [] });
redis.getJson.mockImplementation(async (...a: unknown[]) =>
String(a[0]).includes("progress") ? { total: 5000 } : null,
);
it("processChildren marks poison once the STORED tree exceeds CATEGORY_CAP", async () => {
// The cap is now measured from the DB (categories count), not the ephemeral
// progress.total which every processInit resets to 0.
const { service, queue, redis } = makeDeps({ waiting: 0, limitResults: [[{ n: 5000 }]] });
await (service as never as PC).processChildren({
data: { vehicleId: "op1", categoryId: "c1", source: "pl24", depth: 1 },
} as never);
@@ -291,3 +314,28 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
});
});
});
// ── PL24 reaktif drill derinliği + backfill pacing (plv2 Faz 1 / adım 2b) ──
// Ban'ı süren hacim, her yeni decode'da tüm ağacın gezilmesiydi (bir Passat =
// 1.251 kategori). Fast lane artık PL24'te 1. seviyede durur; derin drill ya
// kullanıcı tıklamasıyla ya da bütçeli backfill lane'inde olur.
describe("PrefetchWorkerService — PL24 derinlik tavanı", () => {
const load = async () => {
const mod = await import("./prefetch-worker.service");
return mod as unknown as {
__testables?: { maxDepthFor(source: string, fast: boolean): number };
};
};
it("pl24 fast lane 1. seviyede durur, diğer kaynaklar tam derinlik kullanır", async () => {
// maxDepthFor modül-özel; davranışı dolaylı doğrula: env varsayılanları
process.env.PREFETCH_PL24_FAST_DEPTH = "";
process.env.PREFETCH_MAX_DEPTH = "";
const mod = await load();
const fn = mod.__testables?.maxDepthFor;
if (!fn) return; // testable export yoksa atla (davranış e2e'de doğrulanır)
expect(fn("pl24", true)).toBe(1);
expect(fn("pl24", false)).toBeGreaterThan(1);
expect(fn("parts-catalogs", true)).toBeGreaterThan(1);
});
});

View File

@@ -10,6 +10,7 @@ import { and, asc, eq, gt, inArray, isNull, notExists, sql } from "drizzle-orm";
import { CategoriesService } from "../categories/categories.service";
import { DATABASE, type Database } from "../database/database.provider";
import { categories, parts, vehicles } from "../database/schema/core";
import { isPl24LeafNode } from "../integrations/pl24/pl24-tree";
import { PostHogService } from "../posthog/posthog.service";
import { RedisService } from "../redis/redis.service";
import { QUEUE_NAMES, getBullConnection } from "./bull.config";
@@ -24,7 +25,10 @@ import {
updateProgress,
} from "./prefetch-utils";
import { PrefetchCategoryJobData, PrefetchInitJobData } from "./prefetch.types";
import { CATALOG_PREFETCH_QUEUE } from "./queues/catalog-prefetch.queue";
import {
CATALOG_PREFETCH_FAST_QUEUE,
CATALOG_PREFETCH_QUEUE,
} from "./queues/catalog-prefetch.queue";
/**
* Depth cap for the prefetch BFS — a safety ceiling, NOT the target depth.
@@ -50,8 +54,51 @@ const MAX_DEPTH = Number(process.env.PREFETCH_MAX_DEPTH) || 12;
const BACKFILL_BATCH_SIZE = 40;
/** Skip the wave entirely if the queue already has more than this many jobs pending. */
const BACKFILL_MAX_BACKLOG = 1000;
/** In-flight guard TTL (seconds) — safety net if a run dies without clearing. */
const BACKFILL_SCHEDULED_TTL = 6 * 60 * 60;
/**
* In-flight guard TTL (seconds) — safety net if a run dies without clearing.
*
* 36h, NOT 6h: a chain deferred on the daily budget lives until the next UTC
* midnight (~24h). With a 6h TTL the guard expired mid-chain, the scan re-picked
* the vehicle, processInit reset progress.total, and the in-flight jobs'
* incrementCompleted then compared `completed >= total` against the NEW total —
* corrupting completion accounting for a still-partial vehicle.
*/
const BACKFILL_SCHEDULED_TTL = Number(process.env.PREFETCH_SCHEDULED_TTL_H || 36) * 60 * 60;
/**
* Only delayed jobs due within this horizon count as queue PRESSURE.
*
* `delayed` lumps two very different things together: per-minute source-rate
* defers (<60s) and retry backoff (30/60s), which ARE load, versus daily-budget
* and off-hours defers (hours, parked on the next UTC midnight), which are merely
* THROTTLED FUTURE WORK. Counting the latter as backlog froze Phase-2 for most of
* the day (2026-08-01: delayed=11495, all pcat, all parked at 00:00:01 UTC; the
* partial drain fell to ~25 vehicles/day, ETA 79 days).
*/
const PRESSURE_HORIZON_MS = Number(process.env.PREFETCH_PRESSURE_HORIZON_MS) || 10 * 60_000;
/**
* Absolute ceiling on TOTAL pending jobs (waiting+active+delayed+prioritized)
* across BOTH lanes — the runaway backstop that `delayed` used to provide by
* accident. Excluding long defers from the pressure gate removes the only
* negative feedback on production, so the pool needs its own hard stop: Phase-2
* can otherwise produce ~288k jobs/day against ~65k/day of budget. 50k ≈ 15h of
* the combined daily budgets, so the pool always drains inside a window; 1/9 of
* the guard-less BFS runaway (470k, months to drain). Set 0 to disable Phase-2.
*/
const HARD_MAX_TOTAL_JOBS = Number(process.env.PREFETCH_MAX_TOTAL_JOBS ?? 50_000);
/**
* Rough fan-out of one Phase-2 re-drill. Admission control is done in JOB units,
* not vehicle units: 40 vehicles/wave is meaningless when one vehicle is 100-3000
* jobs (p95 tree = 744 categories).
*/
const EST_JOBS_PER_VEHICLE = Number(process.env.PREFETCH_EST_JOBS_PER_VEHICLE) || 400;
/**
* Share of each source's daily budget reserved for the FAST lane. The daily
* counter has no lane component, so backfill spending the budget also parked the
* user's fresh-decode chain until midnight. One shared counter (the total
* upstream/bandwidth ceiling stays exactly SOURCE_DAILY_MAX) but two thresholds:
* the main (backfill) lane stops at 80%, the fast lane may use 100%.
*/
const DAILY_FAST_RESERVE = 0.2;
/** Only these decode sources have catalogs worth prefetching. */
const BACKFILL_SOURCES = ["pl24", "emex", "parts-catalogs"];
/** Redis key holding the rolling rescan cursor (last createdAt seen). */
@@ -71,14 +118,14 @@ const BACKFILL_SCAN_LOCK_TTL_S = 55 * 60;
* lets a slow parts-catalogs job stop head-of-line-blocking emex. A user-active
* cooldown still pauses the whole worker, so this only speeds up idle periods.
*/
const WORKER_CONCURRENCY = Number(process.env.PREFETCH_CONCURRENCY) || 3;
const WORKER_CONCURRENCY = Number(process.env.PREFETCH_CONCURRENCY) || 8;
/**
* Global queue rate ceiling (jobs/min) across ALL sources — now a SAFETY CAP,
* not the real throttle. Per-source limits (below) do the actual pacing so a
* slow/limited source can't starve the others. Defaults to the sum of the
* per-source ceilings + headroom. Was a hard 5/min, then a shared 20/min.
*/
const WORKER_RATE_MAX = Number(process.env.PREFETCH_RATE_MAX) || 60;
const WORKER_RATE_MAX = Number(process.env.PREFETCH_RATE_MAX) || 180;
/**
* Per-source rate ceilings (jobs/min). Each source is throttled INDEPENDENTLY
* (Redis fixed-window) so PL24, EMEX and parts-catalogs run concurrently at
@@ -91,14 +138,72 @@ const SOURCE_RATE_MAX: Record<string, number> = {
emex: Number(process.env.PREFETCH_RATE_EMEX) || 20,
// pcat runs through a DataImpulse rotating gateway (74.81.81.81:10000-10999,
// ~1000 port-rotated exit IPs — verified 2026-06-25), so per-IP ban load is
// spread; safe to run well above the old shared cap. Ramp step 1 = 20/min.
"parts-catalogs": Number(process.env.PREFETCH_RATE_PCAT) || 20,
// spread; safe to run well above the old shared cap. Ramp step 2 = 90/min
// (2026-07-08: 93k backlog %89 pcat idi — 20/min + 5s pace drenaja yetmiyordu).
"parts-catalogs": Number(process.env.PREFETCH_RATE_PCAT) || 90,
};
/**
* Per-source ROLLING-DAY budget (backfill jobs/source/UTC-day). A storm guard on
* top of the per-minute ceilings: those cap burst rate but not the daily TOTAL,
* so a source running near its ceiling for many hours drains the proxy budget
* (2026-07-09: a backlog drain pushed pcat to ~74k calls / ~10 GB in one day).
* When a source hits its daily budget, further backfill jobs defer until the
* window rolls. User-facing decodes are unaffected — they don't pass through the
* worker. Only counts jobs that already cleared the per-minute gate, so
* rate-limited retries don't inflate it. 0 = unlimited. Env-tunable: raise to
* drain a backlog faster, lower to conserve proxy budget harder.
*/
const SOURCE_DAILY_MAX: Record<string, number> = {
pl24: Number(process.env.PREFETCH_DAILY_PL24) || 15_000,
emex: Number(process.env.PREFETCH_DAILY_EMEX) || 20_000,
// 45k (1.5x): the 2026-07-09 incident that created this guard was ~74k calls /
// ~10 GB in a day, so 45k ≈ 61% of that — a reversible step at ~6 GB/day. With
// DAILY_FAST_RESERVE the backfill (main) lane gets 36k, i.e. +20% over the old
// effective 30k while still leaving 9k for user decodes. Burst rate is
// UNCHANGED (SOURCE_RATE_MAX pcat 90/min), so per-IP ban pressure is the same —
// this only sustains that pace for more of the day. Rollback signal: any hour
// with >2 pcat HTTP 402 (DataImpulse quota) → back to 30_000.
"parts-catalogs": Number(process.env.PREFETCH_DAILY_PCAT) || 45_000,
};
/**
* Per-job pacing for parts-catalogs only (its browser/JWT capture is heavy).
* Set 0 to disable. Other sources are paced by the limiter + cooldown alone.
*/
const PCAT_PACE_MS = Number(process.env.PREFETCH_PCAT_DELAY_MS) || 5_000;
const PCAT_PACE_MS = Number(process.env.PREFETCH_PCAT_DELAY_MS) || 1_500;
/**
* Per-job pacing for PL24 BACKFILL jobs (main lane only — a user waiting on a
* fresh decode must never be slowed down). Both account bans followed days of
* thousands of back-to-back PL24 calls; a paced, jittered stream looks nothing
* like that. Set 0 to disable.
*/
const PL24_PACE_MS = Number(process.env.PREFETCH_PL24_DELAY_MS) || 8_000;
/**
* How deep the REACTIVE (fast-lane) drill may go for PL24.
*
* A freshly decoded vehicle used to be walked to the bottom immediately: one
* Passat produced 1,251 categories, one L200 2,323 — 1.4k-6.8k categories/day
* from 3-17 decodes, which is exactly the volume that preceded both bans
* (plv2.md §2.2). Depth 1 = top groups and their direct children; anything
* deeper is fetched lazily when the user actually opens that node, or by the
* budgeted backfill lane. Other sources keep MAX_DEPTH.
*/
const PL24_FAST_MAX_DEPTH = Number(process.env.PREFETCH_PL24_FAST_DEPTH) || 1;
/** Depth ceiling for this source+lane. */
function maxDepthFor(source: string, fast: boolean): number {
if (source === "pl24" && fast) return PL24_FAST_MAX_DEPTH;
return MAX_DEPTH;
}
/** Jittered pace so our request stream is not a metronome. */
function jitter(ms: number): number {
return Math.round(ms * (0.5 + Math.random()));
}
/** Test-only surface for the pure helpers above. */
export const __testables = { maxDepthFor, jitter };
// ── Phase-1 residue exclusion ──
/**
@@ -133,11 +238,13 @@ const POISON_TTL_S = (Number(process.env.PREFETCH_POISON_TTL_DAYS) || 30) * 86_4
export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrefetchWorkerService.name);
private worker: Worker | null = null;
private fastWorker?: Worker;
private backfillKickoff?: ReturnType<typeof setTimeout>;
private backfillInterval?: ReturnType<typeof setInterval>;
constructor(
@Inject(CATALOG_PREFETCH_QUEUE) private queue: Queue,
@Inject(CATALOG_PREFETCH_FAST_QUEUE) private fastQueue: Queue,
private categoriesService: CategoriesService,
private redis: RedisService,
private posthog: PostHogService,
@@ -167,9 +274,30 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
this.logger.error(`[prefetch] Worker error: ${err.message}`);
});
// FAST lane: own queue + worker so fresh decodes never compete with the
// deep-drill backlog (whose production>consumption regime starves lifo tails).
// Same process fn; the shared per-source redis rate gate still protects
// upstream across BOTH workers.
this.fastWorker = new Worker(
QUEUE_NAMES.CATALOG_PREFETCH_FAST,
(job, token) => backfillContext.run(true, () => this.process(job, token)),
{
connection: getBullConnection(),
concurrency: 4,
limiter: { max: 120, duration: 60_000 },
},
);
this.fastWorker.on("failed", (job, err) => {
this.logger.warn(`[prefetch:fast] Job ${job?.name} failed: ${err.message}`);
});
this.fastWorker.on("error", (err) => {
this.logger.error(`[prefetch:fast] Worker error: ${err.message}`);
});
this.logger.log("[prefetch] Fast worker started (concurrency=4, 120/min)");
this.logger.log(
`[prefetch] Worker started (concurrency=${WORKER_CONCURRENCY}, global ${WORKER_RATE_MAX}/min, ` +
`per-source ${JSON.stringify(SOURCE_RATE_MAX)}, pcatPace=${PCAT_PACE_MS}ms)`,
`per-source ${JSON.stringify(SOURCE_RATE_MAX)}, daily ${JSON.stringify(SOURCE_DAILY_MAX)}, pcatPace=${PCAT_PACE_MS}ms)`,
);
// Hourly backfill scan — run IN-PROCESS, not as a BullMQ cron job. A cron
@@ -197,6 +325,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
if (this.backfillKickoff) clearTimeout(this.backfillKickoff);
if (this.backfillInterval) clearInterval(this.backfillInterval);
if (this.worker) {
await this.fastWorker?.close();
await this.worker.close();
this.worker = null;
}
@@ -224,11 +353,27 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
job.name === "prefetch-children" ||
job.name === "prefetch-parts")
) {
await this.checkSourceRate(data.source);
const lane = (job.data as { fast?: boolean }).fast ? "fast" : "main";
await this.checkSourceRate(data.source, lane);
// Daily budget AFTER the per-minute gate: a job deferred on the minute
// ceiling above never reaches here, so rate-limited retries don't inflate
// the daily counter — only jobs about to do real work are counted. The
// lane decides which threshold applies (backfill stops at the main limit,
// the user's fast lane may use the full budget).
await this.checkSourceDailyBudget(data.source, lane);
}
if (data.source === "parts-catalogs" && PCAT_PACE_MS > 0) {
await new Promise((r) => setTimeout(r, PCAT_PACE_MS));
}
// PL24 backfill only: pace + jitter. The fast (user) lane is never delayed.
if (
data.source === "pl24" &&
PL24_PACE_MS > 0 &&
!(job.data as { fast?: boolean }).fast &&
(job.name === "prefetch-children" || job.name === "prefetch-parts")
) {
await new Promise((r) => setTimeout(r, jitter(PL24_PACE_MS)));
}
if (job.name === "backfill-scan") {
return await this.processBackfillScan();
@@ -253,7 +398,8 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
// global wait is correct: when it ends and cooldown is still active,
// we'll pause again — at most ~1 cycle per retryAfterMs instead of
// cycling every queued job through.
await this.worker?.rateLimit(delayMs);
const lane = (job.data as { fast?: boolean }).fast ? this.fastWorker : this.worker;
await lane?.rateLimit(delayMs);
this.logger.debug(`[prefetch] Worker paused ${delayMs}ms (cooldown ${job.data.source})`);
throw Worker.RateLimitError();
}
@@ -355,8 +501,9 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
for (const child of children) {
if (child.unavailable) continue;
await this.queueCategoryJob(child, vehicleId, source, 1, fast);
queued++;
// Count jobs ACTUALLY queued, not nodes walked — see addJob's doc: an
// inflated progress.total makes the chain never reach "finished".
queued += await this.queueCategoryJob(child, vehicleId, source, 1, fast);
}
} else if (this.isLeafLinkPath(cat.linkPath, cat.source, cat.hasSubgroups)) {
// Leaf — check if parts already fetched
@@ -366,32 +513,46 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
.where(eq(parts.categoryId, cat.id))
.limit(1);
if (!partCheck && cat.linkPath) {
await this.addJob("prefetch-parts", {
if (
!partCheck &&
cat.linkPath &&
(await this.addJob("prefetch-parts", {
vehicleId,
categoryId: cat.id,
source,
action: "parts" as const,
depth: 0,
fast,
});
}))
) {
queued++;
}
} else if (cat.linkPath) {
} else if (
cat.linkPath &&
// Non-leaf without children — needs children fetch
await this.addJob("prefetch-children", {
(await this.addJob("prefetch-children", {
vehicleId,
categoryId: cat.id,
source,
action: "children" as const,
depth: 0,
fast,
});
}))
) {
queued++;
}
}
await updateProgress(this.redis, vehicleId, { total: queued });
if (queued === 0) {
// Nothing left to fetch — the tree is already complete in DB. Without this
// there is no job to fire incrementCompleted, so progress sits at {0,0}, the
// vehicle is never marked fullyFetched, and the scan re-picks it every wave
// forever while burning a daily-budget slot each time.
this.logger.log(`[prefetch] Nothing to queue for vehicle=${vehicleId} — already complete`);
await this.finalizeVehicle(vehicleId);
return;
}
this.logger.log(`[prefetch] Queued ${queued} sub-jobs for vehicle=${vehicleId}`);
}
@@ -405,19 +566,30 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
await checkCooldown(this.redis, source);
checkTimeWindow(source);
if (depth >= MAX_DEPTH) {
this.logger.warn(`[prefetch] Max depth reached for category=${categoryId}`);
const depthCeiling = maxDepthFor(source, fast);
if (depth >= depthCeiling) {
// For the PL24 fast lane this is the normal stopping point, not a problem:
// deeper nodes are drilled lazily on user click or by the backfill lane.
const level = source === "pl24" && fast ? "log" : "warn";
this.logger[level](
`[prefetch] Depth ceiling ${depthCeiling} reached for category=${categoryId} (source=${source}, lane=${fast ? "fast" : "main"})`,
);
return;
}
// Anti-poison cap: progress.total tracks every sub-job queued for this vehicle
// (≈ its discovered tree size). Once it blows past the ceiling the vehicle is a
// generic-catalog explosion — stop drilling and mark it poison so the rest of
// its (part-less) tree is never fetched and it's never re-picked.
const prog = await this.redis.getJson<{ total: number }>(`prefetch:progress:${vehicleId}`);
if ((prog?.total ?? 0) >= CATEGORY_CAP) {
// Anti-poison cap measured from the DB, NOT from progress.total: the ephemeral
// counter is reset to 0 by every processInit, so a re-picked vehicle could
// drill another CATEGORY_CAP nodes per round and never trip the guard (the
// generic-ROOT Opels: 420k/102k/21k categories, ZERO parts). The stored tree
// is the real, cumulative size.
const [capRow] = await this.db
.select({ n: sql<number>`count(*)::int` })
.from(categories)
.where(eq(categories.vehicleId, vehicleId))
.limit(1);
if ((capRow?.n ?? 0) >= CATEGORY_CAP) {
this.logger.warn(
`[prefetch] Category cap ${CATEGORY_CAP} hit for vehicle=${vehicleId} (tree${prog?.total}) — marking poison, stop drilling`,
`[prefetch] Category cap ${CATEGORY_CAP} hit for vehicle=${vehicleId} (tree=${capRow?.n}) — marking poison, stop drilling`,
);
await this.markPoison(vehicleId);
return;
@@ -429,8 +601,9 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
let queued = 0;
for (const child of children) {
if (child.unavailable) continue;
await this.queueCategoryJob(child, vehicleId, source, depth + 1, fast);
queued++;
// Real queued-job count (see addJob) — walking a node that dedupes or
// already has parts must not inflate progress.total.
queued += await this.queueCategoryJob(child, vehicleId, source, depth + 1, fast);
}
if (queued > 0) {
@@ -509,19 +682,61 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
// genuinely-empty vehicles keep getting onboarded through the fast lane even
// while a large deep-drill backlog is still draining — otherwise a single
// backlog spike freezes new-vehicle coverage until the whole queue clears.
const counts = await this.queue.getJobCounts("waiting", "delayed", "active");
const backlog = (counts.waiting ?? 0) + (counts.delayed ?? 0) + (counts.active ?? 0);
const phase2Allowed = backlog <= maxBacklog;
// Two INDEPENDENT ceilings (Phase-1 fast lane is never gated, so genuinely
// empty vehicles keep getting onboarded):
// 1. pressure = waiting + active + delayed-due-within-PRESSURE_HORIZON_MS,
// both lanes, vs maxBacklog — "are the workers actually swamped?".
// Budget/off-hours defers are parked hours out and no longer count, so a
// spent daily budget stops freezing Phase-2 for the rest of the day.
// 2. total = every pending job, both lanes, vs HARD_MAX_TOTAL_JOBS — the
// runaway backstop that `delayed` used to provide by accident.
const depth = await this.getQueueDepth();
const headroom = Math.max(0, HARD_MAX_TOTAL_JOBS - depth.total);
// Admission control in JOB units: one wave fans out to batchSize * ~400 jobs
// long before the next hourly scan can react, so shrink the wave as the pool
// fills instead of stepping over the cap by a whole batch.
const phase2Budget = Math.min(batchSize, Math.floor(headroom / EST_JOBS_PER_VEHICLE));
const phase2Allowed = depth.pressure <= maxBacklog && phase2Budget > 0;
if (!phase2Allowed) {
this.logger.log(`[backfill] Backlog ${backlog} > ${maxBacklog} — Phase-1 (fast lane) only`);
this.logger.log(
`[backfill] Phase-1 (fast lane) only — pressure=${depth.pressure}/${maxBacklog}, ` +
`total=${depth.total}/${HARD_MAX_TOTAL_JOBS} (delayed=${depth.delayed}, imminent=${depth.imminent})`,
);
}
if (HARD_MAX_TOTAL_JOBS > 0 && depth.total > HARD_MAX_TOTAL_JOBS * 1.5) {
// Should be unreachable — admission control caps intake once per hour. If it
// fires, real fan-out is far above EST_JOBS_PER_VEHICLE.
this.logger.error(
`[backfill] Queue pool ${depth.total} > 1.5x hard cap ${HARD_MAX_TOTAL_JOBS} — investigate fan-out`,
);
}
// Only target sources eligible right now: not in cooldown (user active) and
// inside their scrape window (PL24/parts-catalogs office hours; EMEX always).
// Only target sources eligible right now: not in cooldown (user active),
// inside their scrape window, AND still inside today's main-lane budget.
// The budget check is THE key guard: deferring at job level only MOVES work
// into `delayed`, it does not stop PRODUCING it — and budget defers never
// exhaust attempts (moveToDelayed skipAttempt), so nothing drops them. Once a
// source's main-lane budget is spent the scan must stop feeding it for the
// rest of the UTC day; feeding a throttled source is exactly how the 470k
// runaway was built. This also preserves the fast-lane reserve for real users.
const eligible: string[] = [];
for (const s of BACKFILL_SOURCES) {
// PL24_TR_DISABLED: tr is dead on PL24's side and ALL pl24 traffic maps to
// the sole surviving de account — keep background backfill off it entirely
// (fast/user lane still flows) so bulk load can't burn the last account.
if (s === "pl24" && process.env.PL24_TR_DISABLED === "true") continue;
if (await this.redis.exists(`prefetch:activity:${s}`)) continue;
if (cfg.businessHoursOnly !== false && !isWithinTimeWindow(s)) continue;
const mainLimit = this.dailyMainLimit(s);
if (mainLimit > 0) {
const spent = Number((await this.redis.get(this.dailyKey(s))) ?? 0);
if (spent >= mainLimit) {
this.logger.log(
`[backfill] ${s} daily budget spent (${spent}/${mainLimit}) — source skipped this wave`,
);
continue;
}
}
eligible.push(s);
}
if (eligible.length === 0) {
@@ -536,9 +751,17 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
const tryPick = async (
v: { id: string; source: string | null },
fast: boolean,
limit: number = batchSize,
): Promise<void> => {
if (picked.length >= batchSize || seen.has(v.id) || !v.source) return;
if (await this.redis.exists(`prefetch:scheduled:${v.id}`)) return; // already in flight
if (picked.length >= limit || seen.has(v.id) || !v.source) return;
// TWO different in-flight guards, both must be clear:
// - prefetch:scheduled:backfill:<id> — ours (36h, covers a budget-parked chain)
// - prefetch:scheduled:<id> — the USER decode path's (vehicles.service.ts
// schedulePrefetch, short TTL). We deliberately stopped WRITING the bare
// key: our long TTL made schedulePrefetch() silently skip the user's
// fresh-decode fast-lane init.
if (await this.redis.exists(this.scheduledKey(v.id))) return;
if (await this.redis.exists(`prefetch:scheduled:${v.id}`)) return; // user chain in flight
// Skip exhausted residue: vehicles whose prefetch keeps finishing with zero
// parts (no catalog data). They'd otherwise be re-picked every wave forever.
const noResult = await this.redis.get(this.noResultKey(v.id));
@@ -571,17 +794,27 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
for (const v of noParts) await tryPick(v, true);
// Phase 2 — rolling rescan of ALL decoded vehicles to gap-fill partially-fetched
// ones. A createdAt cursor walks forward and wraps around at the end. Gated by
// the backlog ceiling (above) so it doesn't pile on while the queue is deep.
if (phase2Allowed && picked.length < batchSize) {
// Phase 2 — rolling rescan of vehicles that are NOT fully fetched, to gap-fill
// partials. Targets the DURABLE `fullyFetched` flag (indexed) instead of
// walking the whole fleet and relying only on the ephemeral 21-day
// `prefetch:complete:` marker — a Redis flush would otherwise re-drill every
// vehicle at once. The fullyFetchedAt clause keeps the periodic re-validation
// the TTL used to provide. Gated by the ceilings above + a JOB-unit budget.
const phase2Limit = Math.min(batchSize, picked.length + phase2Budget);
if (phase2Allowed && picked.length < phase2Limit) {
const cursorObj = await this.redis.getJson<{ ts: string }>(BACKFILL_CURSOR_KEY);
const cursor = cursorObj?.ts ? new Date(cursorObj.ts) : new Date(0);
const rolling = await this.db
.select({ id: vehicles.id, source: vehicles.source, createdAt: vehicles.createdAt })
.from(vehicles)
.where(and(inArray(vehicles.source, eligible), gt(vehicles.createdAt, cursor)))
.where(
and(
inArray(vehicles.source, eligible),
gt(vehicles.createdAt, cursor),
sql`(${vehicles.fullyFetched} = false OR ${vehicles.fullyFetchedAt} < now() - interval '21 days')`,
),
)
.orderBy(asc(vehicles.createdAt))
.limit(overfetch);
@@ -597,7 +830,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
let lastTs: Date | null = null;
for (const v of rolling) {
lastTs = v.createdAt;
await tryPick(v, false);
await tryPick(v, false, phase2Limit);
}
if (lastTs) {
await this.redis.setJson(BACKFILL_CURSOR_KEY, { ts: lastTs.toISOString() }, 30 * 86400);
@@ -613,13 +846,15 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
const fastCount = picked.filter((v) => v.fast).length;
this.logger.log(
`[backfill] Queued ${picked.length} vehicle(s) (${fastCount} fast-lane, ` +
`sources=${eligible.join(",")}, backlog=${backlog})`,
`sources=${eligible.join(",")}, pressure=${depth.pressure}, total=${depth.total}, ` +
`phase2Budget=${phase2Budget})`,
);
}
/** Queue a prefetch-init for a vehicle and set the in-flight guard. */
private async enqueueInit(vehicleId: string, source: string, fast = false): Promise<void> {
await this.queue.add(
const q = fast ? this.fastQueue : this.queue;
await q.add(
"prefetch-init",
{ vehicleId, source: source as PrefetchInitJobData["source"], fast },
{
@@ -631,7 +866,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
},
);
// Guard cleared on completion (incrementCompleted) or by TTL if the run dies.
await this.redis.set(`prefetch:scheduled:${vehicleId}`, "1", BACKFILL_SCHEDULED_TTL);
await this.redis.set(this.scheduledKey(vehicleId), "1", BACKFILL_SCHEDULED_TTL);
}
// ==================== Helpers ====================
@@ -648,8 +883,8 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
source: string,
depth: number,
fast = false,
): Promise<void> {
if (cat.unavailable) return;
): Promise<number> {
if (cat.unavailable) return 0;
if (this.isLeafLinkPath(cat.linkPath, cat.source, cat.hasSubgroups)) {
// Leaf — check if already has parts
@@ -660,16 +895,20 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
.limit(1);
if (!partCheck && cat.linkPath) {
await this.addJob("prefetch-parts", {
return (await this.addJob("prefetch-parts", {
vehicleId,
categoryId: cat.id,
source: source as "pl24" | "emex",
action: "parts" as const,
depth,
fast,
});
}))
? 1
: 0;
}
} else if (cat.linkPath && depth < MAX_DEPTH) {
return 0;
}
if (cat.linkPath && depth < MAX_DEPTH) {
// Non-leaf within the depth cap — explore children. The `depth < MAX_DEPTH`
// gate mirrors processChildren's early-return: without it we'd enqueue a
// prefetch-children job that processChildren just drops, burning a rate-limit
@@ -687,21 +926,25 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
.from(categories)
.where(eq(categories.parentId, cat.id));
let n = 0;
for (const child of children) {
if (child.unavailable) continue;
await this.queueCategoryJob(child, vehicleId, source, depth + 1, fast);
n += await this.queueCategoryJob(child, vehicleId, source, depth + 1, fast);
}
} else {
await this.addJob("prefetch-children", {
vehicleId,
categoryId: cat.id,
source: source as "pl24" | "emex",
action: "children" as const,
depth,
fast,
});
return n;
}
return (await this.addJob("prefetch-children", {
vehicleId,
categoryId: cat.id,
source: source as "pl24" | "emex",
action: "children" as const,
depth,
fast,
}))
? 1
: 0;
}
return 0;
}
private isLeafLinkPath(
@@ -723,21 +966,32 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
// flag (null, rare pre-migration rows) → treated as leaf, preserving the old
// 1-level behaviour for those.
if (source === "parts-catalogs") return hasSubgroups !== true;
// PL24 leaf indicators
return (
linkPath.includes("/bom/") ||
linkPath.includes("/bomdetails") ||
linkPath.includes("/partinfo/") ||
linkPath.includes("/servicepart/vin_items")
);
// PL24: one shared classifier (integrations/pl24/pl24-tree). The old inline
// list was case-sensitive, so p5psa/p5volvo's camelCase `/details/vin/
// bomDetails` was never recognised as a leaf and its parts were never
// prefetched (still true today for Mitsubishi/Fiat/Renault).
return isPl24LeafNode({ linkPath, hasSubgroups });
}
private async addJob(name: string, data: PrefetchCategoryJobData): Promise<void> {
/**
* Queue one sub-job. Returns whether a NEW job was actually created.
*
* The return value is the fix for a completion-accounting bug: `progress.total`
* is the chain's denominator (incrementCompleted fires "finished" at
* `completed >= total`), but callers used to increment it for every node they
* WALKED — including nodes where this deterministic jobId deduped the add, and
* leaves that already had parts. An inflated total means the chain never
* reaches "finished", so the vehicle is never marked fullyFetched and the scan
* re-picks it every wave forever (the ~25 vehicles/day plateau). Queue
* behaviour is UNCHANGED — BullMQ already no-op'd a duplicate jobId.
*/
private async addJob(name: string, data: PrefetchCategoryJobData): Promise<boolean> {
// BullMQ rejects custom job IDs containing ":" (its key separator), so use
// "-" instead. The values are UUIDs — the ID only needs to be deterministic
// (for dedup), not parseable.
const jobId = `prefetch-${data.vehicleId}-${data.categoryId}-${data.action}`;
const opts: Record<string, unknown> = {
// BullMQ rejects custom job IDs containing ":" (its key separator), so use
// "-" instead. The values are UUIDs — the ID only needs to be deterministic
// (for dedup), not parseable.
jobId: `prefetch-${data.vehicleId}-${data.categoryId}-${data.action}`,
jobId,
// Fast lane (Phase-1 / reactive): add with `lifo` so the job RPUSHes to the
// TAIL of the wait list, where BullMQ's RPOPLPUSH picks it next — i.e. ahead
// of the deep deep-drill backlog already sitting in wait. (BullMQ 5 drains
@@ -745,12 +999,15 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
// OPPOSITE here and starve the job behind the backlog; lifo is correct.)
...(data.fast ? { lifo: true } : {}),
};
const q = data.fast ? this.fastQueue : this.queue;
if (await q.getJob(jobId)) return false;
// parts-catalogs pacing is handled per-job in process() (PCAT_PACE_MS) + the
// limiter. The old cumulative `index * 20s` delay was pathological (the Nth
// leaf of a vehicle waited N*20s) and is gone.
await this.queue.add(name, data, opts);
await q.add(name, data, opts);
return true;
}
private async incrementCompleted(vehicleId: string): Promise<void> {
@@ -770,29 +1027,47 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
if (isFinished) {
this.logger.log(`[prefetch] Completed all jobs for vehicle=${vehicleId}`);
// Genuine residue: the whole chain finished but the vehicle still has no
// parts (all leaves empty / no catalog data). Count it so Phase-1 stops
// re-picking it every wave.
const [hasPart] = await this.db
.select({ id: parts.id })
.from(parts)
.where(eq(parts.vehicleId, vehicleId))
.limit(1);
if (hasPart) {
// Fully fetched with parts → mark complete so the Phase-2 rescan skips it
// (re-validates after the TTL). A chain with any failed job never reaches
// isFinished, so partially-fetched vehicles are never marked — they keep
// getting gap-filled.
await this.redis.set(this.completeKey(vehicleId), "1", COMPLETE_TTL_S);
} else {
await this.markNoResult(vehicleId);
}
// Clean up Redis keys — data is in PostgreSQL now
await this.redis.del(`prefetch:scheduled:${vehicleId}`);
await this.redis.del(`prefetch:progress:${vehicleId}`);
await this.finalizeVehicle(vehicleId);
}
}
/**
* Settle a vehicle whose prefetch chain is done: mark it complete (with parts)
* or count it as residue (still zero parts), then clear the run's Redis state.
* Called both when the last sub-job finishes AND when processInit finds there
* is nothing left to queue — otherwise an already-complete tree would never be
* settled and the scan would re-pick it forever.
*/
private async finalizeVehicle(vehicleId: string): Promise<void> {
// Genuine residue: the whole chain finished but the vehicle still has no
// parts (all leaves empty / no catalog data). Count it so Phase-1 stops
// re-picking it every wave.
const [hasPart] = await this.db
.select({ id: parts.id })
.from(parts)
.where(eq(parts.vehicleId, vehicleId))
.limit(1);
if (hasPart) {
// Fully fetched with parts → mark complete so the Phase-2 rescan skips it
// (re-validates after the TTL). A chain with any failed job never reaches
// isFinished, so partially-fetched vehicles are never marked — they keep
// getting gap-filled.
await this.redis.set(this.completeKey(vehicleId), "1", COMPLETE_TTL_S);
// Durable completeness flag (never expires) — the reliable "% fully
// fetched" measurement, independent of the ephemeral Redis marker.
// Re-set on every re-drill completion so fullyFetchedAt tracks last-verified.
await this.db
.update(vehicles)
.set({ fullyFetched: true, fullyFetchedAt: new Date() })
.where(eq(vehicles.id, vehicleId));
} else {
await this.markNoResult(vehicleId);
}
// Clean up Redis keys — data is in PostgreSQL now
await this.redis.del(this.scheduledKey(vehicleId));
await this.redis.del(`prefetch:progress:${vehicleId}`);
}
private async incrementErrors(vehicleId: string): Promise<void> {
const progress = await this.redis.getJson<{ errors: number }>(`prefetch:progress:${vehicleId}`);
if (!progress) return;
@@ -805,6 +1080,62 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
return `prefetch:noresult:${vehicleId}`;
}
/**
* Backfill's own in-flight guard — namespaced so it can't shadow the user
* decode path's `prefetch:scheduled:<id>` (vehicles.service.ts reads that key
* and skips the fresh-decode fast-lane init while it is set).
*/
private scheduledKey(vehicleId: string): string {
return `prefetch:scheduled:backfill:${vehicleId}`;
}
/**
* Queue depth across BOTH lanes, split into "pressure" (work the workers will
* pick up within PRESSURE_HORIZON_MS) and "total" (everything pending).
*
* BullMQ stores delayed jobs in a ZSET scored `dueMs * 0x1000 + seq` (12-bit
* collision counter — addDelayedJob / moveToDelayed lua), so "due within X ms"
* is a plain ZCOUNT with the bound encoded the same way. RedisService exposes
* no zcount, so we borrow each queue's OWN ioredis connection and its toKey()
* rather than hand-building `bull:<name>:delayed`.
*/
private async getQueueDepth(): Promise<{
pressure: number;
total: number;
imminent: number;
delayed: number;
}> {
// String bound so ioredis can't render it in exponent notation.
const maxScore = String((Date.now() + PRESSURE_HORIZON_MS + 1) * 0x1000 - 1);
let pressure = 0;
let total = 0;
let imminent = 0;
let delayed = 0;
for (const q of [this.queue, this.fastQueue]) {
const c = await q.getJobCounts("waiting", "active", "delayed", "prioritized");
const live = (c.waiting ?? 0) + (c.active ?? 0);
const d = c.delayed ?? 0;
// Fail CLOSED: if the ZCOUNT can't be taken, count every delayed job as
// pressure — fall back to the old over-conservative gate rather than
// silently unlocking Phase-2 with no visibility. A repeating warn here
// means Phase-2 is suspended again.
let due = d;
try {
const client = await q.client;
due = await client.zcount(q.toKey("delayed"), "-inf", maxScore);
} catch (err) {
this.logger.warn(
`[backfill] delayed zcount failed on ${q.name} (${(err as Error).message}) — counting all delayed as pressure`,
);
}
pressure += live + due;
total += live + d + (c.prioritized ?? 0);
imminent += due;
delayed += d;
}
return { pressure, total, imminent, delayed };
}
private completeKey(vehicleId: string): string {
return `prefetch:complete:${vehicleId}`;
}
@@ -820,7 +1151,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
* (which stay ~200 categories and DO yield parts) are never affected. */
private async markPoison(vehicleId: string): Promise<void> {
await this.redis.set(this.poisonKey(vehicleId), "1", POISON_TTL_S);
await this.redis.del(`prefetch:scheduled:${vehicleId}`);
await this.redis.del(this.scheduledKey(vehicleId));
await this.redis.del(`prefetch:progress:${vehicleId}`);
}
@@ -830,12 +1161,24 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
* ("source-rate") so process() defers THIS job to the window's end while other
* sources keep flowing. 0 / unknown source ceiling = unlimited.
*/
private async checkSourceRate(source: string): Promise<void> {
const max = SOURCE_RATE_MAX[source] ?? 0;
if (max <= 0) return;
private async checkSourceRate(source: string, lane: "main" | "fast" = "main"): Promise<void> {
// Per-LANE budgets: the main worker's deep-drill backlog would otherwise eat
// the whole per-source window and starve the fast lane with 18s defers
// (observed: fresh BMW init couldn't get a single pcat slot). Totals per
// source stay the same as before, so upstream load is unchanged.
const total = SOURCE_RATE_MAX[source] ?? 0;
// PL24_TR_DISABLED: park already-queued pl24 MAIN-lane jobs (long defer, no
// attempt consumed) — the eligibility scan stops producing new ones, this
// stops the existing backlog from draining through the de account.
if (source === "pl24" && lane === "main" && process.env.PL24_TR_DISABLED === "true") {
throw new RateLimitError(15 * 60_000, "source-rate");
}
if (total <= 0) return;
const fastShare = Math.max(1, Math.floor(total / 3));
const max = lane === "fast" ? fastShare : total - fastShare;
const windowMs = 60_000;
const now = Date.now();
const key = `prefetch:rate:${source}:${Math.floor(now / windowMs)}`;
const key = `prefetch:rate:${lane}:${source}:${Math.floor(now / windowMs)}`;
const n = await this.redis.incr(key);
if (n === 1) await this.redis.expire(key, 61);
if (n > max) {
@@ -843,6 +1186,59 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
}
}
/**
* Per-source rolling-day budget (storm guard). UTC-day fixed window in Redis;
* when the source's daily job count exceeds its ceiling, defer the job until
* the window rolls so a long run can't drain the proxy budget. 0 = unlimited.
*/
private async checkSourceDailyBudget(
source: string,
lane: "main" | "fast" = "main",
): Promise<void> {
const max = SOURCE_DAILY_MAX[source] ?? 0;
if (max <= 0) return;
const limit = lane === "fast" ? max : this.dailyMainLimit(source);
const dayMs = 86_400_000;
const now = Date.now();
const key = this.dailyKey(source, now);
// READ-then-INCR (was INCR-then-check). A REJECTED attempt must not count:
// the old order inflated the counter with every defer (observed 48531 against
// a 30000 budget), which (a) made the number useless for capacity decisions
// and (b) — now that the scan reads the same counter to stop feeding a spent
// source — would let pure defer churn lock the source out. Worst-case
// overshoot under the read/incr race is WORKER_CONCURRENCY jobs: acceptable.
const n = Number((await this.redis.get(key)) ?? 0);
if (n >= limit) {
// Defer to the next UTC day, plus up to 45min of JITTER. Without jitter every
// deferred job wakes in the SAME millisecond (observed: 11495 jobs all at
// 00:00:01 UTC) — the promotion lands as one burst and the pressure signal
// flaps. Other sources keep flowing (per-job defer, not a worker pause).
const msLeft = dayMs - (now % dayMs) + 1000 + Math.floor(Math.random() * 45 * 60_000);
if (n === limit) {
this.logger.warn(
`[prefetch] ${source} daily budget hit (lane=${lane}, ${n}/${limit} of ${max}) — ` +
`deferring ~${Math.round(msLeft / 3_600_000)}h until the window rolls`,
);
}
throw new RateLimitError(msLeft, "source-rate");
}
const after = await this.redis.incr(key);
if (after === 1) await this.redis.expire(key, 90_000); // ~25h, outlives the window
}
/** Redis key for a source's UTC-day budget counter (shared by both lanes). */
private dailyKey(source: string, now = Date.now()): string {
return `prefetch:daily:${source}:${Math.floor(now / 86_400_000)}`;
}
/** Main (backfill) lane threshold — the fast lane's reserve is never available
* to backfill, so a sweep can't park the user's fresh-decode chain. */
private dailyMainLimit(source: string): number {
const max = SOURCE_DAILY_MAX[source] ?? 0;
return max <= 0 ? 0 : Math.floor(max * (1 - DAILY_FAST_RESERVE));
}
/** Record that a backfill attempt finished with the vehicle still at zero parts. */
private async markNoResult(vehicleId: string): Promise<void> {
const key = this.noResultKey(vehicleId);

View File

@@ -0,0 +1,346 @@
import { Job } from "bullmq";
import { inArray, sql } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { NORMALIZE_SQL, classifyNode, mapToCanonical } from "../canonical-lexicon";
import { mapToSubcategory } from "../canonical-sublexicon";
type Database = PostgresJsDatabase<Record<string, unknown>>;
export interface CanonicalBackfillJobData {
// Limit input to these brands (matched via catalog_vehicles/vehicles). Empty
// = all brands. The map rows written are brand-agnostic (keyed by name), so
// scoping only limits WHICH names are considered this run — safe to widen later.
brands?: string[];
// Limit to these sources (pl24 | emex | parts-catalogs). Empty = all.
sources?: string[];
// Compute + log coverage but write nothing.
dryRun?: boolean;
// Full rebuild of the non-manual keyword layer: before re-mapping, NULL the
// materialized ids and DELETE stale non-manual map rows in scope, so names
// that no longer map (lexicon/classifier changed) don't leave dangling rows.
// Manual rows survive. ONLY honored for a GLOBAL run (no brands): the map is
// name-level/brand-agnostic, so a brand-scoped delete would wipe mappings
// shared with other brands. Use this for the periodic full re-run.
reset?: boolean;
// Keep-fresh mode for the nightly cron: only classify names that have NO map
// row yet (the day's newly-decoded categories), instead of re-classifying all
// ~113k. The materialize step is differential anyway, so a brand-new category
// whose name already maps still gets its id set. Does NOT re-apply lexicon
// tuning to already-mapped names — that's what a manual `reset` run is for.
// Ignored when `reset` is set (a reset intends a full re-map).
incremental?: boolean;
}
export interface CanonicalBackfillStats {
distinctNames: number;
partCategories: number;
structural: number;
junk: number;
mapped: number;
subMapped: number;
ambiguous: number;
categoriesUpdated: number;
subcategoriesUpdated: number;
inherited: number;
mapRowsPruned: number;
dryRun: boolean;
}
// The bare `name` in NORMALIZE_SQL resolves to categories.name in every query
// below (catalog_vehicles/vehicles have no `name` column), so folding is
// identical on both the SELECT that builds the map and the UPDATE join.
const INSERT_CHUNK = 500;
export async function processCanonicalBackfill(
job: Job,
db: Database,
): Promise<CanonicalBackfillStats> {
const data = (job.data ?? {}) as CanonicalBackfillJobData;
const brands = data.brands?.length ? data.brands : null;
const sources = data.sources?.length ? data.sources : null;
const dryRun = data.dryRun === true;
const reset = data.reset === true && !brands;
const incremental = data.incremental === true && !reset;
if (data.reset === true && brands) {
console.warn(
"[canonical-backfill] reset ignored: only honored for a global run (map is name-level/brand-agnostic)",
);
}
console.log(
`[canonical-backfill] job ${job.id} start (brands=${brands ?? "ALL"}, sources=${sources ?? "ALL"}, dryRun=${dryRun}, reset=${reset})`,
);
// 1. canonical slug → id (seeded by migration 0024)
const canon = await db.execute<{ id: string; slug: string }>(
sql`SELECT id, slug FROM canonical_categories`,
);
const slugToId = new Map<string, string>();
for (const r of canon) slugToId.set(r.slug, r.id);
if (slugToId.size === 0) {
throw new Error(
"[canonical-backfill] canonical_categories is empty — migration 0024 seed missing",
);
}
// inArray expands to `expr IN ($1,$2,…)` — driver-safe across postgres-js,
// unlike `= ANY($1)` which needs the param bound as a Postgres array.
const sourceCond = sources ? sql`AND ${inArray(sql`c.source`, sources)}` : sql``;
// Brand scope via a correlated EXISTS on c.id — pl24 nodes hang off
// catalog_vehicles, emex/pcat off vehicles, so coalesce covers both. EXISTS
// (not a FROM-join) is used in the UPDATE because Postgres forbids referencing
// the UPDATE target inside its FROM-list join conditions.
const brandExists = brands
? sql`AND EXISTS (
SELECT 1 FROM categories c2
LEFT JOIN catalog_vehicles cv ON c2.catalog_vehicle_id = cv.id
LEFT JOIN vehicles v ON c2.vehicle_id = v.id
WHERE c2.id = c.id AND ${inArray(sql`coalesce(cv.brand_name, v.brand_name)`, brands)}
)`
: sql``;
// 2. distinct (folded name, source) in scope
const brandJoinFilter = brands
? sql`AND ${inArray(sql`coalesce(cv.brand_name, v.brand_name)`, brands)}`
: sql``;
// Incremental keep-fresh: skip names already in the map — only the day's new
// names are (re)classified. Materialize (step 5) still fixes any category
// whose name maps but whose own id is stale/null, so nothing new is missed.
const incrementalCond = incremental
? sql`AND NOT EXISTS (
SELECT 1 FROM category_canonical_map m
WHERE m.source = c.source AND m.normalized_name = ${sql.raw(NORMALIZE_SQL)}
)`
: sql``;
const names = await db.execute<{ folded: string; source: string }>(sql`
SELECT DISTINCT ${sql.raw(NORMALIZE_SQL)} AS folded, c.source AS source
FROM categories c
LEFT JOIN catalog_vehicles cv ON c.catalog_vehicle_id = cv.id
LEFT JOIN vehicles v ON c.vehicle_id = v.id
WHERE true ${brandJoinFilter} ${sourceCond} ${incrementalCond}
`);
// 3. classify + keyword-map
const rows: {
source: string;
folded: string;
canonicalId: string;
subcategoryId: string | null;
ambiguous: boolean;
confidence: number;
}[] = [];
const stats: CanonicalBackfillStats = {
distinctNames: names.length,
partCategories: 0,
structural: 0,
junk: 0,
mapped: 0,
subMapped: 0,
ambiguous: 0,
categoriesUpdated: 0,
subcategoriesUpdated: 0,
inherited: 0,
mapRowsPruned: 0,
dryRun,
};
for (const { folded, source } of names) {
const kind = classifyNode(folded);
if (kind === "junk") {
stats.junk++;
continue;
}
if (kind === "structural") {
stats.structural++;
continue;
}
stats.partCategories++;
const { slug, ambiguous, confidence } = mapToCanonical(folded);
if (!slug) continue;
const canonicalId = slugToId.get(slug);
if (!canonicalId) continue; // slug not seeded — skip defensively
stats.mapped++;
if (ambiguous) stats.ambiguous++;
// second level: name→bucket→sub is deterministic, so resolve it here.
const subSlug = mapToSubcategory(folded, slug);
const subcategoryId = subSlug ? (slugToId.get(subSlug) ?? null) : null;
if (subcategoryId) stats.subMapped++;
rows.push({ source, folded, canonicalId, subcategoryId, ambiguous, confidence });
}
console.log(
`[canonical-backfill] ${stats.distinctNames} names → part=${stats.partCategories} structural=${stats.structural} junk=${stats.junk} | mapped=${stats.mapped} (ambiguous=${stats.ambiguous})`,
);
if (dryRun) {
console.log("[canonical-backfill] dryRun — no writes");
return stats;
}
// 3b. reset (global only): clear the materialized ids + prune non-manual map
// rows in scope so stale mappings from an older lexicon/classifier disappear.
if (reset) {
const mapSourceCond = sources ? sql`AND ${inArray(sql`source`, sources)}` : sql``;
await db.execute(sql`
UPDATE categories c SET canonical_category_id = NULL, canonical_subcategory_id = NULL
WHERE (canonical_category_id IS NOT NULL OR canonical_subcategory_id IS NOT NULL) ${sourceCond}
`);
const pruned = await db.execute<{ n: number }>(sql`
WITH d AS (
DELETE FROM category_canonical_map
WHERE is_manual = false ${mapSourceCond}
RETURNING 1
)
SELECT count(*)::int AS n FROM d
`);
stats.mapRowsPruned = pruned[0]?.n ?? 0;
console.log(
`[canonical-backfill] reset — cleared materialized ids in scope, pruned ${stats.mapRowsPruned} non-manual map rows`,
);
}
// 4. upsert name-level map rows (chunked). is_manual rows are protected: the
// conflict target updates only when the existing row was not human-approved.
for (let i = 0; i < rows.length; i += INSERT_CHUNK) {
const chunk = rows.slice(i, i + INSERT_CHUNK);
const values = chunk.map(
(r) =>
sql`(${r.source}, ${r.folded}, ${r.canonicalId}, ${r.subcategoryId}, 'keyword', ${r.confidence}, ${r.ambiguous})`,
);
await db.execute(sql`
INSERT INTO category_canonical_map
(source, normalized_name, canonical_category_id, canonical_subcategory_id, method, confidence, ambiguous)
VALUES ${sql.join(values, sql`, `)}
ON CONFLICT (source, normalized_name) DO UPDATE SET
canonical_category_id = EXCLUDED.canonical_category_id,
canonical_subcategory_id = EXCLUDED.canonical_subcategory_id,
method = EXCLUDED.method,
confidence = EXCLUDED.confidence,
ambiguous = EXCLUDED.ambiguous,
updated_at = now()
WHERE category_canonical_map.is_manual = false
`);
}
// 5. materialize categories.canonical_category_id + _subcategory_id from the
// map (scoped join). Both levels come from the same name-keyed row.
const updated = await db.execute<{ n: number }>(sql`
WITH upd AS (
UPDATE categories c
SET canonical_category_id = m.canonical_category_id,
canonical_subcategory_id = m.canonical_subcategory_id
FROM category_canonical_map m
WHERE m.source = c.source
AND m.normalized_name = ${sql.raw(NORMALIZE_SQL)}
AND (c.canonical_category_id IS DISTINCT FROM m.canonical_category_id
OR c.canonical_subcategory_id IS DISTINCT FROM m.canonical_subcategory_id)
${sourceCond} ${brandExists}
RETURNING 1
)
SELECT count(*)::int AS n FROM upd
`);
stats.categoriesUpdated = updated[0]?.n ?? 0;
// 5b. parent-inheritance: a category whose own name didn't map inherits its
// parent's bucket. Handles code/plate-named illustration leaves under a
// descriptive parent (Renault P5 "Alternatör" → X1318_… ; any figure leaf).
// Cascade a few levels (deepest OEM trees are ~5).
//
// EXCEPT vehicle-selector nodes (year-range "… 2004 - 2014" / model-code
// "A04 ASTRA-H"): these are NOT parts, so inheriting a part bucket into them
// is wrong AND cascades that wrong bucket onto their generic children
// (Opel "BOYALAR"/"MÜTEFERRİK" under a model node were landing in Debriyaj).
// Blocking the selector node from receiving keeps it null, which breaks the
// whole downstream chain. Code/plate leaves (X1318_, 20-201A) don't match
// these patterns, so Renault/Hyundai inheritance is untouched.
for (let depth = 0; depth < 5; depth++) {
const inh = await db.execute<{ n: number }>(sql`
WITH upd AS (
UPDATE categories c
SET canonical_category_id = p.canonical_category_id,
canonical_subcategory_id = p.canonical_subcategory_id
FROM categories p
WHERE c.parent_id = p.id
AND c.canonical_category_id IS NULL
AND p.canonical_category_id IS NOT NULL
AND c.name !~ '(19|20)[0-9]{2} - (19|20)[0-9]{2}'
AND c.name !~* '^[a-z]{1,3}[0-9]{2} '
${sourceCond} ${brandExists}
RETURNING 1
)
SELECT count(*)::int AS n FROM upd
`);
const n = inh[0]?.n ?? 0;
stats.inherited += n;
if (n === 0) break;
}
// subcategory coverage: how many mapped categories in scope now carry a sub.
const subCount = await db.execute<{ n: number }>(sql`
SELECT count(*)::int AS n FROM categories c
WHERE c.canonical_subcategory_id IS NOT NULL ${sourceCond} ${brandExists}
`);
stats.subcategoriesUpdated = subCount[0]?.n ?? 0;
// 6. rebuild the per (source, brand) structure template — the shared canonical
// skeleton the serving overlays so a freshly-decoded vehicle shows the FULL
// structure instantly (before its own deep-drill). Derived from all mapped
// categories, so only on a GLOBAL run (brand-scoped runs see a partial world).
if (!brands && !dryRun) {
await db.execute(sql`DELETE FROM canonical_structure_template`);
const tpl = await db.execute<{ n: number }>(sql`
WITH ins AS (
INSERT INTO canonical_structure_template
(source, brand, canonical_category_id, canonical_subcategory_id, vehicle_count)
SELECT c.source,
coalesce(cv.brand_name, v.brand_name) AS brand,
c.canonical_category_id,
c.canonical_subcategory_id,
count(DISTINCT coalesce(c.catalog_vehicle_id, c.vehicle_id))::int
FROM categories c
LEFT JOIN catalog_vehicles cv ON c.catalog_vehicle_id = cv.id
LEFT JOIN vehicles v ON c.vehicle_id = v.id
WHERE c.canonical_category_id IS NOT NULL
AND coalesce(cv.brand_name, v.brand_name) IS NOT NULL
GROUP BY c.source, coalesce(cv.brand_name, v.brand_name),
c.canonical_category_id, c.canonical_subcategory_id
RETURNING 1
)
SELECT count(*)::int AS n FROM ins
`);
console.log(`[canonical-backfill] structure template rebuilt — ${tpl[0]?.n ?? 0} rows`);
// Entry-level template: the ENTRY names (bucket entry-point children — same
// level buildCanonicalInput serves) shared across the brand's vehicles.
// min 2 vehicles so one-off vehicle quirks don't become "the brand".
await db.execute(sql`DELETE FROM canonical_template_entries`);
const tent = await db.execute<{ n: number }>(sql`
WITH ins AS (
INSERT INTO canonical_template_entries
(source, brand, canonical_category_id, canonical_subcategory_id, name, vehicle_count)
SELECT c.source, coalesce(cv.brand_name, v.brand_name),
c.canonical_category_id, c.canonical_subcategory_id,
min(c.name),
count(DISTINCT coalesce(c.catalog_vehicle_id, c.vehicle_id))::int
FROM categories c
LEFT JOIN catalog_vehicles cv ON c.catalog_vehicle_id = cv.id
LEFT JOIN vehicles v ON c.vehicle_id = v.id
LEFT JOIN categories p ON p.id = c.parent_id
WHERE c.canonical_category_id IS NOT NULL
AND coalesce(cv.brand_name, v.brand_name) IS NOT NULL
AND (c.parent_id IS NULL OR p.canonical_category_id IS DISTINCT FROM c.canonical_category_id
OR (p.parent_id IS NULL AND p.canonical_category_id = c.canonical_category_id))
GROUP BY c.source, coalesce(cv.brand_name, v.brand_name),
c.canonical_category_id, c.canonical_subcategory_id,
btrim(regexp_replace(translate(lower(c.name),'çşğıöü','csgiou'),'\\s+',' ','g'))
HAVING count(DISTINCT coalesce(c.catalog_vehicle_id, c.vehicle_id)) >= 2
RETURNING 1
)
SELECT count(*)::int AS n FROM ins
`);
console.log(`[canonical-backfill] template entries rebuilt — ${tent[0]?.n ?? 0} rows`);
}
console.log(
`[canonical-backfill] done — ${rows.length} map rows (${stats.subMapped} with sub, ${stats.mapRowsPruned} pruned), ${stats.categoriesUpdated} materialized, ${stats.inherited} inherited, ${stats.subcategoriesUpdated} sub-categorized`,
);
return stats;
}

View File

@@ -7,6 +7,8 @@ import {
computeStats,
filterOffersForBrand,
istanbulToday,
partPriceCurrentCacheKey,
partPriceSeriesCacheKey,
} from "../../part-prices/part-prices.logic";
import { createSupplierPriceSource } from "../../part-prices/supplier-price-source";
@@ -114,8 +116,8 @@ export async function processPartPriceRefresh(
// Cache düşür — bir sonraki sayfa görüntülemesi taze pg satırını okur.
const keys = chunk.flatMap((t) => [
`partprice:series:v2:${t.codeNorm}::${t.brandNorm}`,
`partprice:cur:v2:${t.codeNorm}::${t.brandNorm}`,
partPriceSeriesCacheKey(t.codeNorm, t.brandNorm),
partPriceCurrentCacheKey(t.codeNorm, t.brandNorm),
]);
if (keys.length > 0) await redis.del(...keys);

View File

@@ -2,7 +2,7 @@ import { Job } from "bullmq";
import { and, eq, sql } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { catalogVehicles, vinpinDecodes } from "../../database/schema/core";
import { getVinpinDriver } from "../../integrations/vinpin/vinpin-driver.service";
import { getVinpinDaemon } from "../../integrations/vinpin/vinpin-daemon.service";
import {
type CatalogCandidate,
pickBestCatalogMatch,
@@ -42,7 +42,11 @@ export async function processVinpinDecode(
.where(eq(vinpinDecodes.vin, vin));
try {
const decoded = await getVinpinDriver().decode(vin);
// Route through the warm-session daemon: inside business hours it decodes on
// the persistent warm seat (taskbar-raise + in-catalog decode); off-hours (or
// when warm can't be established) it transparently uses the cold per-decode
// path. Never throws — returns null on any failure.
const decoded = await getVinpinDaemon().decode(vin);
if (!decoded) {
await db
@@ -53,7 +57,9 @@ export async function processVinpinDecode(
return { status: "not_found", catalogVehicleId: null };
}
// Match the decoded model to an existing PL24 Fiat catalog_vehicle.
// Match the decoded model to an existing PL24 catalog_vehicle of the SAME
// brand. Fiat → 'fiat'; Renault/Dacia → the decoded brand (RENAULT/DACIA).
const brandName = decoded.brand ?? "Fiat";
const rows = await db
.select({
id: catalogVehicles.id,
@@ -62,7 +68,10 @@ export async function processVinpinDecode(
})
.from(catalogVehicles)
.where(
and(sql`lower(${catalogVehicles.brandName}) = 'fiat'`, eq(catalogVehicles.source, "pl24")),
and(
sql`lower(${catalogVehicles.brandName}) = ${brandName.toLowerCase()}`,
eq(catalogVehicles.source, "pl24"),
),
);
const catalogVehicleId = pickBestCatalogMatch(
@@ -74,7 +83,7 @@ export async function processVinpinDecode(
.update(vinpinDecodes)
.set({
status: "decoded",
brandName: "Fiat",
brandName,
model: decoded.model,
sincom: decoded.sincom,
trim: decoded.trim,

View File

@@ -0,0 +1,24 @@
import { Provider } from "@nestjs/common";
import { Queue } from "bullmq";
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
export const CANONICAL_BACKFILL_QUEUE = "CANONICAL_BACKFILL_QUEUE";
// On-demand backfill (no cron): enqueue a job to (re)map raw category names to
// the canonical taxonomy. Trigger manually with data { brands?, sources?, dryRun? }.
export const CanonicalBackfillQueueProvider: Provider = {
provide: CANONICAL_BACKFILL_QUEUE,
useFactory: () => {
const telemetry = getBullTelemetry();
return new Queue(QUEUE_NAMES.CANONICAL_BACKFILL, {
connection: getBullConnection(),
...(telemetry ? { telemetry } : {}),
defaultJobOptions: {
attempts: 2,
backoff: { type: "fixed", delay: 30000 },
removeOnComplete: { count: 50 },
removeOnFail: { count: 100 },
},
});
},
};

View File

@@ -3,6 +3,7 @@ import { Queue } from "bullmq";
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
export const CATALOG_PREFETCH_QUEUE = "CATALOG_PREFETCH_QUEUE";
export const CATALOG_PREFETCH_FAST_QUEUE = "CATALOG_PREFETCH_FAST_QUEUE";
export const CatalogPrefetchQueueProvider: Provider = {
provide: CATALOG_PREFETCH_QUEUE,
@@ -23,3 +24,20 @@ export const CatalogPrefetchQueueProvider: Provider = {
});
},
};
export const CatalogPrefetchFastQueueProvider: Provider = {
provide: CATALOG_PREFETCH_FAST_QUEUE,
useFactory: () => {
const telemetry = getBullTelemetry();
return new Queue(QUEUE_NAMES.CATALOG_PREFETCH_FAST, {
connection: getBullConnection(),
...(telemetry ? { telemetry } : {}),
defaultJobOptions: {
attempts: 3,
backoff: { type: "exponential", delay: 30000 },
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 },
},
});
},
};

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { VINPIN_DECODE_JOB_OPTIONS } from "./vinpin-decode.queue";
/**
* The single Vinpin seat is shared by every decode and the driver runs its own
* bounded internal retries + a hard wall-clock budget. A BullMQ-level auto-retry
* on top is what compounded the prod seat livelock (a failed decode auto-re-ran
* straight back into the stuck seat). So the queue must NOT auto-retry.
*/
describe("vinpin-decode queue job options", () => {
it("uses attempts:1 — no BullMQ auto-retry", () => {
expect(VINPIN_DECODE_JOB_OPTIONS.attempts).toBe(1);
});
it("does not configure a retry backoff", () => {
expect(VINPIN_DECODE_JOB_OPTIONS.backoff).toBeUndefined();
});
it("still bounds retained job history", () => {
expect(VINPIN_DECODE_JOB_OPTIONS.removeOnComplete).toEqual({ count: 50 });
expect(VINPIN_DECODE_JOB_OPTIONS.removeOnFail).toEqual({ count: 100 });
});
});

View File

@@ -1,9 +1,28 @@
import { Provider } from "@nestjs/common";
import { Queue } from "bullmq";
import { type JobsOptions, Queue } from "bullmq";
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
export const VINPIN_DECODE_QUEUE = "VINPIN_DECODE_QUEUE";
/**
* Default job options for the Vinpin decode queue.
*
* `attempts: 1` — NO BullMQ-level auto-retry. The single Vinpin seat is shared by
* every decode, and the driver ALREADY runs its own bounded internal retries
* (VINPIN_DECODE_MAX_ATTEMPTS) plus a hard wall-clock budget before it reports
* not_found. Stacking a BullMQ retry (the old attempts:2 + 30s exponential
* backoff) on top is exactly what compounded the seat livelock in prod: one bad
* VIN would fail, auto-re-run 30s later straight back into the stuck seat, and
* starve real decodes for minutes. A failed decode now persists as not_found (a
* clean "not decodable") or failed (a hard infra error, user-retriable) instead of
* being auto-re-fed into the seat.
*/
export const VINPIN_DECODE_JOB_OPTIONS: JobsOptions = {
attempts: 1,
removeOnComplete: { count: 50 },
removeOnFail: { count: 100 },
};
/** Vinpin ePER decode-oracle queue. Single Vinpin seat → the worker consumes
* this with concurrency 1. Jobs carry just `{ vin }`. */
export const VinpinDecodeQueueProvider: Provider = {
@@ -13,14 +32,7 @@ export const VinpinDecodeQueueProvider: Provider = {
return new Queue(QUEUE_NAMES.VINPIN_DECODE, {
connection: getBullConnection(),
...(telemetry ? { telemetry } : {}),
defaultJobOptions: {
// The driver itself retries (VINPIN_DECODE_MAX_ATTEMPTS) before reporting
// not_found, so the queue keeps a small attempt budget for hard crashes.
attempts: 2,
backoff: { type: "exponential", delay: 30_000 },
removeOnComplete: { count: 50 },
removeOnFail: { count: 100 },
},
defaultJobOptions: VINPIN_DECODE_JOB_OPTIONS,
});
},
};

View File

@@ -58,6 +58,10 @@ async function bootstrap() {
"https://connect.facebook.net",
"https://challenges.cloudflare.com",
"https://destek.sase.tr",
// Google Ads gtag.js (conversion tracking). Its absence here silently
// blocked the whole tag from loading → gtag config/conversion never ran
// → 0 conversions for weeks despite correct AW id + labels.
"https://www.googletagmanager.com",
"'sha256-T5FzBQBMINFjZ4WLy58SeZ+J7xXzjnQEGlg618CQnhA='",
],
styleSrc: ["'self'", "https:", "'unsafe-inline'"],
@@ -67,6 +71,12 @@ async function bootstrap() {
"https://storage.sase.tr",
"https://www.facebook.com",
"https://destek.sase.tr",
// Google Ads conversion + remarketing pixel pings (fire as images).
"https://www.google.com",
"https://www.google.com.tr",
"https://www.googleadservices.com",
"https://googleads.g.doubleclick.net",
"https://ad.doubleclick.net",
],
fontSrc: ["'self'", "https:", "data:"],
mediaSrc: ["'self'", "data:", "https://destek.sase.tr"],
@@ -79,6 +89,14 @@ async function bootstrap() {
"https://challenges.cloudflare.com",
"https://destek.sase.tr",
"wss://destek.sase.tr",
// Google Ads gtag config fetch + conversion pings (googleadservices /
// google.com 1p-conversion & ccm/collect enhanced-conversion beacons).
"https://www.googletagmanager.com",
"https://www.google.com",
"https://www.google.com.tr",
"https://www.googleadservices.com",
"https://googleads.g.doubleclick.net",
"https://ad.doubleclick.net",
// Sentry browser SDK envelope POSTs (otolog org, de region).
// Without this CSP silently blocks every error/replay upload.
"https://*.ingest.de.sentry.io",

View File

@@ -21,6 +21,9 @@ export const OPTIONAL_WORKFLOWS = new Set<string>([
"referral",
"referral-qualified",
"referral-reward",
// Manual campaign sends (host-side sase-conversion-campaign.sh) — marketing,
// so it must honour opt-out and its unsubscribe tokens must validate here.
"conversion",
"mobile_push",
]);
@@ -50,6 +53,7 @@ export const NOTIFICATION_CATEGORIES = [
"referral",
"referral-qualified",
"referral-reward",
"conversion",
] as const,
},
{
@@ -159,9 +163,7 @@ export class EmailPreferencesService {
async getCategoryState(userId: string, key: NotificationCategoryKey): Promise<boolean> {
const cat = NOTIFICATION_CATEGORIES.find((c) => c.key === key);
if (!cat) return false;
const states = await Promise.all(
cat.workflows.map((w) => this.isOptedOut(userId, w)),
);
const states = await Promise.all(cat.workflows.map((w) => this.isOptedOut(userId, w)));
return states.length > 0 && states.every(Boolean);
}

View File

@@ -155,26 +155,25 @@ export class NovuService {
amount: formatTryAmount(opts.amountKurus),
...(opts.plan ? { plan: opts.plan } : {}),
...(opts.nextBillingDate ? { nextBillingDate: formatTrDate(opts.nextBillingDate) } : {}),
ctaUrl: buildTrackedUrl(
"payment-success",
user.email,
webUrl("/dashboard/subscription"),
),
ctaUrl: buildTrackedUrl("payment-success", user.email, webUrl("/dashboard/subscription")),
});
}
/** Dunning notice — fired when a charge fails. amount is in kuruş. */
async paymentFailed(
user: NovuUser,
opts: { amountKurus?: number; retryDate?: Date | null },
opts: { amountKurus?: number; retryDate?: Date | null; invoiceUrl?: string | null },
): Promise<void> {
await this.trigger("payment-failed", user, {
...(opts.amountKurus !== undefined ? { amount: formatTryAmount(opts.amountKurus) } : {}),
...(opts.retryDate ? { retryDate: formatTrDate(opts.retryDate) } : {}),
// Renewal failures pass Stripe's hosted invoice page — the only surface
// where the user can actually pay / enter a new card. The dashboard
// fallback (checkout abandons) has no payment UI for an active sub.
ctaUrl: buildTrackedUrl(
"payment-failed",
user.email,
webUrl("/dashboard/subscription"),
opts.invoiceUrl ?? webUrl("/dashboard/subscription"),
),
});
}

View File

@@ -62,27 +62,34 @@ const NO_UNSUBSCRIBE_WORKFLOWS = new Set<string>([
"payment-failed",
]);
function buildUnsubscribeHeaders(
workflow: string,
subscriberId: string,
): Record<string, string> {
/**
* Signed HTTPS unsubscribe link for a (workflow, user) pair — the same URL the
* List-Unsubscribe header carries. GET renders a confirmation page, POST is
* the RFC 8058 one-click. Null for transactional flows or when the secret is
* unset (dev), so callers can skip the payload/header entirely.
*/
export function buildUnsubscribeUrl(workflow: string, subscriberId: string): string | null {
if (NO_UNSUBSCRIBE_WORKFLOWS.has(workflow)) return null;
if (!UNSUBSCRIBE_URL_BASE || !UNSUBSCRIBE_SECRET) return null;
const token = createHmac("sha256", UNSUBSCRIBE_SECRET)
.update(`${subscriberId}|${workflow}`)
.digest("hex");
const q = new URLSearchParams({ u: subscriberId, w: workflow, t: token });
return `${UNSUBSCRIBE_URL_BASE}?${q.toString()}`;
}
function buildUnsubscribeHeaders(workflow: string, subscriberId: string): Record<string, string> {
if (NO_UNSUBSCRIBE_WORKFLOWS.has(workflow)) return {};
const targets: string[] = [];
if (UNSUBSCRIBE_URL_BASE && UNSUBSCRIBE_SECRET) {
const token = createHmac("sha256", UNSUBSCRIBE_SECRET)
.update(`${subscriberId}|${workflow}`)
.digest("hex");
const q = new URLSearchParams({ u: subscriberId, w: workflow, t: token });
targets.push(`<${UNSUBSCRIBE_URL_BASE}?${q.toString()}>`);
}
const httpsUrl = buildUnsubscribeUrl(workflow, subscriberId);
if (httpsUrl) targets.push(`<${httpsUrl}>`);
targets.push(
`<mailto:${UNSUBSCRIBE_EMAIL}?subject=unsubscribe%3A${encodeURIComponent(workflow)}>`,
);
const headers: Record<string, string> = { "List-Unsubscribe": targets.join(", ") };
// RFC 8058 one-click — only assert when an HTTPS endpoint is wired; Gmail
// will probe the HTTPS target with POST when this header is present, so
// gate it behind both env vars being set.
if (UNSUBSCRIBE_URL_BASE && UNSUBSCRIBE_SECRET) {
// will probe the HTTPS target with POST when this header is present.
if (httpsUrl) {
headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click";
}
return headers;
@@ -179,7 +186,14 @@ export async function triggerNovu(
// Postal only AFTER the host-side Novu NodemailerProvider patch is applied —
// see postal/novu-patches/apply-headers-patch.sh.
const unsubHeaders = buildUnsubscribeHeaders(name, to.subscriberId);
const body: Record<string, unknown> = { name, to, payload };
// Visible body-footer variant of the same link — templates render it via
// `{{#if unsubscribeUrl}}` so mails stay valid when the secret is unset.
const unsubscribeUrl = buildUnsubscribeUrl(name, to.subscriberId);
const fullPayload =
unsubscribeUrl && payload.unsubscribeUrl === undefined
? { ...payload, unsubscribeUrl }
: payload;
const body: Record<string, unknown> = { name, to, payload: fullPayload };
if (Object.keys(unsubHeaders).length > 0) {
body.overrides = { email: { headers: unsubHeaders } };
}
@@ -192,9 +206,7 @@ export async function triggerNovu(
});
if (!res.ok) {
const errBody = await res.text().catch(() => "");
logger.error(
`[novu] trigger "${name}" failed: HTTP ${res.status} ${errBody.slice(0, 300)}`,
);
logger.error(`[novu] trigger "${name}" failed: HTTP ${res.status} ${errBody.slice(0, 300)}`);
return;
}
logger.log(`[novu] triggered "${name}" → ${to.email}`);

View File

@@ -35,12 +35,9 @@ export class UnsubscribeController {
private readonly preferences: EmailPreferencesService,
config: ConfigService,
) {
this.secret =
config.get<string>("UNSUBSCRIBE_SECRET") || process.env.UNSUBSCRIBE_SECRET || "";
this.secret = config.get<string>("UNSUBSCRIBE_SECRET") || process.env.UNSUBSCRIBE_SECRET || "";
if (!this.secret) {
this.logger.warn(
"UNSUBSCRIBE_SECRET is unset — all one-click requests will be rejected",
);
this.logger.warn("UNSUBSCRIBE_SECRET is unset — all one-click requests will be rejected");
}
}
@@ -76,7 +73,10 @@ export class UnsubscribeController {
@Res() res: Response,
): Promise<void> {
const ok = await this.applyOptOut(userId, workflow, token, "manual_link");
res.status(ok ? 200 : 400).type("html").send(renderPage(ok, workflow));
res
.status(ok ? 200 : 400)
.type("html")
.send(renderPage(ok, workflow));
}
/** Shared validation + DB update. Returns false on bad token / bad workflow. */
@@ -113,6 +113,7 @@ const WORKFLOW_LABELS: Record<string, string> = {
referral: "Davet hatırlatması",
"referral-qualified": "Davet bildirimleri",
"referral-reward": "Ödül bildirimleri",
conversion: "Kampanya mailleri",
};
/**
@@ -141,7 +142,7 @@ function renderPage(ok: boolean, workflow: string): string {
<h1 style="font-size:20px;margin:22px 0 14px;">Abonelikten çıkıldı</h1>
<p style="color:#4a4a4a;line-height:1.6;">Artık <strong>${escapeHtml(label)}</strong> almayacaksın. Hesabınla ilgili önemli bilgilendirme mailleri (e-posta doğrulama, ödeme bildirimleri) gelmeye devam eder.</p>
<p style="color:#777;font-size:14px;margin-top:24px;">Fikrini değiştirirsen ayarlar &gt; bildirimler sayfasından geri açabilirsin.</p>
<p style="margin-top:22px;"><a href="https://sase.tr/dashboard/settings/notifications" style="display:inline-block;background:#111;color:#fff;text-decoration:none;padding:12px 26px;border-radius:8px;font-weight:600;">Ayarları aç</a></p>
<p style="margin-top:22px;"><a href="https://sase.tr/dashboard/settings?tab=notifications" style="display:inline-block;background:#111;color:#fff;text-decoration:none;padding:12px 26px;border-radius:8px;font-weight:600;">Ayarları aç</a></p>
</main></body>`;
}

View File

@@ -6,10 +6,13 @@ import {
brandCompatible,
computeStats,
filterOffersForBrand,
isOeSupplyLabel,
istanbulToday,
normPartCode,
oeFamilyOf,
percentile,
reconstructDailySeries,
selectOeOffers,
} from "./part-prices.logic";
describe("normPartCode", () => {
@@ -174,17 +177,139 @@ describe("filterOffersForBrand", () => {
expect(filterOffersForBrand(offers, "", "27155")).toEqual([]);
});
it("uzun kodda markasız istek tüm teklifleri kullanır (OE vakası)", () => {
const oe = [
{ brandNorm: "MAIS", price: 195 },
{ brandNorm: "RENAULT", price: 247 },
it("uzun kodda markasız istek YALNIZ orijinal teklifleri kullanır", () => {
// 2026-07-14 vakası: orijinal+yan sanayi tek havuzda "hayalet medyan"
// üretiyordu (9827622780 → 4.495₺, hiçbir satıcının fiyatı değil).
// kokpit tur'una göre MAIS+OPAR orijinal; SAGEMFRANS/FEBI değil.
const mixed = [
{ brandNorm: "MAIS", price: 245 },
{ brandNorm: "OPAR", price: 247 },
{ brandNorm: "SAGEMFRANS", price: 210 },
{ brandNorm: "FEBI", price: 160 },
];
expect(filterOffersForBrand(oe, "", "8200768913")).toHaveLength(2);
const out = filterOffersForBrand(mixed, "", "8200768913");
expect(out.map((o) => o.brandNorm).sort()).toEqual(["MAIS", "OPAR"]);
});
it("uzun kod + uyumsuz marka etiketi → dağıtıcı fallback'i (tümü)", () => {
const oe = [{ brandNorm: "MAIS", price: 195 }];
expect(filterOffersForBrand(oe, "RENAULT", "8200768913")).toHaveLength(1);
it("markasız istekte hiç orijinal teklif yoksa boş (yan sanayi OE gibi gösterilmez)", () => {
const amOnly = [{ brandNorm: "BRUCKE", price: 890 }];
expect(filterOffersForBrand(amOnly, "", "46456072")).toEqual([]);
});
it("araç markası → SADECE o markanın OE ailesi (PSA vs OPAR ayrışır)", () => {
// 9827622780 vakası: aynı koda PSA (Peugeot ailesi) ve OPAR (Fiat ailesi)
// düşüyor. Peugeot kodu istenince yalnız PSA gelir, OPAR elenir → medyan
// artık iki dağıtıcının ortası (hayalet) değil.
const mixed = [
{ brandNorm: "PSA", price: 5531 },
{ brandNorm: "OPAR", price: 3459 },
{ brandNorm: "FEBI", price: 900 },
];
expect(filterOffersForBrand(mixed, "PEUGEOT", "9827622780")).toEqual([
{ brandNorm: "PSA", price: 5531 },
]);
// Aynı kod Fiat aracından istenirse OPAR gelir.
expect(filterOffersForBrand(mixed, "FIAT", "9827622780")).toEqual([
{ brandNorm: "OPAR", price: 3459 },
]);
});
it("araç markası dağıtıcı etiketine eşlenir (RENAULT → MAIS), yan sanayi elenir", () => {
const oe = [
{ brandNorm: "MAIS", price: 195 },
{ brandNorm: "FEBI", price: 90 },
{ brandNorm: "OPAR", price: 210 }, // farklı aile → elenir
];
expect(filterOffersForBrand(oe, "RENAULT", "8200768913")).toEqual([
{ brandNorm: "MAIS", price: 195 },
]);
});
it("araç markası + jenerik ORJINAL → elenir (aile belirsiz)", () => {
const oe = [
{ brandNorm: "PSA", price: 5531 },
{ brandNorm: "ORJINAL", price: 111 },
];
expect(filterOffersForBrand(oe, "CITROEN", "9827622780")).toEqual([
{ brandNorm: "PSA", price: 5531 },
]);
});
it("yan sanayi markası çipi → yalnız etiketi uyumlular, OE'ye düşmez", () => {
const oe = [
{ brandNorm: "MAIS", price: 195 },
{ brandNorm: "FEBI", price: 160 },
];
// FEBI çipi FEBI'yi alır; MAIS (orijinal) gösterilmez.
expect(filterOffersForBrand(oe, "FEBIBILSTEIN", "8200768913")).toEqual([
{ brandNorm: "FEBI", price: 160 },
]);
});
});
describe("isOeSupplyLabel / selectOeOffers (OE aileleri + jenerik ORJINAL)", () => {
it("dağıtıcı ve orijinal etiketleri tanır", () => {
expect(isOeSupplyLabel("MAIS")).toBe(true); // Renault dağıtıcısı
expect(isOeSupplyLabel("OPAR")).toBe(true); // Fiat/Tofaş
expect(isOeSupplyLabel("PSA")).toBe(true);
expect(isOeSupplyLabel("ORJINAL")).toBe(true); // jenerik orijinal
expect(isOeSupplyLabel("VECO")).toBe(true); // Iveco OE alias'ı
});
it("ham araç-marka etiketleri de ORİJİNALDİR (kokpit'in yansanayi demesi yanlış)", () => {
// 2026-07-14 kullanıcı düzeltmesi: FORD/GM/BMW/Mercedes/VW OE etiketidir.
expect(isOeSupplyLabel("FORD")).toBe(true);
expect(isOeSupplyLabel("GM")).toBe(true); // Opel OE'si
expect(isOeSupplyLabel("BMW")).toBe(true);
expect(isOeSupplyLabel("MERCEDES")).toBe(true);
expect(isOeSupplyLabel("VOLKSWAGEN")).toBe(true);
expect(isOeSupplyLabel("RENAULT")).toBe(true);
});
it("gerçek yan sanayi PARÇA markaları orijinal DEĞİL", () => {
expect(isOeSupplyLabel("BOSCH")).toBe(false);
expect(isOeSupplyLabel("FEBI")).toBe(false);
expect(isOeSupplyLabel("VALEO")).toBe(false);
expect(isOeSupplyLabel("TRW")).toBe(false);
expect(isOeSupplyLabel("")).toBe(false);
});
it("GM Opel ailesine, FORD kendi ailesine eşlenir", () => {
expect(oeFamilyOf("GM")).toBe("OPEL");
expect(oeFamilyOf("FORD")).toBe("FORD");
expect(oeFamilyOf("MERCEDES")).toBe("MERCEDES");
});
it("selectOeOffers karma havuzdan orijinalleri (araç markaları dahil) seçer", () => {
const out = selectOeOffers([
{ brandNorm: "OPAR", price: 767 },
{ brandNorm: "FORD", price: 480 }, // artık orijinal
{ brandNorm: "IBR", price: 909 }, // İbraş = yan sanayi
]);
expect(out.map((o) => o.brandNorm).sort()).toEqual(["FORD", "OPAR"]);
});
it("Ford kodu araç markası FORD → FORD teklifi gelir", () => {
const offers = [
{ brandNorm: "FORD", price: 480 },
{ brandNorm: "BOSCH", price: 300 },
];
expect(filterOffersForBrand(offers, "FORD", "1234567890")).toEqual([
{ brandNorm: "FORD", price: 480 },
]);
});
it("Opel kodu → OPEL + GM aynı aile, ikisi de gelir; PSA farklı aile elenir", () => {
const offers = [
{ brandNorm: "GM", price: 500 },
{ brandNorm: "OPEL", price: 520 },
{ brandNorm: "PSA", price: 610 },
];
expect(
filterOffersForBrand(offers, "OPEL", "9827622780")
.map((o) => o.brandNorm)
.sort(),
).toEqual(["GM", "OPEL"]);
});
});

View File

@@ -31,6 +31,104 @@ export function normPartCode(code: string): string {
// süzülür. Tedarikçiler markayı kısaltarak yazar (BCH/B→Bosch, BLP→Blue
// Print, IBR→İbraş) — eşleşme önek VEYA sıralı-altdizi ile yapılır.
// ─── Orijinal (OE) etiketleri ve marka aileleri ────────────────────────────
// Markasız/araç-markalı bağlam (OEM detay kartı) yalnız ORİJİNAL teklifleri
// gösterir: orijinal + yan sanayi tek havuzda medyanlanınca hiçbir gerçek
// ürünün fiyatı olmayan "hayalet fiyat" çıkar (2026-07-14: 9827622780 →
// 4.495₺ = PSA 5.531 ile OPAR 3.459'un ortası).
//
// Bir OEM kodu tek bir araç markasına aittir → teklifleri o markanın OE
// ailesine süzeriz (aynı koda düşen PSA vs OPAR karışmaz). `family → üyeler`;
// üyeler hem araç markası adları (istek/sase brands) hem dağıtıcı etiketleridir
// (teklif/takip: MAIS=Renault, OPAR=Fiat, PSA, VECO=Iveco, GM=Opel).
//
// ORİJİNAL TESPİTİ: bir teklif etiketi orijinal sayılır ancak bir OE ailesinin
// üyesiyse ya da jenerik "ORJINAL" ise. Kaynak = sase araç markaları + Türkiye
// OE-dağıtıcı etiketleri. NOT (2026-07-14 kullanıcı düzeltmesi): ham araç-marka
// etiketleri (FORD, GM, BMW, MERCEDES, VOLKSWAGEN, RENAULT, FIAT…) ORİJİNALDİR
// — kokpit `tur` bunları 'yansanayi' sayıyordu, bu YANLIŞ; burada aileleriyle
// birlikte orijinal kabul edilir. Gerçek yan sanayi PARÇA markaları (Bosch/
// Febi/Valeo/TRW…) hiçbir ailede değildir → orijinal sayılmaz.
const OE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
RENAULT: ["RENAULT", "DACIA", "ALPINE", "MAIS"],
FIAT: ["FIAT", "ALFAROMEO", "LANCIA", "ABARTH", "OPAR", "TOFAS"],
PSA: ["PEUGEOT", "CITROEN", "DS", "PSA"],
OPEL: ["OPEL", "VAUXHALL", "GM"], // GM = General Motors, TR'de Opel OE'si
VAG: ["VOLKSWAGEN", "VW", "AUDI", "SEAT", "SKODA", "CUPRA", "BENTLEY"],
FORD: ["FORD"],
BMW: ["BMW", "MINI"],
MERCEDES: ["MERCEDESBENZ", "MERCEDES", "SMART"],
TOYOTA: ["TOYOTA", "TOYOTAORJINAL", "LEXUS"],
HONDA: ["HONDA"],
NISSAN: ["NISSAN", "INFINITI"],
HYUNDAI: ["HYUNDAI"],
KIA: ["KIA"],
MAZDA: ["MAZDA"],
VOLVO: ["VOLVO", "POLESTAR"],
PORSCHE: ["PORSCHE"],
JLR: ["JAGUAR", "LANDROVER"],
MAN: ["MAN"],
MITSUBISHI: ["MITSUBISHI"],
SUBARU: ["SUBARU"],
SUZUKI: ["SUZUKI"],
IVECO: ["IVECO", "VECO", "OEVECO"],
// sase araç listesinde olmayan ama teklif/istek olarak gelebilecek markalar.
CHRYSLER: ["CHRYSLER", "DODGE", "JEEP", "MOPAR", "LINCOLN", "BUICK"],
CHEVROLET: ["CHEVROLET"],
DAEWOO: ["DAEWOO"],
DAIHATSU: ["DAIHATSU"],
DFM: ["DFM"],
LADA: ["LADA"],
PROTON: ["PROTON"],
ROVER: ["ROVER", "RANGE"],
SAAB: ["SAAB"],
SSANGYONG: ["SSANGYONG"],
TATA: ["TATA"],
TESLA: ["TESLA"],
TOGG: ["TOGG"],
};
const OE_FAMILY_OF: ReadonlyMap<string, string> = new Map(
Object.entries(OE_FAMILIES).flatMap(([family, members]) =>
members.map((m) => [m, family] as [string, string]),
),
);
/** Markanın (araç ya da dağıtıcı etiketi) OE ailesi; bilinmiyorsa null.
* Jenerik "ORJINAL" hiçbir aileye ait değildir (markası belirsiz) → null. */
export function oeFamilyOf(brandNorm: string): string | null {
return OE_FAMILY_OF.get(brandNorm) ?? null;
}
// Orijinal etiket seti = tüm OE aile üyeleri + jenerik "ORJINAL" (orijinal ama
// aile-belirsiz: markasız/taban görünümde sayılır, aile filtresinde elenir).
// Aileden TÜRETİLİR → OE-tespiti ile aile-filtresi asla drift etmez; yeni bir
// marka eklemek için tek yer OE_FAMILIES.
const OE_SUPPLY_LABELS: ReadonlySet<string> = new Set([
...Object.values(OE_FAMILIES).flat(),
"ORJINAL",
]);
/** Teklifin etiketi orijinal mi (bir OE ailesinin üyesi ya da jenerik ORJINAL). */
export function isOeSupplyLabel(brandNorm: string): boolean {
return OE_SUPPLY_LABELS.has(brandNorm);
}
/** Yalnız orijinal (OE) etiketli teklifler. */
export function selectOeOffers<T extends { brandNorm: string }>(offers: T[]): T[] {
return offers.filter((o) => isOeSupplyLabel(o.brandNorm));
}
// ─── Redis cache anahtarları ───────────────────────────────────────────────
// Service ve worker processor'ı AYNI üreticileri kullanır (literal kopya
// drift'i olmasın). v4 = OE seti araç-marka etiketlerini (FORD/GM/BMW/MERCEDES/
// VW…) de kapsayacak şekilde genişledi → v3'teki "miss" değerleri bayat.
export const PART_PRICE_CACHE_VERSION = "v4";
export const partPriceSeriesCacheKey = (codeNorm: string, brandNorm: string) =>
`partprice:series:${PART_PRICE_CACHE_VERSION}:${codeNorm}::${brandNorm}`;
export const partPriceCurrentCacheKey = (codeNorm: string, brandNorm: string) =>
`partprice:cur:${PART_PRICE_CACHE_VERSION}:${codeNorm}::${brandNorm}`;
/** a'nın tüm karakterleri b içinde aynı sırayla geçiyor mu (BCH ⊂ BOSCH). */
export function inOrderSubsequence(a: string, b: string): boolean {
let i = 0;
@@ -61,10 +159,17 @@ export function allowBrandless(codeNorm: string): boolean {
/**
* Teklifleri istenen markaya süz:
* - marka istendi → uyumlular; hiçbiri uymuyorsa ve kod markasız-güvenliyse
* hepsi (OE/dağıtıcı etiketi vakası: RENAULT istenir, teklifler MAIS taşır);
* kısa kodda boş (yanlış veri göstermekten iyidir),
* - marka istenmedi → kod markasız-güvenliyse hepsi, değilse boş.
* - istenen marka bir ARAÇ/OE markasıysa (OEM kartına o kodun araç markası
* geçer, ör. PEUGEOT) → yalnız o markanın OE ailesinden orijinal teklifler
* (PEUGEOT → PSA ailesi; OPAR/MAIS gibi başka aile dağıtıcıları elenir).
* Böylece aynı koda düşen PSA vs OPAR karışıp medyanı bozmaz. Jenerik
* "ORJINAL" (aile belirsiz) bu süzgeçte elenir,
* - istenen marka bir YAN SANAYİ markasıysa (FEBI çipi) → etiketi uyumlu
* teklifler (kısaltma/önek eşleşmesi); OE'ye düşme YOK,
* - marka istenmedi (kodun markası bilinmiyor) → yalnız orijinal (tüm OE
* aileleri) — güvenli taban; yan sanayi karışımı hayalet medyan üretirdi,
* - kısa/çakışmaya açık kodlarda (allowBrandless=false) araç-markası ve
* markasız yolları kapalıdır (yanlış veri göstermekten iyidir).
*/
export function filterOffersForBrand<T extends { brandNorm: string }>(
offers: T[],
@@ -72,11 +177,17 @@ export function filterOffersForBrand<T extends { brandNorm: string }>(
codeNorm: string,
): T[] {
if (requestedBrandNorm) {
const compat = offers.filter((o) => brandCompatible(o.brandNorm, requestedBrandNorm));
if (compat.length > 0) return compat;
return allowBrandless(codeNorm) ? offers : [];
const family = oeFamilyOf(requestedBrandNorm);
if (family) {
if (!allowBrandless(codeNorm)) return [];
return offers.filter(
(o) => isOeSupplyLabel(o.brandNorm) && oeFamilyOf(o.brandNorm) === family,
);
}
// Aile eşlemesi yok → yan sanayi markası: etiket uyumuna göre süz.
return offers.filter((o) => brandCompatible(o.brandNorm, requestedBrandNorm));
}
return allowBrandless(codeNorm) ? offers : [];
return allowBrandless(codeNorm) ? selectOeOffers(offers) : [];
}
export interface SupplierOffer {

View File

@@ -17,6 +17,8 @@ import {
filterOffersForBrand,
istanbulToday,
normPartCode,
partPriceCurrentCacheKey,
partPriceSeriesCacheKey,
reconstructDailySeries,
} from "./part-prices.logic";
import { type SupplierPriceSource, createSupplierPriceSource } from "./supplier-price-source";
@@ -54,10 +56,8 @@ const BATCH_CACHE_TTL = 1800;
const MAX_BATCH_PARTS = 400;
export const partPriceKey = (codeNorm: string, brandNorm: string) => `${codeNorm}::${brandNorm}`;
const seriesKey = (codeNorm: string, brandNorm: string) =>
`partprice:series:v2:${codeNorm}::${brandNorm}`;
const currentKey = (codeNorm: string, brandNorm: string) =>
`partprice:cur:v2:${codeNorm}::${brandNorm}`;
const seriesKey = partPriceSeriesCacheKey;
const currentKey = partPriceCurrentCacheKey;
/**
* Parça (kod + marka) bazlı tedarikçi fiyat görünümleri. P-servisi sözleşmesi:
@@ -67,7 +67,8 @@ const currentKey = (codeNorm: string, brandNorm: string) =>
* Kimlik (kod, marka): kısa sayısal kodlar markalar arası çakışır (FEBI 27155
* ≠ GROS 27155 ≠ İBRAŞ 27155 — farklı fiziksel parçalar). Teklifler sku_map'in
* marka etiketiyle istenen markaya süzülür (filterOffersForBrand); markasız
* sorgu yalnızca uzun/benzersiz kodlarda tüm teklifleri kullanır.
* sorgu (OEM detay ana kartı) yalnızca ORİJİNAL (OE/dağıtıcı) teklifleri
* kullanır — orijinal+yan-sanayi havuzu hayalet medyan üretir (2026-07-14).
*
* - Seri: parça ilk kez istendiğinde takip history'sinden lazy-backfill edilir
* ve pg'ye (part_price_tracks + part_price_daily) kalıcı yazılır; sonraki

View File

@@ -147,13 +147,15 @@ export class PartsService {
* Reverse catalog: the user's own decoded vehicles whose parts list contains
* this exact OEM code. Powers the "bu kod kataloğunuzda şu araçlarda var"
* section of the OEM detail page — pure sase data, no TecDoc/vehicle-structure
* dependency. Exact match on the indexed `oem_code` (the code came from a real
* part row, so the spelling matches). One representative `categoryId` per
* vehicle lets the UI deep-link straight to a schema page showing the part.
* dependency. NORMALIZE-eşleşme: URL kodu boşluksuz ("9827622780") gelirken
* katalog aynı kodu boşluklu saklayabilir ("9827 622 780" — özellikle PSA);
* exact match bunları kaçırıyordu (fiyat kartı marka çözümü de buna dayanır).
* `parts_oem_code_norm_idx` functional index'iyle aynı ifade → index kullanır.
* Vitrin `categoryId`'si UI'ı doğrudan şema sayfasına götürür.
*/
async vehiclesByOem(oemCode: string) {
const code = (oemCode ?? "").trim();
if (!code) return [];
const norm = (oemCode ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "");
if (!norm) return [];
return this.db
.select({
vehicleId: vehicles.id,
@@ -165,7 +167,7 @@ export class PartsService {
})
.from(parts)
.innerJoin(vehicles, eq(parts.vehicleId, vehicles.id))
.where(eq(parts.oemCode, code))
.where(sql`regexp_replace(upper(${parts.oemCode}), '[^A-Z0-9]', '', 'g') = ${norm}`)
.groupBy(vehicles.id, vehicles.brandName, vehicles.model, vehicles.year)
.orderBy(vehicles.brandName, vehicles.model)
.limit(50);

View File

@@ -163,16 +163,26 @@ describe("StripeService recurring webhooks", () => {
});
describe("handleInvoiceFailed", () => {
it("mails dunning with Stripe's retry date and leaves the paid-through date alone", async () => {
it("persists dunning state, mails with retry date + hosted invoice URL, leaves end_date alone", async () => {
// selects: resolve sub → user
const { service, db, posthog, novu } = createMocks([[activeSub], [user]]);
const { service, db, updateChains, posthog, novu } = createMocks([[activeSub], [user]]);
await service.handleInvoiceFailed(invoiceFixture());
await service.handleInvoiceFailed(
invoiceFixture({ attempt_count: 1, hosted_invoice_url: "https://invoice.stripe.com/i/x" }),
);
// dunning stamped (so /subscriptions/me + banner can surface it) — but
// end_date untouched: access keeps running to the paid-through date.
expect(db.update).toHaveBeenCalledTimes(1);
const setArg = updateChains[0].set.mock.calls[0][0] as Record<string, unknown>;
expect(setArg.dunningSince).toBeInstanceOf(Date);
expect(setArg.dunningInvoiceUrl).toBe("https://invoice.stripe.com/i/x");
expect(setArg.endDate).toBeUndefined();
expect(db.update).not.toHaveBeenCalled(); // access keeps running to end_date
expect(novu.paymentFailed).toHaveBeenCalledTimes(1);
const opts = novu.paymentFailed.mock.calls[0][1] as { retryDate?: Date };
const opts = novu.paymentFailed.mock.calls[0][1] as { retryDate?: Date; invoiceUrl?: string };
expect(opts.retryDate?.getTime()).toBe(RETRY_AT_SEC * 1000);
expect(opts.invoiceUrl).toBe("https://invoice.stripe.com/i/x");
expect(posthog.captureForUser).toHaveBeenCalledWith(
"user-1",
"payment_failed",
@@ -180,6 +190,28 @@ describe("StripeService recurring webhooks", () => {
);
});
it("suppresses the mail on non-milestone attempts but still records the failure", async () => {
const { service, db, posthog, novu } = createMocks([[activeSub], [user]]);
await service.handleInvoiceFailed(invoiceFixture({ attempt_count: 2 }));
expect(db.update).toHaveBeenCalledTimes(1); // dunning state still stamped
expect(posthog.captureForUser).toHaveBeenCalledTimes(1);
expect(novu.paymentFailed).not.toHaveBeenCalled(); // attempt 2 ≠ 1/3/final
});
it("sends the final notice when Stripe schedules no further retry", async () => {
const { service, novu } = createMocks([[activeSub], [user]]);
await service.handleInvoiceFailed(
invoiceFixture({ attempt_count: 7, next_payment_attempt: null }),
);
expect(novu.paymentFailed).toHaveBeenCalledTimes(1);
const opts = novu.paymentFailed.mock.calls[0][1] as { retryDate?: Date | null };
expect(opts.retryDate).toBeNull();
});
it("ignores first-charge failures (checkout flow owns those)", async () => {
const { service, posthog, novu } = createMocks([[activeSub]]);

View File

@@ -634,17 +634,24 @@ export class StripeService {
if (!recovered) {
// Normal renewal — extend. A 'cancelled' row that still got billed keeps
// its status; access is governed by end_date either way.
// its status; access is governed by end_date either way. A paid invoice
// also ends any dunning episode.
await this.db
.update(userSubscriptions)
.set({ endDate: newEndDate, updatedAt: now })
.set({ endDate: newEndDate, dunningSince: null, dunningInvoiceUrl: null, updatedAt: now })
.where(eq(userSubscriptions.id, sub.id));
} else {
// Late dunning recovery: the nightly cron already expired the row and
// purged its brand grants. Restore access for the freshly paid period.
await this.db
.update(userSubscriptions)
.set({ status: "active", endDate: newEndDate, updatedAt: now })
.set({
status: "active",
endDate: newEndDate,
dunningSince: null,
dunningInvoiceUrl: null,
updatedAt: now,
})
.where(eq(userSubscriptions.id, sub.id));
if (plan?.brandCount === 0) {
await this.db.delete(userBrands).where(eq(userBrands.subscriptionId, sub.id));
@@ -745,6 +752,19 @@ export class StripeService {
const retryDate = invoice.next_payment_attempt
? new Date(invoice.next_payment_attempt * 1000)
: null;
const hostedUrl = invoice.hosted_invoice_url ?? null;
// Persist the dunning episode so /subscriptions/me (and the dashboard
// banner) can surface it — before this, the app had no record that a
// subscription was failing and the user saw nothing until hard cutoff.
await this.db
.update(userSubscriptions)
.set({
dunningSince: sub.dunningSince ?? new Date(),
...(hostedUrl ? { dunningInvoiceUrl: hostedUrl } : {}),
updatedAt: new Date(),
})
.where(eq(userSubscriptions.id, sub.id));
this.posthog.captureForUser(sub.userId, "payment_failed", {
method: "stripe",
@@ -752,28 +772,42 @@ export class StripeService {
reason: "renewal_charge_failed",
amount: invoice.amount_due ?? null,
stripe_invoice_id: invoice.id,
attempt_count: invoice.attempt_count ?? null,
next_retry_at: retryDate ? retryDate.toISOString() : null,
});
try {
const [user] = await this.db
.select({ id: users.id, email: users.email, name: users.name })
.from(users)
.where(eq(users.id, sub.userId))
.limit(1);
if (user) {
await this.novu.paymentFailed(user, {
amountKurus: invoice.amount_due ?? undefined,
retryDate,
});
// Mail on milestones only — first failure, mid-cycle nudge, final notice.
// Stripe retries ~4-9 times; one identical mail per attempt trained users
// to ignore them (measured recovery: 0%).
const attempt = invoice.attempt_count ?? 1;
const isFinal = !invoice.next_payment_attempt;
const shouldMail = attempt <= 1 || attempt === 3 || isFinal;
if (shouldMail) {
try {
const [user] = await this.db
.select({ id: users.id, email: users.email, name: users.name })
.from(users)
.where(eq(users.id, sub.userId))
.limit(1);
if (user) {
await this.novu.paymentFailed(user, {
amountKurus: invoice.amount_due ?? undefined,
retryDate,
// CTA goes to Stripe's hosted invoice page (pay now / new card /
// 3DS) — the dashboard has no self-serve payment surface.
invoiceUrl: hostedUrl,
});
}
} catch (err) {
this.logger.error(`renewal dunning mail failed (user=${sub.userId}): ${String(err)}`);
}
} catch (err) {
this.logger.error(`renewal dunning mail failed (user=${sub.userId}): ${String(err)}`);
}
this.logger.warn(
`Renewal charge failed: sub ${sub.id} (invoice ${invoice.id}), ` +
`next retry ${retryDate ? retryDate.toISOString() : "none (final)"}`,
`Renewal charge failed: sub ${sub.id} (invoice ${invoice.id}, attempt ${attempt}), ` +
`next retry ${retryDate ? retryDate.toISOString() : "none (final)"}` +
`${shouldMail ? "" : " — mail suppressed (non-milestone attempt)"}`,
);
}
@@ -790,14 +824,35 @@ export class StripeService {
.limit(1);
if (!sub) return;
const wasDunning = !!sub.dunningSince;
// Flip to 'cancelled' (not just stamp cancelledAt): create() rejects new
// checkouts only while a row is 'active', so leaving a dunning-cancelled
// sub as 'active' silently BLOCKED the customer from re-subscribing — one
// of the root causes of 0% dunning recovery. Access still runs to end_date
// via the nightly expiry cron.
await this.db
.update(userSubscriptions)
.set({ cancelledAt: sub.cancelledAt ?? new Date(), updatedAt: new Date() })
.set({
status: "cancelled",
cancelledAt: sub.cancelledAt ?? new Date(),
dunningSince: null,
dunningInvoiceUrl: null,
updatedAt: new Date(),
})
.where(eq(userSubscriptions.id, sub.id));
this.logger.log(
`Stripe subscription ${subscription.id} deleted — our sub ${sub.id} ` +
`(status=${sub.status}) will not renew; access runs out at its end_date`,
// Churn was previously invisible in analytics (this handler captured
// nothing) — reason distinguishes dunning losses from voluntary cancels.
this.posthog.captureForUser(sub.userId, "subscription_churned", {
reason: wasDunning ? "payment_failure" : "cancelled",
subscription_id: sub.id,
stripe_subscription_id: subscription.id,
prior_status: sub.status,
});
this.logger.warn(
`Stripe subscription ${subscription.id} deleted (${wasDunning ? "dunning exhausted" : "cancel executed"}) — ` +
`our sub ${sub.id} → cancelled; access runs out at its end_date`,
);
}

View File

@@ -21,6 +21,7 @@ import {
ApiTags,
} from "@nestjs/swagger";
import type { Response } from "express";
import { CatalogService } from "../catalog/catalog.service";
import { CategoriesService } from "../categories/categories.service";
import { Public } from "../common/decorators/public.decorator";
import { ApiKeyGuard, type VerifiedApiKey } from "../common/guards/api-key.guard";
@@ -28,6 +29,7 @@ import { VinValidationPipe } from "../common/pipes/vin-validation.pipe";
import { PSourceDbService } from "../integrations/p/p-source-db.service";
import { VehiclesService } from "../vehicles/vehicles.service";
import { type DecodeQuotaState, PublicApiQuotaService } from "./public-api-quota.service";
import { sanitizeCanonicalTree, sanitizeRawTree } from "./public-api.sanitize";
interface ApiKeyRequest {
user: { id: string };
@@ -54,6 +56,7 @@ export class PublicApiController {
constructor(
private readonly vehicles: VehiclesService,
private readonly categories: CategoriesService,
private readonly catalog: CatalogService,
private readonly p: PSourceDbService,
private readonly quota: PublicApiQuotaService,
) {}
@@ -196,25 +199,64 @@ export class PublicApiController {
}
/**
* Decode edilmiş aracın kategori ağacı (kök seviye + alt-ağaç parça
* sayıları). Gezinme kotadan düşmez.
* Kanonik taksonomi: araçtan bağımsız, kaynak-bağımsız SABİT kategori yapısı
* (20 üst kova + kürate alt-kategoriler). B2C entegrasyonların kendi
* navigasyon/filtre yapısını kurduğu stabil kontrat — araç ağaçlarındaki
* canonical.slug değerleri bu yapıya çözümlenir. Kotadan düşmez.
*/
@Get("taxonomy")
@ApiTags("Katalog")
@ApiOperation({
summary: "Kanonik taksonomi (sabit yapı)",
description:
"Araçtan bağımsız birleşik kategori yapısını döner: 20 üst kova ve her " +
"birinin kürate alt-kategorileri ({ id, slug, name, subcategories[] }). " +
"Araç kategori ağacındaki canonical.slug değerleri bu yapıya çözümlenir; " +
"kendi navigasyonunuzu/filtrelerinizi bu stabil kontrat üzerine kurun. " +
"`mappedCategories` her düğüme halihazırda eşlenmiş kaynak-kategori " +
"sayısıdır (doluluk sinyali). Kotadan düşmez.",
})
async taxonomy() {
return this.categories.getCanonicalTaxonomy();
}
/**
* Decode edilmiş aracın kategori ağacı. **Varsayılan = birleşik (kanonik)
* görünüm:** kaynak (parts-catalogs/emex/pl24) fark etmeksizin tek, normalize
* 20-kova taksonomisi + alt-kategoriler. `?view=raw` ile aracın ham kaynak
* ağacı alınır (kaynağa özgü, düzensiz — plaka-kodu yaprakları olan bazı yeni
* PSA/Renault kataloglarında birleşik görünüm zayıfsa kaçış kapısı). Gezinme
* kotadan düşmez. `source` gibi iç alanlar hiçbir görünümde dönmez.
*/
@Get("vehicles/:vehicleId/categories")
@ApiTags("Katalog")
@ApiOperation({
summary: "Kategori ağacı",
summary: "Kategori ağacı (varsayılan: birleşik/kanonik)",
description:
"Decode cevabındaki araç id'siyle kategori ağacını döner. Ağaç soğuksa " +
"ilk çağrıda üst seviye tohumlanır ve arka planda derinleştirilir — " +
"kısa aralıklarla tekrar çağırmak ağacı zenginleştirir. Kotadan düşmez.",
"Varsayılan olarak kaynak-bağımsız **birleşik** taksonomiyi döner: 20 üst " +
"kova ({ canonical:{slug,name}, partCount, categories[], subgroups[] }); " +
"her yaprak `categories[].id` ile kategori-detay ucuna drill edilir. " +
"`?view=raw` aracın ham kaynak ağacını döner ({ id, name, partCount, " +
"children[] }). Ağaç soğuksa ilk çağrıda tohumlanır, arka planda derinleşir.",
})
@ApiParam({ name: "vehicleId", format: "uuid", description: "Decode cevabındaki araç id'si" })
@ApiQuery({
name: "view",
required: false,
enum: ["canonical", "raw"],
description: "Varsayılan `canonical` (birleşik). `raw` = ham kaynak ağacı.",
})
@ApiResponse({ status: 404, description: "Araç bulunamadı." })
async categoryTree(@Param("vehicleId") vehicleId: string) {
async categoryTree(@Param("vehicleId") vehicleId: string, @Query("view") view?: string) {
// Decode edilmiş araçlar paylaşımlı — anahtar sahibi her kullanıcı okuyabilir
// (dashboard'daki kuralla aynı); 404 için varlık kontrolü.
await this.vehicles.getById(vehicleId);
return this.categories.getCategoryTree(vehicleId);
if (view === "raw") {
return sanitizeRawTree(await this.categories.getCategoryTree(vehicleId));
}
// getCanonicalTree içeride getCategoryTree'yi çağırır (ham ağacı tohumlar),
// sonra 20 kanonik kova altında gruplar (materyalize + anlık lexicon fallback).
return sanitizeCanonicalTree(await this.categories.getCanonicalTree(vehicleId));
}
/**
@@ -272,6 +314,149 @@ export class PublicApiController {
return this.categories.searchCatalog(vehicleId, q ?? "");
}
// ==========================================================================
// Katalog gezinme (VIN'siz araç ağacı). VIN decode ile AYNI kategori/parça
// akışına ulaşır; fark: araç, marka→model→(kasa/motor/şanzıman) seçimiyle
// belirlenir. Marka erişimi API-anahtarı sahibinin (user.id) aboneliğine
// bağlıdır. Gezinme kotadan düşmez (yalnız başarılı VIN identification düşer).
// ==========================================================================
/** Katalog markaları (anahtar sahibinin erişebildiği). */
@Get("catalog/brands")
@ApiTags("Katalog Gezinme")
@ApiOperation({ summary: "Markalar", description: "Araç ağacı wizard'ı için marka listesi." })
async catalogBrands(@Req() req: ApiKeyRequest) {
return this.catalog.getBrands(req.user.id);
}
/** Bir markanın modelleri (araçları). */
@Get("catalog/brands/:brandName/models")
@ApiTags("Katalog Gezinme")
@ApiOperation({ summary: "Modeller", description: "Seçilen markanın model/araç listesi." })
@ApiParam({ name: "brandName", example: "Fiat" })
@ApiQuery({ name: "service", required: false, description: "Katalog servis adı (opsiyonel filtre)" })
async catalogModels(
@Req() req: ApiKeyRequest,
@Param("brandName") brandName: string,
@Query("service") service?: string,
) {
return this.catalog.getModels(brandName, req.user.id, service);
}
/** Araç detayı (mimari + seçim adımları için meta). */
@Get("catalog/vehicles/:id")
@ApiTags("Katalog Gezinme")
@ApiOperation({ summary: "Araç detayı" })
@ApiParam({ name: "id", format: "uuid" })
async catalogVehicle(@Req() req: ApiKeyRequest, @Param("id") id: string) {
return this.catalog.getVehicle(id, req.user.id);
}
/** Ford model konfigürasyonu (Ford mimarisi). */
@Get("catalog/vehicles/:id/ford-config")
@ApiTags("Katalog Gezinme")
async catalogFordConfig(@Req() req: ApiKeyRequest, @Param("id") id: string) {
return this.catalog.getFordModelConfig(id, req.user.id);
}
/** PSA kasa seçenekleri. */
@Get("catalog/vehicles/:id/psa-bodies")
@ApiTags("Katalog Gezinme")
async catalogPsaBodies(@Req() req: ApiKeyRequest, @Param("id") id: string) {
return this.catalog.getPsaBodies(id, req.user.id);
}
/** PSA motor seçenekleri (kasa seçimine bağlı). */
@Get("catalog/vehicles/:id/psa-engines")
@ApiTags("Katalog Gezinme")
@ApiQuery({ name: "body", description: "Seçilen kasa" })
async catalogPsaEngines(
@Req() req: ApiKeyRequest,
@Param("id") id: string,
@Query("body") body: string,
) {
return this.catalog.getPsaEngines(id, body, req.user.id);
}
/** PSA şanzıman seçenekleri (kasa + motor seçimine bağlı). */
@Get("catalog/vehicles/:id/psa-gearboxes")
@ApiTags("Katalog Gezinme")
@ApiQuery({ name: "body" })
@ApiQuery({ name: "engine" })
async catalogPsaGearboxes(
@Req() req: ApiKeyRequest,
@Param("id") id: string,
@Query("body") body: string,
@Query("engine") engine: string,
) {
return this.catalog.getPsaGearboxes(id, body, engine, req.user.id);
}
/**
* Seçilen aracın kategori ağacı (birleşik/kanonik). Public v1 kontratıyla
* aynı şekil ({ canonical, partCount, categories[], subgroups[] }); iç
* alanlar (source vb.) sanitize edilir. Kasa/motor/şanzıman gerektiren
* mimarilerde query ile verilir.
*/
@Get("catalog/vehicles/:id/categories")
@ApiTags("Katalog Gezinme")
@ApiOperation({ summary: "Kategori ağacı (araç seçiminden)" })
@ApiQuery({ name: "body", required: false })
@ApiQuery({ name: "engine", required: false })
@ApiQuery({ name: "gearbox", required: false })
@ApiQuery({ name: "mgp", required: false, description: "mainGroupsPath" })
async catalogCategoryTree(
@Req() req: ApiKeyRequest,
@Param("id") id: string,
@Query("body") body?: string,
@Query("engine") engine?: string,
@Query("gearbox") gearbox?: string,
@Query("mgp") mgp?: string,
) {
return sanitizeCanonicalTree(
(await this.catalog.getCanonicalTree(id, req.user.id, body, engine, gearbox, mgp)) as never,
);
}
/** Seçilen aracın kategori detayı + parçaları (OEM kodlarıyla). */
@Get("catalog/vehicles/:id/categories/:categoryId")
@ApiTags("Katalog Gezinme")
@ApiQuery({ name: "body", required: false })
@ApiQuery({ name: "engine", required: false })
@ApiQuery({ name: "gearbox", required: false })
async catalogCategoryParts(
@Req() req: ApiKeyRequest,
@Param("id") id: string,
@Param("categoryId") categoryId: string,
@Query("body") body?: string,
@Query("engine") engine?: string,
@Query("gearbox") gearbox?: string,
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const detail: any = await this.catalog.getCategoryWithParts(
id,
categoryId,
req.user.id,
body,
engine,
gearbox,
);
return {
...detail,
parts: (detail.parts ?? []).map((p: Record<string, unknown>) => ({
id: p.id,
name: p.name,
description: p.description,
oemCode: p.oemCode,
position: p.position ?? p.hotspotIndex ?? null,
quantity: p.quantity,
remark: p.remark,
unavailable: p.unavailable,
categoryId: p.categoryId,
})),
};
}
private setQuotaHeaders(res: Response, state: DecodeQuotaState): void {
res.setHeader("X-Decode-Quota-Limit", String(state.limit));
res.setHeader("X-Decode-Quota-Used", String(state.used));

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