Compare commits
409 Commits
blog-cms-a
...
feat/rpart
| Author | SHA1 | Date | |
|---|---|---|---|
| 71568e2274 | |||
| 41147f3f05 | |||
| e03d721c37 | |||
| 142ec3a150 | |||
| e8ffa6a5db | |||
| bd9f4bd3f8 | |||
| d75c468341 | |||
| a4e22e1bb8 | |||
| c2f4d8f421 | |||
| 72f8c2a3e4 | |||
| 0928559ea4 | |||
| eb18c9a122 | |||
| ae8a8046bf | |||
| 7644d91407 | |||
| 40c4d33068 | |||
|
|
fe91b504de | ||
| 3b63ae02c6 | |||
| 58ba612707 | |||
|
|
854ccbbe12 | ||
|
|
ddc223f745 | ||
| d9bc61b99e | |||
|
|
8d284f6442 | ||
| 4cae337241 | |||
|
|
dde6f6a15c | ||
|
|
3cfa3d588a | ||
|
|
02ec3856d5 | ||
| 2add2b253a | |||
|
|
56a8ea09be | ||
| 62ad9ac318 | |||
|
|
e50a907931 | ||
| ca18749ec5 | |||
|
|
330015fbb6 | ||
| 24f7e7aa5b | |||
| 5d9da95830 | |||
| 1e3f37f71e | |||
| 181bdbe971 | |||
| c12b2992f9 | |||
| 57d16b9aea | |||
| eb277cd55a | |||
| 8346a1db8e | |||
| 2df73e6a48 | |||
| c21472d930 | |||
| 3597b03c4f | |||
| 2cda50f9af | |||
| 6c936d546b | |||
| bf0cc4e96c | |||
| 685c6af3ed | |||
| 95818b2501 | |||
| 55b4cd38e9 | |||
| c0045fa372 | |||
| 6610bdf720 | |||
| 8c1ee15fac | |||
| ed8a6ce6e9 | |||
| 8a4dd6e10e | |||
| bf834b8f41 | |||
| bd134bba06 | |||
| 7fd486855f | |||
| 8d07d9a31a | |||
| fdbaaf375f | |||
| ebbdb22e33 | |||
| 8e4581f1b5 | |||
| 679664d7b0 | |||
| c3ea03db03 | |||
| 7ec4fb9297 | |||
| 6df9c18efb | |||
| f42f1888ae | |||
| 0f2126d694 | |||
| 3248ab1c3a | |||
| a6437dcd67 | |||
| 544b3f25d1 | |||
| cc543ace21 | |||
| c03e7f4079 | |||
| 41f2f1777f | |||
| d7d78a77a6 | |||
| 5a79e94df3 | |||
| dac0708cda | |||
| 5628cbceaf | |||
| aa798ec03d | |||
| 0011c80ec3 | |||
| 46f0210ba6 | |||
| 31aabfc28f | |||
| 0de05e5515 | |||
| 9cb58c583c | |||
| d55a2b570f | |||
| cf36da07af | |||
| 814d11d03e | |||
| ee56dec2d5 | |||
| 8137845198 | |||
| c8d9e45207 | |||
| 281c54a423 | |||
| 5b93864dd5 | |||
| f54511d393 | |||
| 9f6693e833 | |||
| e0b3de8dc7 | |||
| 6c5f8401f8 | |||
| 542ab0cb2b | |||
| 74c718fb18 | |||
| 0ad3f114f3 | |||
| 68e3eb4a61 | |||
| 61b5769e48 | |||
| 8e10ebc883 | |||
| 29e10b432f | |||
| 55dd34a450 | |||
| 66b39862c8 | |||
| fcc02984dd | |||
| edde2dc3b4 | |||
| f52cafd622 | |||
| d0caf57e89 | |||
| 32655ead7b | |||
| 98376747cd | |||
| ccf2417cc8 | |||
| d253a43ad2 | |||
| c2bb1e7382 | |||
| 1fade89df4 | |||
| 7387c02da0 | |||
| 7069ceaeae | |||
| cf9454482f | |||
| eaa500ddd5 | |||
| e47e795a09 | |||
| 77c93af9a0 | |||
| 24716d03a9 | |||
| 6de7839107 | |||
| edcd168d48 | |||
| c20fc5462a | |||
| 4bba42fc13 | |||
| 2bf7525fe2 | |||
| bf49900b57 | |||
| ff96dcac1c | |||
| 5a3e1a2299 | |||
| 24495d8970 | |||
| de960a0dd4 | |||
| cee11b5769 | |||
| 5697447988 | |||
| 70d901f9f5 | |||
| 731ac93a55 | |||
|
|
0e8b2ab294 | ||
| 814b798b3f | |||
| c51b7b4e78 | |||
| ff81b244b7 | |||
| 8dd2d223e9 | |||
| 36ed37d5c0 | |||
| c8803ada35 | |||
| ccc2cd75a5 | |||
| f2a37b78cb | |||
| 0e2871c964 | |||
| 116def2c79 | |||
| bcf8bb38cd | |||
| d38bcaa36f | |||
| 25965717c3 | |||
| 671e9af15b | |||
| da95b0484d | |||
| 5ed3959a12 | |||
| eb92c5c5f8 | |||
| 8a079d84f8 | |||
| 1c4c896d64 | |||
| 3c6c27cb00 | |||
| 8d11ae7400 | |||
| cafe206001 | |||
| a803d9da10 | |||
|
|
8054e2faf3 | ||
| 7956fc9e9b | |||
| 006cf1fd63 | |||
| 6fb27d9509 | |||
| 394e71e269 | |||
| 454eab6b0e | |||
| 6585412932 | |||
| df04784ddb | |||
| 239a46451d | |||
| d167c15c5e | |||
| ae31a7b61a | |||
| 101ee755f1 | |||
| dc65c61440 | |||
| 380ab60572 | |||
| 28720c7d7d | |||
| 8b7bd56c7b | |||
| 7fb3608dea | |||
| 8ce387c0c5 | |||
| 5a933fec9d | |||
| 682f189e6e | |||
| f5d7a27ea3 | |||
| d997abc3d3 | |||
| e5fd44820a | |||
| b5b846f3f2 | |||
| f25ef2d54f | |||
| 6eb7dc11a4 | |||
| 7b1daffe97 | |||
| c5c569b941 | |||
| 013cd43e7e | |||
| c6e5d06172 | |||
| 34d97b47ac | |||
| dc5cf4039c | |||
| 6f8e9ff618 | |||
| 2581908cc4 | |||
| 6e556e198a | |||
| cec68a8cd9 | |||
| 75dbadcb47 | |||
| 046b8a8f4c | |||
| 1aaf2d8d8b | |||
| 33f58e14c6 | |||
| db646d10f4 | |||
| a9a40e4587 | |||
| 2d5e2d04a9 | |||
| cf11379b49 | |||
| 9dd3b2c858 | |||
| 5ac92556e3 | |||
| 458603fb98 | |||
| f63ccf0aef | |||
| 5900b8d399 | |||
| 57d9ff2e30 | |||
| a10654f210 | |||
| d04d27d221 | |||
| 86a08dbe12 | |||
| c4608a8421 | |||
| c88b6b45c5 | |||
| 0517a57021 | |||
| e42345f35b | |||
| dce3a4f332 | |||
| ee7925c960 | |||
| 527f69dc1b | |||
| 50a92c78cc | |||
| b6a6893f6f | |||
| db4b85da8e | |||
| e428a82bd5 | |||
| 44987e7e7e | |||
| 675fab0c79 | |||
| 1fcbfdf5d1 | |||
| afd92e90b6 | |||
| 347d9f2ddb | |||
| 51eec60c6c | |||
| f1f36d8cf1 | |||
| bf259f3d79 | |||
| 25cf0fa670 | |||
| c9f96f8666 | |||
| 82793a8257 | |||
| 51032342b8 | |||
| aba1bfc126 | |||
| 3fb7fa1aa3 | |||
| c70b9b61b9 | |||
| 6bbc32d9c6 | |||
| 05951f9853 | |||
| 50c7d91707 | |||
| 4b6642a78a | |||
| ef8240a012 | |||
| b547140eb1 | |||
| 5abdc6f628 | |||
| efa395b7b5 | |||
| b96ca78942 | |||
| 5a74a0e552 | |||
| 643e1d923c | |||
| dedcf71419 | |||
| 07d4bf7e69 | |||
| cc1d22a7d4 | |||
| af2b35e6e2 | |||
| 3c26f00a19 | |||
| cd66947303 | |||
| c3d0dbaa0b | |||
| 8b88ccd238 | |||
| 754f1af61c | |||
| 0ae7c3ceb6 | |||
| 7b5e678c55 | |||
| d2f42af935 | |||
| ec95ab025d | |||
| 8f6894eecb | |||
| 123b701ed7 | |||
| cd30278ad9 | |||
| f586b2f9c2 | |||
| da2eaecd47 | |||
| d46a463076 | |||
| e952687710 | |||
| ae61ac055e | |||
| acadd84e4a | |||
| 773e9c35fa | |||
| 06f247beb7 | |||
| c9c33d58e1 | |||
| 7a4fe51d09 | |||
| 4614d79d77 | |||
| 371aed3645 | |||
| 98c21fa30e | |||
| 5b640988c4 | |||
| 8735eece13 | |||
| 373af248b4 | |||
| 5763e7fbff | |||
| 9df85c335f | |||
| 346ebaa0e6 | |||
| a4e53f3cfd | |||
| 3de7fa4989 | |||
| fff873a64d | |||
| 38c2afa587 | |||
| 8e45d00173 | |||
| dfe90fb968 | |||
| 72889796e5 | |||
| 3096d35113 | |||
| a4103a8e08 | |||
| 31fbcf2821 | |||
| 0760989b52 | |||
| 9232e40942 | |||
| 00d1e8c961 | |||
| b3dd119c99 | |||
| 335a3b0cae | |||
| 95061dfa53 | |||
| fd884271b7 | |||
| d119b1eb2c | |||
| 5200d5a61d | |||
| 7c715af0c5 | |||
| 61436db48f | |||
| a36008017c | |||
| 70b6597811 | |||
| 64b2c9fb06 | |||
| 690bdeb647 | |||
| 3cadf80286 | |||
| 50cef50289 | |||
| 34d68510e3 | |||
| bee68b6a34 | |||
| 5aad3e8b22 | |||
| b992c0075b | |||
| aa4503f1c9 | |||
| aba8e5eb9b | |||
| 97c1dbcece | |||
| d5362a5390 | |||
| 7571438a6b | |||
| e57a9968b2 | |||
| 6c6b74a8e5 | |||
| 4879f7cdef | |||
| f394897a08 | |||
| 51d8104cdc | |||
| 500738fcd4 | |||
| 532e1ad9ef | |||
| 6a54e63c0f | |||
| 7d10107f4b | |||
| a304420456 | |||
| 2c848df97d | |||
| 33e59c9554 | |||
| a7678acbe1 | |||
| cbdff3311f | |||
| 137d0c07a6 | |||
| 8c1d247c45 | |||
| 579ee8d275 | |||
| 4df06ab3a1 | |||
| 58a0faeb99 | |||
| 6c414f9a59 | |||
| fe35bbd826 | |||
| 55894bec0e | |||
| 4068091a21 | |||
| 5c7803e12a | |||
| 24e668ea01 | |||
| eccecf73e6 | |||
| 6575c611e8 | |||
| 4072c6e736 | |||
| 0de6bd3faa | |||
| d922fb7a02 | |||
| 8fddd09087 | |||
| 09a9487564 | |||
| e6c8c9c32e | |||
| 4bcac424e0 | |||
| e91159003e | |||
| 45f7e3f5a4 | |||
| 9f47d15312 | |||
| 3c9245bd8b | |||
| ffcd417939 | |||
| 75d5b11475 | |||
| 4a6e673af1 | |||
| d82bcb560b | |||
| 46e4b7cfcb | |||
| 2e6c8ac751 | |||
| 2f6a0e3158 | |||
| b6e4deb7bd | |||
| c972a17670 | |||
| fd789574fd | |||
| 4b5f3bb41e | |||
| 63908b1fca | |||
| ef4ab9bcd5 | |||
| 1392a642a9 | |||
| b886a05b5b | |||
| 25ba37103d | |||
| a237e55874 | |||
| 8b7dc83bd2 | |||
| c2dfa5cd2e | |||
| 0652fb3a18 | |||
| 7aafdc92b6 | |||
| 6154656c3e | |||
| 02c8321023 | |||
| 2077a9724a | |||
| 43dd4d38ac | |||
| 34cb361b67 | |||
| c18c513139 | |||
| 5a13f13e8e | |||
| f29730fae0 | |||
| c1ae18e5f1 | |||
| 7f5db3234b | |||
| 72c3fe0dc2 | |||
| 7ce8e34d81 | |||
| 4ffc3577a5 | |||
| 572337ba62 | |||
| 07cf710bc3 | |||
| f955bb7ef8 | |||
| 597391bbdf | |||
| 4716232398 | |||
| e91af4b953 | |||
| 3b144313b3 | |||
| aeb438e442 | |||
| 24e44a49f8 | |||
| eae373c553 | |||
| fb2c28faf1 | |||
| 5e1675f95a | |||
| d3ae278c75 | |||
| cd54cf9443 | |||
| f02f97f69f | |||
| 11975d8b5a | |||
| d6c88ede73 |
10
Dockerfile
10
Dockerfile
@@ -30,14 +30,20 @@ ARG VITE_POSTHOG_KEY=
|
|||||||
ARG VITE_META_PIXEL_ID=
|
ARG VITE_META_PIXEL_ID=
|
||||||
ARG VITE_CHATWOOT_BASE_URL=
|
ARG VITE_CHATWOOT_BASE_URL=
|
||||||
ARG VITE_CHATWOOT_WEBSITE_TOKEN=
|
ARG VITE_CHATWOOT_WEBSITE_TOKEN=
|
||||||
|
ARG VITE_GOOGLE_ADS_ID=
|
||||||
|
ARG VITE_GOOGLE_ADS_SIGNUP_LABEL=
|
||||||
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# ── Stage 4: Production ───────────────────────────────
|
# ── Stage 4: Production ───────────────────────────────
|
||||||
FROM node:22-alpine AS production
|
FROM node:22-alpine AS production
|
||||||
|
|
||||||
# Chromium for Playwright (EMEX/PartsCatalogs)
|
# Chromium for Playwright (EMEX/PartsCatalogs). ffmpeg is required by the Vinpin
|
||||||
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
|
# 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_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||||
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||||
|
|
||||||
|
|||||||
127
PROJECT_INDEX.json
Normal file
127
PROJECT_INDEX.json
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
{
|
||||||
|
"project": "sase-v2 (sase.tr)",
|
||||||
|
"description": "Turkish B2B SaaS: VIN decode + OEM parts-catalog platform for parts dealers & repair shops",
|
||||||
|
"generated": "2026-06-10",
|
||||||
|
"generator": "/sc:index-repo",
|
||||||
|
"humanReadable": "PROJECT_INDEX.md",
|
||||||
|
"stack": {
|
||||||
|
"packageManager": "pnpm@10.29.3",
|
||||||
|
"node": "22 (alpine in Docker)",
|
||||||
|
"monorepo": "pnpm workspaces + turbo",
|
||||||
|
"language": "TypeScript",
|
||||||
|
"lint": "biome"
|
||||||
|
},
|
||||||
|
"workspaces": {
|
||||||
|
"apps/api": { "name": "api", "framework": "NestJS ^10.4", "role": "REST API :4000 + BullMQ worker (worker.ts)" },
|
||||||
|
"apps/web": { "name": "web", "framework": "React ^19 + TanStack Router ^1.120 + Vite", "role": "SPA dashboard + marketing pages" },
|
||||||
|
"packages/shared": { "name": "@sase/shared", "role": "zod schemas, types, constants (plans, error-codes, regex), utils (vin-validator, formatters, currency)" },
|
||||||
|
"packages/config": { "name": "@sase/config", "role": "shared config" },
|
||||||
|
"packages/ui": { "name": "@sase/ui", "role": "shadcn-style component library" }
|
||||||
|
},
|
||||||
|
"entryPoints": {
|
||||||
|
"api": "apps/api/src/main.ts",
|
||||||
|
"worker": "apps/api/src/worker.ts",
|
||||||
|
"web": "apps/web/src/main.tsx",
|
||||||
|
"migrations": "apps/api/src/database/migrate.ts"
|
||||||
|
},
|
||||||
|
"apiModules": {
|
||||||
|
"vehicles": "VIN decode orchestration; fallback chain Corgi→PartsCatalogs→PL24→EMEX→NHTSA; slim response + Redis candidate stash (vin-cache-keys.ts)",
|
||||||
|
"integrations/pl24": "PartsLink24 — shared P4 core + brand services: pl24.service, pl24-psa, pl24-volvo, pl24-ford, pl24-hyundai-kia, pl24-opel, pl24-ford-legacy, pl24-auth, parsers/",
|
||||||
|
"integrations/emex": "emexdwc.ae scrape — HTTP (undici ProxyAgent) primary, Playwright fallback (emex.browser.ts), emex-tree.parser",
|
||||||
|
"integrations/parts-catalogs": "PartsCatalogs API (pcat)",
|
||||||
|
"integrations/p": "OEM cross-reference 'P' — TecDoc-derived MySQL (P_DB_* env): p.controller, p-source-db.service",
|
||||||
|
"integrations/corgi": "offline WMI lookup (instant)",
|
||||||
|
"integrations/vin-api": "NHTSA fallback decoder",
|
||||||
|
"integrations/catalog-source-db": "upstream source-DB bridge",
|
||||||
|
"catalog": "catalog browse — catalog/pcat-catalog/emex-catalog controllers+services",
|
||||||
|
"categories": "category tree: partCount rollup, lazy drill/heal, PSA parent gates",
|
||||||
|
"parts": "part listings",
|
||||||
|
"brands": "brand canonicalization (case-insensitive lookup)",
|
||||||
|
"auth": "better-auth cookie sessions",
|
||||||
|
"users": "user management",
|
||||||
|
"referrals": "referral program",
|
||||||
|
"payments": "Stripe",
|
||||||
|
"subscriptions": "subscription lifecycle",
|
||||||
|
"plans": "plan definitions",
|
||||||
|
"jobs": "BullMQ — queues: catalog-prefetch, emex-scrape, translation, lifecycle-email, subscription-expiry, query-cleanup; processors mirror queues; prefetch-worker.service",
|
||||||
|
"posthog": "server-side feature-flag eval + kill-source-* switches",
|
||||||
|
"meta-capi": "Meta Conversions API (event_id dedup, fail-open)",
|
||||||
|
"notifications": "Novu lifecycle triggers",
|
||||||
|
"email": "Postal HTTP API ops mail",
|
||||||
|
"chatwoot": "support-desk integration",
|
||||||
|
"analytics": "product analytics endpoints",
|
||||||
|
"telemetry": "OpenTelemetry → OTLP exporters",
|
||||||
|
"common": "shared infra incl. catalog-degradation → Sentry (fingerprinted, Redis-deduped)",
|
||||||
|
"redis": "ioredis provider",
|
||||||
|
"storage": "S3/MinIO",
|
||||||
|
"translations": "OpenAI-backed translation",
|
||||||
|
"admin": "admin endpoints",
|
||||||
|
"internal-admin": "internal admin",
|
||||||
|
"blog": "blog content",
|
||||||
|
"changelog": "changelog content",
|
||||||
|
"demo": "public demo flow",
|
||||||
|
"contact": "contact form",
|
||||||
|
"config": "configuration.ts + env.validation.ts (zod; empty-string trap)",
|
||||||
|
"database": "Drizzle provider; schema split: core, pl24, emex, parts-catalogs, relations"
|
||||||
|
},
|
||||||
|
"webRoutes": {
|
||||||
|
"public": ["index", "pricing", "blog", "blog_/$slug", "demo", "demo_/categories_/$categoryId", "about", "contact", "kvkk", "privacy", "terms"],
|
||||||
|
"auth": ["login", "register", "forgot-password", "reset-password", "email-verified"],
|
||||||
|
"dashboard": [
|
||||||
|
"index", "search", "history", "settings", "billing", "changelog",
|
||||||
|
"vehicles_/$id", "vehicles_/$id/categories_/$categoryId",
|
||||||
|
"catalog/index", "catalog_/$brandName", "catalog_/$brandName_/$modelId", "catalog_/$brandName_/$modelId/categories_/$categoryId",
|
||||||
|
"catalog_/pcat/$catalogId[/_$modelId/_$carId[/groups/$groupId]]",
|
||||||
|
"catalog_/emex/$catalogCode[/_$vehicleId[/groups/$groupId]]",
|
||||||
|
"oem.$code", "subscription", "service-test"
|
||||||
|
],
|
||||||
|
"admin": ["admin/index", "admin/users", "admin/analytics", "admin/referrals", "admin/copy-logs"]
|
||||||
|
},
|
||||||
|
"webLib": ["api-client", "auth-client", "posthog", "surveys", "meta-pixel", "faro", "sentry", "chatwoot", "i18n", "vehicle", "toast", "user-settings", "keys", "category-icons", "emex-group-hierarchy", "recently-used-brands"],
|
||||||
|
"configFiles": {
|
||||||
|
"turbo.json": "task pipeline",
|
||||||
|
"biome.json": "lint/format",
|
||||||
|
"docker-compose.coolify.yml": "prod/staging: api + worker + sase-redis; env vars must be referenced as ${VAR} per service block",
|
||||||
|
"Dockerfile": "multi-stage node:22-alpine + Chromium; runtime imports must be in dependencies",
|
||||||
|
"ecosystem.config.js": "PM2",
|
||||||
|
"apps/api/src/config/env.validation.ts": "zod env gate"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"primary": "PostgreSQL via drizzle-orm ^0.41",
|
||||||
|
"migrations": "apps/api/drizzle/ — 14 SQL files (0000–0013) + meta/_journal.json; custom SHA256-tracked runner (src/database/migrate.ts)",
|
||||||
|
"schemas": ["core", "pl24", "emex", "parts-catalogs", "relations"],
|
||||||
|
"secondary": "MySQL (mysql2) — TecDoc-derived 'P' OEM cross-ref db",
|
||||||
|
"cache": "Redis (sase-redis container) — BullMQ + VIN candidate stash + dedup"
|
||||||
|
},
|
||||||
|
"tests": {
|
||||||
|
"api": { "framework": "vitest", "count": 30, "pattern": "*.spec.ts", "run": "pnpm --filter api test" },
|
||||||
|
"web": { "framework": "vitest (no Playwright e2e)", "count": 24, "pattern": "*.test.ts(x)", "run": "pnpm --filter web test" },
|
||||||
|
"qa": "qa/post-deploy smoke checks"
|
||||||
|
},
|
||||||
|
"keyDependencies": {
|
||||||
|
"api": ["@nestjs/* ^10.4", "drizzle-orm + postgres", "better-auth", "bullmq + ioredis", "stripe", "posthog-node", "openai", "undici", "@sentry/nestjs", "@opentelemetry/*", "mysql2", "zod"],
|
||||||
|
"web": ["react ^19", "@tanstack/react-router + react-query", "zustand", "posthog-js", "@grafana/faro-web-sdk", "@sentry/react", "remotion", "better-auth", "zod"]
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"install": "pnpm install",
|
||||||
|
"dev": "pnpm dev",
|
||||||
|
"build": "pnpm build",
|
||||||
|
"lint": "pnpm lint",
|
||||||
|
"typecheck": "pnpm typecheck",
|
||||||
|
"test": "pnpm test",
|
||||||
|
"dbMigrate": "pnpm --filter api db:migrate",
|
||||||
|
"dbStudio": "pnpm --filter api db:studio"
|
||||||
|
},
|
||||||
|
"deployment": {
|
||||||
|
"platform": "Coolify (cool.semih.ai)",
|
||||||
|
"staging": { "branch": "dev", "fqdn": "dev.sase.tr", "rule": "verify every change here first" },
|
||||||
|
"production": { "branch": "main", "fqdn": "sase.tr", "rule": "promote only after dev verified" },
|
||||||
|
"policy": "never edit on server/container; push-to-deploy via Gitea webhooks"
|
||||||
|
},
|
||||||
|
"docs": {
|
||||||
|
"assistant": ["CLAUDE.md", "AGENTS.md"],
|
||||||
|
"internal": "docs/ (INDEX.md, 00-testing.md, design-specs/, pl24-catalog/, product/, clarification/)",
|
||||||
|
"rootReports": ["upstream-prob.md", "undecoded-vin-rca.md", "posthog-max-leverage.md", "posthog-feature-roadmap.md", "katalogayapi.md", "knowledge.md", "ralpRapor.md", "emex-hierarchy-plan.md", "MANIFEST.md", "MANIFEST-FN-403.md", "RECOVERY-FN-412.md", "SCOPING-FN-411.md", "SASE_PRD_Hesap_Paylasimi_Onleme_v1.1.md"],
|
||||||
|
"bizWorkspace": "/home/s/ss/INDEX.md"
|
||||||
|
}
|
||||||
|
}
|
||||||
88
PROJECT_INDEX.md
Normal file
88
PROJECT_INDEX.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Project Index: sase.tr (sase-v2)
|
||||||
|
|
||||||
|
Generated: 2026-06-10 · regenerate via `/sc:index-repo` · machine-readable twin: `PROJECT_INDEX.json`
|
||||||
|
|
||||||
|
Turkish B2B SaaS: VIN decode + OEM parts-catalog platform for parts dealers & repair shops.
|
||||||
|
pnpm 10.29.3 workspaces + Turbo · Node 22 · TypeScript · Biome.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/api NestJS 10 backend — REST API (:4000) + separate BullMQ worker
|
||||||
|
apps/web React 19 + TanStack Router/Query + Vite SPA (Remotion video, i18n)
|
||||||
|
packages/shared @sase/shared — zod schemas, types, constants (plans, error-codes), utils (vin-validator, formatters)
|
||||||
|
packages/config @sase/config — shared config
|
||||||
|
packages/ui @sase/ui — shadcn-style components
|
||||||
|
scripts/ one-off ops/debug (emex scrapers, backfills, deploy.sh, pl24-catalog-explorer)
|
||||||
|
qa/post-deploy post-deploy smoke checks
|
||||||
|
docs/ internal docs — see docs/INDEX.md (testing, design-specs, pl24-catalog, product)
|
||||||
|
docker/ local dev compose (minio, nginx)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entry Points
|
||||||
|
|
||||||
|
- `apps/api/src/main.ts` — API bootstrap (also serves built web via ServeStatic; no nginx in prod)
|
||||||
|
- `apps/api/src/worker.ts` — BullMQ worker process (separate container)
|
||||||
|
- `apps/api/src/database/migrate.ts` — custom SHA256-tracked migration runner over `apps/api/drizzle/` (14 SQL migrations + `meta/_journal.json`)
|
||||||
|
- `apps/web/src/main.tsx` — SPA bootstrap (`routeTree.gen.ts` generated by `tsr generate`)
|
||||||
|
|
||||||
|
## API Modules (`apps/api/src/`)
|
||||||
|
|
||||||
|
- **vehicles/** — VIN decode orchestration; fallback chain Corgi → PartsCatalogs → PL24 → EMEX → NHTSA; slim decode response + Redis candidate stash (`vin-cache-keys.ts`)
|
||||||
|
- **integrations/pl24/** — PartsLink24: shared P4 core + per-brand services (`psa`, `volvo`, `ford`, `hyundai-kia`, `opel`, `ford-legacy`) + `parsers/`
|
||||||
|
- **integrations/emex/** — emexdwc.ae scrape: HTTP via undici ProxyAgent, Playwright fallback (`emex.browser.ts`), tree parser
|
||||||
|
- **integrations/parts-catalogs/** — PartsCatalogs API ("pcat")
|
||||||
|
- **integrations/p/** — OEM cross-reference ("P", TecDoc-derived MySQL db; `P_DB_*` env)
|
||||||
|
- **integrations/corgi/**, **vin-api/** — offline WMI lookup; NHTSA fallback
|
||||||
|
- **integrations/catalog-source-db/** — upstream source-DB bridge
|
||||||
|
- **catalog/** — catalog browse endpoints (`catalog`, `pcat-catalog`, `emex-catalog` controllers/services)
|
||||||
|
- **categories/**, **parts/**, **brands/** — category tree (partCount rollup, lazy drill/heal), parts, brand canonicalization
|
||||||
|
- **auth/** (better-auth cookie sessions), **users/**, **referrals/**
|
||||||
|
- **payments/** (Stripe), **subscriptions/**, **plans/**
|
||||||
|
- **jobs/** — BullMQ queues + processors: `catalog-prefetch`, `emex-scrape`, `translation`, `lifecycle-email`, `subscription-expiry`, `query-cleanup`
|
||||||
|
- **posthog/** — server-side flag eval + `kill-source-*` switches · **meta-capi/** — Meta Conversions API
|
||||||
|
- **notifications/** (Novu), **email/** (Postal), **chatwoot/**
|
||||||
|
- **analytics/**, **telemetry/** (OTel → OTLP), **common/** (incl. catalog-degradation → Sentry), **redis/**, **storage/** (S3/MinIO), **translations/** (OpenAI), **admin/**, **internal-admin/**, **blog/**, **changelog/**, **demo/**, **contact/**
|
||||||
|
- **config/** — `env.validation.ts` zod gate (beware `""` vs undefined), `configuration.ts`
|
||||||
|
- **database/** — Drizzle provider + `schema/` split: `core`, `pl24`, `emex`, `parts-catalogs`, `relations`
|
||||||
|
|
||||||
|
## Web (`apps/web/src/`)
|
||||||
|
|
||||||
|
- **routes/** — public: `index`, `pricing`, `blog`, `demo`, `about/contact/kvkk/privacy/terms` · `_auth/`: login, register, forgot/reset password, email-verified · `dashboard/`: `search` (main VIN flow), `vehicles_/$id[/categories_/$categoryId]`, catalog trees (`catalog_/$brandName/...`, `catalog_/pcat/...`, `catalog_/emex/...`), `oem.$code` (cross-ref), `subscription`, `billing`, `history`, `settings`, `service-test` (admin), `admin/*` (users, analytics, referrals, copy-logs)
|
||||||
|
- **lib/** — `api-client`, `auth-client`, `posthog`, `surveys` (API-mode survey renderer), `meta-pixel`, `faro`, `sentry`, `chatwoot`, `i18n`, `vehicle`
|
||||||
|
- **stores/** (zustand), **components/**, **remotion/**, **messages/** (i18n), **hooks/**
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- `turbo.json`, `biome.json`, `tsconfig.json` — build/lint/typecheck pipeline
|
||||||
|
- `docker-compose.coolify.yml` — prod/staging: `api` + `worker` + `sase-redis` (Coolify env reaches a service ONLY if its `environment:` block references `${VAR}`)
|
||||||
|
- `Dockerfile` — multi-stage node:22-alpine + Chromium (runtime imports MUST be in `dependencies`)
|
||||||
|
- `ecosystem.config.js` — PM2 config
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- API: 30 `*.spec.ts` (Vitest) → `pnpm --filter api test`
|
||||||
|
- Web: 24 `*.test.ts(x)` (Vitest only; **no Playwright e2e**) → `pnpm --filter web test`; plus per-package `index.spec.ts`
|
||||||
|
- `qa/post-deploy/` smoke checks
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
NestJS 10 · drizzle-orm 0.41 + postgres · better-auth · bullmq + ioredis · stripe · posthog-node · openai (translations) · undici (proxied fetch — global fetch lacks `dispatcher`) · Sentry + OpenTelemetry · mysql2 (P/TecDoc) ·· React 19 · TanStack Router/Query · zustand · posthog-js · @grafana/faro · remotion · zod throughout.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. `pnpm install`
|
||||||
|
2. `pnpm dev` (turbo: api :4000 + web :3000)
|
||||||
|
3. `pnpm lint` / `pnpm typecheck` / `pnpm test`
|
||||||
|
4. DB: `pnpm --filter api db:migrate` (custom runner) · `db:studio` · `db:seed`
|
||||||
|
|
||||||
|
## Deployment (Coolify — NEVER edit on server)
|
||||||
|
|
||||||
|
- Push `dev` → auto-deploys staging **dev.sase.tr** — verify every change here first
|
||||||
|
- Merge to `main` → auto-deploys prod **sase.tr** (only after dev verified)
|
||||||
|
|
||||||
|
## Docs Map
|
||||||
|
|
||||||
|
- `CLAUDE.md` / `AGENTS.md` — assistant instructions · `docs/INDEX.md` — internal docs index
|
||||||
|
- Root reports: `upstream-prob.md`, `undecoded-vin-rca.md`, `posthog-max-leverage.md`, `posthog-feature-roadmap.md`, `katalogayapi.md`, `knowledge.md`, `ralpRapor.md`, `emex-hierarchy-plan.md`, `MANIFEST*.md`, `RECOVERY-FN-412.md`, `SCOPING-FN-411.md`, `SASE_PRD_Hesap_Paylasimi_Onleme_v1.1.md`
|
||||||
|
- Business/ops workspace knowledge base: `/home/s/ss/INDEX.md`
|
||||||
@@ -23,3 +23,8 @@ NOVU_API_KEY=
|
|||||||
APP_PUBLIC_URL=https://sase.tr
|
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.
|
# HMAC secret for signed track.sase.tr click links (Bitwarden "mailtrack tracking (track.sase.tr)"). Empty → no click tracking.
|
||||||
MAILTRACK_SECRET=
|
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
|
||||||
|
|||||||
28
apps/api/drizzle/0013_fix_brand_casing.sql
Normal file
28
apps/api/drizzle/0013_fix_brand_casing.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- Canonicalise vehicle brand casing + backfill missing brand_id.
|
||||||
|
--
|
||||||
|
-- Root cause: the decode persist (vehicles.service.ts) looked brands up with a
|
||||||
|
-- case-SENSITIVE `eq(brands.name, brandName)`. A decode source that yields an
|
||||||
|
-- uppercase brand string (e.g. "FORD", "MERCEDES-BENZ") missed the canonical
|
||||||
|
-- "Ford" / "Mercedes-Benz" row → brand_id stayed NULL and the raw uppercase
|
||||||
|
-- string was stored as brand_name. That split the same brand across casing
|
||||||
|
-- variants in analytics + the catalog ("Ford" vs "FORD"). The companion code
|
||||||
|
-- fix makes the lookup case-insensitive and stores the canonical name; this
|
||||||
|
-- migration repairs the rows already written.
|
||||||
|
--
|
||||||
|
-- Backfill every vehicle whose brand_name matches a canonical brand
|
||||||
|
-- case-insensitively: set the FK and the canonical-cased name. (catalog_vehicles
|
||||||
|
-- is intentionally untouched — its brand_name namespace does not match the
|
||||||
|
-- brands table, so 0 rows would qualify.)
|
||||||
|
UPDATE "vehicles" v
|
||||||
|
SET brand_id = b.id, brand_name = b.name
|
||||||
|
FROM "brands" b
|
||||||
|
WHERE v.brand_id IS NULL AND lower(v.brand_name) = lower(b.name);
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Defensive: realign any vehicle whose brand_id is set but whose denormalised
|
||||||
|
-- brand_name has drifted from the canonical brands.name (none today, but keeps
|
||||||
|
-- the FK and the denormalised name consistent going forward).
|
||||||
|
UPDATE "vehicles" v
|
||||||
|
SET brand_name = b.name
|
||||||
|
FROM "brands" b
|
||||||
|
WHERE v.brand_id = b.id AND v.brand_name <> b.name;
|
||||||
23
apps/api/drizzle/0014_proxy_logs.sql
Normal file
23
apps/api/drizzle/0014_proxy_logs.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- Proxy telemetry: one row per proxied upstream attempt (pcat call/capture/
|
||||||
|
-- validate, emex http). Lets the Süper Panel grade proxy providers per service
|
||||||
|
-- (success rate, ban rate, latency) and track banned residential exit IPs /
|
||||||
|
-- sticky sessions. Written fire-and-forget by ProxyTelemetryService; 30-day
|
||||||
|
-- retention enforced by the query-cleanup job.
|
||||||
|
CREATE TABLE "proxy_logs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"service" varchar(30) NOT NULL,
|
||||||
|
"provider" varchar(20) NOT NULL,
|
||||||
|
"session_key" varchar(60),
|
||||||
|
"exit_ip" varchar(45),
|
||||||
|
"target_host" varchar(120),
|
||||||
|
"status_code" integer,
|
||||||
|
"error_kind" varchar(30),
|
||||||
|
"success" boolean DEFAULT false NOT NULL,
|
||||||
|
"banned" boolean DEFAULT false NOT NULL,
|
||||||
|
"duration_ms" integer,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "proxy_logs_created_at_idx" ON "proxy_logs" ("created_at");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "proxy_logs_banned_created_at_idx" ON "proxy_logs" ("banned","created_at");
|
||||||
37
apps/api/drizzle/0015_oem_votes.sql
Normal file
37
apps/api/drizzle/0015_oem_votes.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
-- OEM community voting + gamification ledger.
|
||||||
|
-- oem_votes: one row per (user, oem_code) uyumlu/uyumsuz verdict cast from the
|
||||||
|
-- catalog part rows. Votes pool globally per code; part/vehicle/category are
|
||||||
|
-- analytics breadcrumbs only (catalog routes pass catalog_vehicles ids → no FK,
|
||||||
|
-- mirrors oem_code_copies). Re-votes update the row in place.
|
||||||
|
-- oem_vote_points: append-only, exactly one row per vote (vote_id unique),
|
||||||
|
-- written in the same transaction. points = 1 (vote) + 2 (agreed with strict
|
||||||
|
-- majority at cast time; first voter always 3). Never retro-adjusted.
|
||||||
|
CREATE TABLE "oem_votes" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"oem_code" varchar(100) NOT NULL,
|
||||||
|
"vote" varchar(12) NOT NULL,
|
||||||
|
"part_id" uuid,
|
||||||
|
"vehicle_id" uuid,
|
||||||
|
"category_id" uuid,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "oem_vote_points" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"vote_id" uuid NOT NULL,
|
||||||
|
"oem_code" varchar(100) NOT NULL,
|
||||||
|
"points" integer NOT NULL,
|
||||||
|
"correct" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "oem_votes" ADD CONSTRAINT "oem_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_vote_id_oem_votes_id_fk" FOREIGN KEY ("vote_id") REFERENCES "public"."oem_votes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "oem_votes_user_oem_idx" ON "oem_votes" USING btree ("user_id","oem_code");--> statement-breakpoint
|
||||||
|
CREATE INDEX "oem_votes_oem_code_idx" ON "oem_votes" USING btree ("oem_code");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "oem_vote_points_vote_id_idx" ON "oem_vote_points" USING btree ("vote_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "oem_vote_points_user_id_idx" ON "oem_vote_points" USING btree ("user_id");
|
||||||
18
apps/api/drizzle/0016_oem_suggestions.sql
Normal file
18
apps/api/drizzle/0016_oem_suggestions.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- Community OEM cross-reference suggestions, collected on the OEM detail page
|
||||||
|
-- ("muadil parça öner": brand + code). Primarily for codes the P snapshot has
|
||||||
|
-- no match for. suggested_code_norm (upper alphanumerics) powers grouping and
|
||||||
|
-- the per-user dedup; status is a moderation hook (active|hidden).
|
||||||
|
CREATE TABLE "oem_suggestions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"oem_code" varchar(100) NOT NULL,
|
||||||
|
"brand" varchar(80) NOT NULL,
|
||||||
|
"suggested_code" varchar(100) NOT NULL,
|
||||||
|
"suggested_code_norm" varchar(100) NOT NULL,
|
||||||
|
"status" varchar(16) DEFAULT 'active' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "oem_suggestions" ADD CONSTRAINT "oem_suggestions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "oem_suggestions_user_oem_code_idx" ON "oem_suggestions" USING btree ("user_id","oem_code","suggested_code_norm");--> statement-breakpoint
|
||||||
|
CREATE INDEX "oem_suggestions_oem_code_idx" ON "oem_suggestions" USING btree ("oem_code");
|
||||||
18
apps/api/drizzle/0017_oem_expert_rewards.sql
Normal file
18
apps/api/drizzle/0017_oem_expert_rewards.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- Monthly Parça Uzmanları prizes: one row per (TR-month, rank). Written by the
|
||||||
|
-- expert-rewards cron (1st of month 00:00 Europe/Istanbul) when it closes the
|
||||||
|
-- finished season and grants the top 3 voters a subscription extension
|
||||||
|
-- (30/15/7 days). The unique index is the run-once guard — a re-run for the
|
||||||
|
-- same period inserts nothing, so days are never granted twice.
|
||||||
|
CREATE TABLE "oem_expert_rewards" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"period_start" timestamp with time zone NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"rank" integer NOT NULL,
|
||||||
|
"points" integer NOT NULL,
|
||||||
|
"reward_days" integer NOT NULL,
|
||||||
|
"granted_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "oem_expert_rewards" ADD CONSTRAINT "oem_expert_rewards_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "oem_expert_rewards_period_rank_idx" ON "oem_expert_rewards" USING btree ("period_start","rank");--> statement-breakpoint
|
||||||
|
CREATE INDEX "oem_expert_rewards_user_id_idx" ON "oem_expert_rewards" USING btree ("user_id");
|
||||||
25
apps/api/drizzle/0018_part_price_history.sql
Normal file
25
apps/api/drizzle/0018_part_price_history.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- Tedarikçi fiyat geçmişi (parça kodu bazlı, takip MySQL'den beslenir).
|
||||||
|
-- part_price_tracks: hangi kod izleniyor (ilk seriyi API lazy-backfill yazar,
|
||||||
|
-- günlük cron sadece buradaki kodları tazeler). part_price_daily: (kod, kaynak,
|
||||||
|
-- gün) başına stoktaki tekliflerin p50/p95/p99'u + teklif sayısı. source şimdilik
|
||||||
|
-- hep 'supplier'; perakende verisi geldiğinde aynı tabloya 'retail' olarak girer.
|
||||||
|
-- Tedarikçi kimliği bilinçli olarak HİÇBİR kolonda yok.
|
||||||
|
CREATE TABLE "part_price_tracks" (
|
||||||
|
"code_norm" varchar(64) PRIMARY KEY NOT NULL,
|
||||||
|
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"backfilled_at" timestamp with time zone,
|
||||||
|
"last_refreshed_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "part_price_daily" (
|
||||||
|
"code_norm" varchar(64) NOT NULL,
|
||||||
|
"source" varchar(16) DEFAULT 'supplier' NOT NULL,
|
||||||
|
"date" date NOT NULL,
|
||||||
|
"p50" numeric(15, 4),
|
||||||
|
"p95" numeric(15, 4),
|
||||||
|
"p99" numeric(15, 4),
|
||||||
|
"offer_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
CONSTRAINT "part_price_daily_code_norm_source_date_pk" PRIMARY KEY("code_norm","source","date")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "part_price_daily_code_date_idx" ON "part_price_daily" USING btree ("code_norm","date");
|
||||||
32
apps/api/drizzle/0019_part_price_brand.sql
Normal file
32
apps/api/drizzle/0019_part_price_brand.sql
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
-- Parça fiyat anahtarına marka eklendi: kısa sayısal kodlar (FEBI 27155 vs
|
||||||
|
-- GROS 27155 vs İBRAŞ 27155) markalar arası çakışıyor ve FARKLI fiziksel
|
||||||
|
-- parçaların fiyatları tek havuzda karışıyordu. Kimlik artık (kod, marka);
|
||||||
|
-- markasız izleme yalnızca uzun/benzersiz kodlar için ('' brand_norm).
|
||||||
|
-- Tablolar 0018'den bu yana yalnızca dev'de üç test kodu içeriyordu — veri
|
||||||
|
-- taşımak yerine drop+create (lazy-backfill ilk görüntülemede yeniden doldurur).
|
||||||
|
DROP TABLE IF EXISTS "part_price_daily";
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TABLE IF EXISTS "part_price_tracks";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "part_price_tracks" (
|
||||||
|
"code_norm" varchar(64) NOT NULL,
|
||||||
|
"brand_norm" varchar(64) DEFAULT '' NOT NULL,
|
||||||
|
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"backfilled_at" timestamp with time zone,
|
||||||
|
"last_refreshed_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "part_price_tracks_code_norm_brand_norm_pk" PRIMARY KEY("code_norm","brand_norm")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "part_price_daily" (
|
||||||
|
"code_norm" varchar(64) NOT NULL,
|
||||||
|
"brand_norm" varchar(64) DEFAULT '' NOT NULL,
|
||||||
|
"source" varchar(16) DEFAULT 'supplier' NOT NULL,
|
||||||
|
"date" date NOT NULL,
|
||||||
|
"p50" numeric(15, 4),
|
||||||
|
"p95" numeric(15, 4),
|
||||||
|
"p99" numeric(15, 4),
|
||||||
|
"offer_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
CONSTRAINT "part_price_daily_code_norm_brand_norm_source_date_pk" PRIMARY KEY("code_norm","brand_norm","source","date")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "part_price_daily_code_date_idx" ON "part_price_daily" USING btree ("code_norm","brand_norm","date");
|
||||||
17
apps/api/drizzle/0020_stripe_recurring.sql
Normal file
17
apps/api/drizzle/0020_stripe_recurring.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- Stripe recurring billing (Checkout mode:"subscription") bağlantı kolonları.
|
||||||
|
-- users.stripe_customer_id → sonraki checkout'lar aynı Stripe Customer'da
|
||||||
|
-- toplanır (kayıtlı kart + fatura geçmişi tek kayıtta).
|
||||||
|
-- user_subscriptions.stripe_subscription_id → yenileme faturaları
|
||||||
|
-- (invoice.paid/payment_failed webhook'ları) bizim abonelik satırına bu
|
||||||
|
-- kolondan çözülür; trial ve eski tek-seferlik satırlarda NULL kalır.
|
||||||
|
-- payments.stripe_invoice_id → invoice.paid webhook retry'larında mükerrer
|
||||||
|
-- gelir kaydını önleyen dedupe anahtarı.
|
||||||
|
ALTER TABLE "users" ADD COLUMN "stripe_customer_id" text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_subscriptions" ADD COLUMN "stripe_subscription_id" text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "payments" ADD COLUMN "stripe_invoice_id" text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "user_subscriptions_stripe_sub_idx" ON "user_subscriptions" USING btree ("stripe_subscription_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "payments_stripe_invoice_id_idx" ON "payments" USING btree ("stripe_invoice_id");
|
||||||
30
apps/api/drizzle/0021_vinpin_decodes.sql
Normal file
30
apps/api/drizzle/0021_vinpin_decodes.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- Vinpin ePER decode-oracle cache (feature-flagged: VINPIN_ENABLED).
|
||||||
|
-- One row per VIN. The vinpin-decode BullMQ job drives the Vinpin ePER web
|
||||||
|
-- catalog for Fiat VINs the normal chain (PL24/pcat/emex) can't identify, then
|
||||||
|
-- matches the decoded model to an EXISTING PL24 catalog_vehicle. Vinpin is ONLY
|
||||||
|
-- a decode oracle — parts are always served from PL24's existing catalog.
|
||||||
|
-- status: 'pending' | 'decoded' | 'not_found' | 'failed'.
|
||||||
|
CREATE TABLE IF NOT EXISTS "vinpin_decodes" (
|
||||||
|
"vin" text PRIMARY KEY NOT NULL,
|
||||||
|
"status" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"brand_name" text,
|
||||||
|
"model" text,
|
||||||
|
"sincom" text,
|
||||||
|
"trim" text,
|
||||||
|
"model_year" text,
|
||||||
|
"engine" text,
|
||||||
|
"catalog_vehicle_id" uuid,
|
||||||
|
"raw" jsonb,
|
||||||
|
"attempts" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone,
|
||||||
|
"decoded_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "vinpin_decodes" ADD CONSTRAINT "vinpin_decodes_catalog_vehicle_id_catalog_vehicles_id_fk" FOREIGN KEY ("catalog_vehicle_id") REFERENCES "public"."catalog_vehicles"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "vinpin_decodes_status_idx" ON "vinpin_decodes" USING btree ("status");
|
||||||
34
apps/api/drizzle/0022_discount_codes.sql
Normal file
34
apps/api/drizzle/0022_discount_codes.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
-- Discount codes for checkout promotions. Each row mirrors a Stripe Coupon +
|
||||||
|
-- Promotion Code; Stripe enforces the redemption cap / expiry at checkout while
|
||||||
|
-- this table powers the admin panel's listing + usage tracking and lets us
|
||||||
|
-- preview a code without a per-render Stripe call. Amounts in kuruş (TRY
|
||||||
|
-- smallest unit), matching plans.price* / payments.amount.
|
||||||
|
CREATE TABLE IF NOT EXISTS "discount_codes" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"code" varchar(50) NOT NULL,
|
||||||
|
"stripe_coupon_id" text,
|
||||||
|
"stripe_promotion_code_id" text,
|
||||||
|
"discount_type" varchar(12) NOT NULL,
|
||||||
|
"percent_off" integer,
|
||||||
|
"amount_off" integer,
|
||||||
|
"duration" varchar(12) DEFAULT 'once' NOT NULL,
|
||||||
|
"duration_in_months" integer,
|
||||||
|
"max_redemptions" integer,
|
||||||
|
"times_redeemed" integer DEFAULT 0 NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"note" text,
|
||||||
|
"created_by" uuid,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "discount_codes" ADD CONSTRAINT "discount_codes_created_by_users_id_fk"
|
||||||
|
FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "discount_codes_code_idx" ON "discount_codes" USING btree ("code");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "discount_codes_active_idx" ON "discount_codes" USING btree ("is_active");
|
||||||
38
apps/api/drizzle/0023_api_keys.sql
Normal file
38
apps/api/drizzle/0023_api_keys.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
-- Public API anahtarları — better-auth `apiKey` plugin'inin tablosu (model
|
||||||
|
-- "apikey", usePlural → "apikeys"). Kolon seti plugin şemasının birebir
|
||||||
|
-- karşılığı; "key" kolonu secret'ın SHA256 hash'i (düz metin sadece üretim
|
||||||
|
-- anında bir kez gösterilir). "metadata" JSON string (dailyDecodeLimit gibi
|
||||||
|
-- anahtar-bazlı limitler burada taşınır).
|
||||||
|
CREATE TABLE IF NOT EXISTS "apikeys" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"name" text,
|
||||||
|
"start" text,
|
||||||
|
"prefix" text,
|
||||||
|
"key" text NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"refill_interval" integer,
|
||||||
|
"refill_amount" integer,
|
||||||
|
"last_refill_at" timestamp with time zone,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"rate_limit_enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"rate_limit_time_window" integer,
|
||||||
|
"rate_limit_max" integer,
|
||||||
|
"request_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"remaining" integer,
|
||||||
|
"last_request" timestamp with time zone,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"permissions" text,
|
||||||
|
"metadata" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "apikeys" ADD CONSTRAINT "apikeys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "apikeys_key_idx" ON "apikeys" ("key");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "apikeys_user_id_idx" ON "apikeys" ("user_id");
|
||||||
70
apps/api/drizzle/0024_canonical_categories.sql
Normal file
70
apps/api/drizzle/0024_canonical_categories.sql
Normal 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;
|
||||||
7
apps/api/drizzle/0025_canonical_wheel_accessory.sql
Normal file
7
apps/api/drizzle/0025_canonical_wheel_accessory.sql
Normal 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;
|
||||||
294
apps/api/drizzle/0026_canonical_subcategories.sql
Normal file
294
apps/api/drizzle/0026_canonical_subcategories.sql
Normal 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
|
||||||
301
apps/api/drizzle/0027_canonical_subcategories_v2.sql
Normal file
301
apps/api/drizzle/0027_canonical_subcategories_v2.sql
Normal 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
|
||||||
305
apps/api/drizzle/0028_canonical_subcat_v3.sql
Normal file
305
apps/api/drizzle/0028_canonical_subcat_v3.sql
Normal 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
|
||||||
11
apps/api/drizzle/0029_canonical_structure_template.sql
Normal file
11
apps/api/drizzle/0029_canonical_structure_template.sql
Normal 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");
|
||||||
91
apps/api/drizzle/0030_categories_dedup_unique.sql
Normal file
91
apps/api/drizzle/0030_categories_dedup_unique.sql
Normal 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;
|
||||||
17
apps/api/drizzle/0031_canonical_template_entries.sql
Normal file
17
apps/api/drizzle/0031_canonical_template_entries.sql
Normal 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");
|
||||||
8
apps/api/drizzle/0032_part_price_oe_reset.sql
Normal file
8
apps/api/drizzle/0032_part_price_oe_reset.sql
Normal 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";
|
||||||
9
apps/api/drizzle/0033_parts_oem_code_norm_idx.sql
Normal file
9
apps/api/drizzle/0033_parts_oem_code_norm_idx.sql
Normal 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'));
|
||||||
9
apps/api/drizzle/0034_vehicle_fully_fetched.sql
Normal file
9
apps/api/drizzle/0034_vehicle_fully_fetched.sql
Normal 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");
|
||||||
9
apps/api/drizzle/0035_subscription_dunning.sql
Normal file
9
apps/api/drizzle/0035_subscription_dunning.sql
Normal 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;
|
||||||
20
apps/api/drizzle/0036_pl24_mitsubishi_partinfo_cleanup.sql
Normal file
20
apps/api/drizzle/0036_pl24_mitsubishi_partinfo_cleanup.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Mitsubishi'nin parça listesi grup sanılıyordu.
|
||||||
|
--
|
||||||
|
-- `/p5mitsubishi/extern/details/vinDetails` yanıtı `partno`/`qty` taşıyan bir
|
||||||
|
-- PARÇA listesi, ama her kaydın kendi linki `partInfoTable` (parça-detay) ve ne
|
||||||
|
-- wid ne de yol sınıflandırıcıda karşılık buluyordu. Sonuç: 2.193 parça listesi
|
||||||
|
-- grup düğümüne dönüştü ve içlerindeki 19.576 tekil parça ("SCREW,LOCK CYLINDER",
|
||||||
|
-- "BOLT,STEERING COLUMN WASHER") kategori olarak kaydedildi. Ölçüm (prod,
|
||||||
|
-- 2026-09-20): bu 19.576 sahte düğümün TOPLAM 2 tanesinde parça var, ve her
|
||||||
|
-- prefetch turunda yeniden çekiliyorlar.
|
||||||
|
--
|
||||||
|
-- Sınıflandırıcı düzeltildi (detailsTable artık yaprak, partInfoTable hiç
|
||||||
|
-- kuyruklanmıyor), ama okuma yolu bir düğümün ÖNCE çocuklarına bakıyor: sahte
|
||||||
|
-- çocuklar dururken parça listesi asla çekilmez. Bu yüzden satırların silinmesi
|
||||||
|
-- düzeltmenin parçası, ayrı bir temizlik değil.
|
||||||
|
--
|
||||||
|
-- Güvenli: yalnız pl24 kaynaklı ve yalnız bu iki imzayı taşıyan satırlar; ikisi
|
||||||
|
-- de prod'da %100 Mitsubishi. Silinen ~2 parça satırı üst listeden yeniden gelir.
|
||||||
|
DELETE FROM "categories"
|
||||||
|
WHERE "source" = 'pl24'
|
||||||
|
AND ("link_wid" = 'partInfoTable' OR "link_path" ILIKE '%/details/vinpartinfo%');
|
||||||
34
apps/api/drizzle/0037_rpartstore_decodes.sql
Normal file
34
apps/api/drizzle/0037_rpartstore_decodes.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
-- RPartStore (rpartstore.renault.com, Renault Group dealer portal) VIN-decode
|
||||||
|
-- fallback cache (feature-flagged: RPARTSTORE_ENABLED). One row per VIN. The
|
||||||
|
-- rpartstore-decode BullMQ job asks the RPartStore BFF (STOMP over WebSocket)
|
||||||
|
-- for Renault/Dacia VINs the normal chain (pcat/PL24/emex) can't identify, then
|
||||||
|
-- matches the decoded model to an EXISTING PL24 catalog_vehicle. RPartStore is
|
||||||
|
-- ONLY a decode oracle here — parts are served from PL24's existing catalog.
|
||||||
|
-- status: 'pending' | 'decoded' | 'not_found' | 'capped' | 'failed'.
|
||||||
|
CREATE TABLE IF NOT EXISTS "rpartstore_decodes" (
|
||||||
|
"vin" text PRIMARY KEY NOT NULL,
|
||||||
|
"status" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"brand_name" text,
|
||||||
|
"model" text,
|
||||||
|
"model_code" text,
|
||||||
|
"family_code" text,
|
||||||
|
"model_year" text,
|
||||||
|
"engine" text,
|
||||||
|
"gearbox" text,
|
||||||
|
"energy_type" text,
|
||||||
|
"manufacturing_date" text,
|
||||||
|
"catalog_vehicle_id" uuid,
|
||||||
|
"raw" jsonb,
|
||||||
|
"attempts" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone,
|
||||||
|
"decoded_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "rpartstore_decodes" ADD CONSTRAINT "rpartstore_decodes_catalog_vehicle_id_catalog_vehicles_id_fk" FOREIGN KEY ("catalog_vehicle_id") REFERENCES "public"."catalog_vehicles"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "rpartstore_decodes_status_idx" ON "rpartstore_decodes" USING btree ("status");
|
||||||
@@ -92,6 +92,181 @@
|
|||||||
"when": 1780581755559,
|
"when": 1780581755559,
|
||||||
"tag": "0012_lifecycle_email_sent",
|
"tag": "0012_lifecycle_email_sent",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780600000000,
|
||||||
|
"tag": "0013_fix_brand_casing",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781136000000,
|
||||||
|
"tag": "0014_proxy_logs",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781222400000,
|
||||||
|
"tag": "0015_oem_votes",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781308800000,
|
||||||
|
"tag": "0016_oem_suggestions",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781395200000,
|
||||||
|
"tag": "0017_oem_expert_rewards",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781481600000,
|
||||||
|
"tag": "0018_part_price_history",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781485200000,
|
||||||
|
"tag": "0019_part_price_brand",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 20,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781268490495,
|
||||||
|
"tag": "0020_stripe_recurring",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 21,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781568000000,
|
||||||
|
"tag": "0021_vinpin_decodes",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 22,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782652379976,
|
||||||
|
"tag": "0022_discount_codes",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 23,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783084194145,
|
||||||
|
"tag": "0023_api_keys",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 24,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783085194145,
|
||||||
|
"tag": "0024_canonical_categories",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 25,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783086194145,
|
||||||
|
"tag": "0025_canonical_wheel_accessory",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783087194145,
|
||||||
|
"tag": "0026_canonical_subcategories",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 27,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783088194145,
|
||||||
|
"tag": "0027_canonical_subcategories_v2",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783089194145,
|
||||||
|
"tag": "0028_canonical_subcat_v3",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 29,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783090194145,
|
||||||
|
"tag": "0029_canonical_structure_template",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 30,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783091194145,
|
||||||
|
"tag": "0030_categories_dedup_unique",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 31,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783092194145,
|
||||||
|
"tag": "0031_canonical_template_entries",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 32,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784023885041,
|
||||||
|
"tag": "0032_part_price_oe_reset",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 33,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784027180912,
|
||||||
|
"tag": "0033_parts_oem_code_norm_idx",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 34,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784965129879,
|
||||||
|
"tag": "0034_vehicle_fully_fetched",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 35,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786435493000,
|
||||||
|
"tag": "0035_subscription_dunning",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 36,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786521893000,
|
||||||
|
"tag": "0036_pl24_mitsubishi_partinfo_cleanup",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 37,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790332800000,
|
||||||
|
"tag": "0037_rpartstore_decodes",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"stripe": "^22.1.1",
|
"stripe": "^22.1.1",
|
||||||
|
"tesseract.js": "^5.1.1",
|
||||||
"undici": "^7.22.0",
|
"undici": "^7.22.0",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,22 +21,30 @@ import { RolesGuard } from "./common/guards/roles.guard";
|
|||||||
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor";
|
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor";
|
||||||
import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor";
|
import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor";
|
||||||
import { TransformInterceptor } from "./common/interceptors/transform.interceptor";
|
import { TransformInterceptor } from "./common/interceptors/transform.interceptor";
|
||||||
|
import { TelegramModule } from "./common/telegram.module";
|
||||||
import configuration from "./config/configuration";
|
import configuration from "./config/configuration";
|
||||||
import { validate } from "./config/env.validation";
|
import { validate } from "./config/env.validation";
|
||||||
import { ContactModule } from "./contact/contact.module";
|
import { ContactModule } from "./contact/contact.module";
|
||||||
import { DatabaseModule } from "./database/database.module";
|
import { DatabaseModule } from "./database/database.module";
|
||||||
import { DemoModule } from "./demo/demo.module";
|
import { DemoModule } from "./demo/demo.module";
|
||||||
|
import { DiscountCodesModule } from "./discount-codes/discount-codes.module";
|
||||||
import { EmailModule } from "./email/email.module";
|
import { EmailModule } from "./email/email.module";
|
||||||
import { HealthController } from "./health.controller";
|
import { HealthController } from "./health.controller";
|
||||||
import { EmexModule } from "./integrations/emex/emex.module";
|
import { EmexModule } from "./integrations/emex/emex.module";
|
||||||
|
import { PModule } from "./integrations/p/p.module";
|
||||||
|
import { VinpinModule } from "./integrations/vinpin/vinpin.module";
|
||||||
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
|
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
|
||||||
import { JobsModule } from "./jobs/jobs.module";
|
import { JobsModule } from "./jobs/jobs.module";
|
||||||
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
|
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
|
||||||
import { NotificationsModule } from "./notifications/notifications.module";
|
import { NotificationsModule } from "./notifications/notifications.module";
|
||||||
|
import { OemSuggestionsModule } from "./oem-suggestions/oem-suggestions.module";
|
||||||
|
import { OemVotesModule } from "./oem-votes/oem-votes.module";
|
||||||
|
import { PartPricesModule } from "./part-prices/part-prices.module";
|
||||||
import { PartsModule } from "./parts/parts.module";
|
import { PartsModule } from "./parts/parts.module";
|
||||||
import { PaymentsModule } from "./payments/payments.module";
|
import { PaymentsModule } from "./payments/payments.module";
|
||||||
import { PlansModule } from "./plans/plans.module";
|
import { PlansModule } from "./plans/plans.module";
|
||||||
import { PostHogModule } from "./posthog/posthog.module";
|
import { PostHogModule } from "./posthog/posthog.module";
|
||||||
|
import { PublicApiModule } from "./public-api/public-api.module";
|
||||||
import { RedisModule } from "./redis/redis.module";
|
import { RedisModule } from "./redis/redis.module";
|
||||||
import { ReferralsModule } from "./referrals/referrals.module";
|
import { ReferralsModule } from "./referrals/referrals.module";
|
||||||
import { StorageModule } from "./storage/storage.module";
|
import { StorageModule } from "./storage/storage.module";
|
||||||
@@ -72,6 +80,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
|||||||
]),
|
]),
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
|
TelegramModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
@@ -81,16 +90,22 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
|||||||
PlansModule,
|
PlansModule,
|
||||||
SubscriptionsModule,
|
SubscriptionsModule,
|
||||||
PaymentsModule,
|
PaymentsModule,
|
||||||
|
DiscountCodesModule,
|
||||||
ReferralsModule,
|
ReferralsModule,
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
CategoriesModule,
|
CategoriesModule,
|
||||||
DemoModule,
|
DemoModule,
|
||||||
PartsModule,
|
PartsModule,
|
||||||
|
PModule,
|
||||||
|
PartPricesModule,
|
||||||
JobsModule,
|
JobsModule,
|
||||||
EmexModule,
|
EmexModule,
|
||||||
|
VinpinModule,
|
||||||
TranslationsModule,
|
TranslationsModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
AnalyticsModule,
|
AnalyticsModule,
|
||||||
|
OemVotesModule,
|
||||||
|
OemSuggestionsModule,
|
||||||
CatalogModule,
|
CatalogModule,
|
||||||
ChangelogModule,
|
ChangelogModule,
|
||||||
ChatwootModule,
|
ChatwootModule,
|
||||||
@@ -100,6 +115,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
|||||||
PostHogModule,
|
PostHogModule,
|
||||||
TelemetryModule,
|
TelemetryModule,
|
||||||
InternalAdminModule,
|
InternalAdminModule,
|
||||||
|
PublicApiModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { generateReferralCode, normalizeName } from "@sase/shared";
|
import { generateReferralCode, normalizeName } from "@sase/shared";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { captcha } from "better-auth/plugins";
|
import { apiKey, captcha } from "better-auth/plugins";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
@@ -224,6 +224,21 @@ export function createAuth(
|
|||||||
"http://localhost:4000",
|
"http://localhost:4000",
|
||||||
],
|
],
|
||||||
plugins: [
|
plugins: [
|
||||||
|
// Public API anahtarları (api.sase.tr / /api/v1). Doğrulama ApiKeyGuard
|
||||||
|
// üzerinden auth.api.verifyApiKey ile yapılır; enableSessionForAPIKeys
|
||||||
|
// bilinçli olarak KAPALI — API anahtarı dashboard oturumu yerine geçmez,
|
||||||
|
// cookie tabanlı AuthGuard'ın davranışı değişmez.
|
||||||
|
apiKey({
|
||||||
|
defaultPrefix: "sase_",
|
||||||
|
enableMetadata: true,
|
||||||
|
// Anahtar başına istek tavanı (verify başına sayaç). Günlük decode
|
||||||
|
// kotasından ayrıdır — o, PublicApiQuotaService'te Redis ile tutulur.
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
timeWindow: 60_000, // 1 dakika
|
||||||
|
maxRequests: 120,
|
||||||
|
},
|
||||||
|
}),
|
||||||
// Cloudflare Turnstile: yalnızca secret tanımlıysa aktif. sign-in/sign-up
|
// Cloudflare Turnstile: yalnızca secret tanımlıysa aktif. sign-in/sign-up
|
||||||
// uçları "x-captcha-response" header'ındaki token ile doğrulanır.
|
// uçları "x-captcha-response" header'ındaki token ile doğrulanır.
|
||||||
...(process.env.TURNSTILE_SECRET_KEY
|
...(process.env.TURNSTILE_SECRET_KEY
|
||||||
@@ -247,3 +262,42 @@ export function getAuth() {
|
|||||||
}
|
}
|
||||||
return authInstance;
|
return authInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** apiKey plugin'inin döndürdüğü anahtar kaydı (verify/create cevapları). */
|
||||||
|
export interface ApiKeyRecord {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string | null;
|
||||||
|
start: string | null;
|
||||||
|
prefix: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* apiKey plugin uçları. `authInstance` geniş `ReturnType<typeof betterAuth>`
|
||||||
|
* ile tiplendiği için plugin uçları tipte görünmüyor (runtime'da mevcutlar);
|
||||||
|
* bu erişimci onları dar ve doğru imzalarla expose eder.
|
||||||
|
*/
|
||||||
|
export function getApiKeyApi() {
|
||||||
|
return getAuth().api as unknown as {
|
||||||
|
verifyApiKey: (input: { body: { key: string } }) => Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
error: { message?: string; code: string } | null;
|
||||||
|
key: ApiKeyRecord | null;
|
||||||
|
}>;
|
||||||
|
createApiKey: (input: {
|
||||||
|
body: {
|
||||||
|
userId: string;
|
||||||
|
name?: string;
|
||||||
|
prefix?: string;
|
||||||
|
expiresIn?: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
rateLimitEnabled?: boolean;
|
||||||
|
rateLimitTimeWindow?: number;
|
||||||
|
rateLimitMax?: number;
|
||||||
|
};
|
||||||
|
}) => Promise<ApiKeyRecord & { key: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,10 +52,7 @@ export class BlogController {
|
|||||||
// Automation endpoint — published by the Süper Panel content pipeline via n8n.
|
// Automation endpoint — published by the Süper Panel content pipeline via n8n.
|
||||||
@Post("posts/internal")
|
@Post("posts/internal")
|
||||||
@Public()
|
@Public()
|
||||||
async createInternal(
|
async createInternal(@Headers("authorization") authHeader: string, @Body() body: CreateBlogPost) {
|
||||||
@Headers("authorization") authHeader: string,
|
|
||||||
@Body() body: CreateBlogPost,
|
|
||||||
) {
|
|
||||||
assertAutomationToken(authHeader);
|
assertAutomationToken(authHeader);
|
||||||
if (!body?.title || !body?.body_markdown) {
|
if (!body?.title || !body?.body_markdown) {
|
||||||
throw new UnauthorizedException("title and body_markdown are required");
|
throw new UnauthorizedException("title and body_markdown are required");
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { Injectable, Logger, NotFoundException, ServiceUnavailableException } from "@nestjs/common";
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { RedisService } from "../redis/redis.service";
|
import { RedisService } from "../redis/redis.service";
|
||||||
import type { BlogPost, BlogPostListItem, CreateBlogPost } from "./blog.dto";
|
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).
|
// slug, but we slugify defensively (and to derive one if it's missing).
|
||||||
function slugify(input: string): string {
|
function slugify(input: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
ç: "c", ğ: "g", ı: "i", İ: "i", ö: "o", ş: "s", ü: "u",
|
ç: "c",
|
||||||
Ç: "c", Ğ: "g", Ö: "o", Ş: "s", Ü: "u",
|
ğ: "g",
|
||||||
|
ı: "i",
|
||||||
|
İ: "i",
|
||||||
|
ö: "o",
|
||||||
|
ş: "s",
|
||||||
|
ü: "u",
|
||||||
|
Ç: "c",
|
||||||
|
Ğ: "g",
|
||||||
|
Ö: "o",
|
||||||
|
Ş: "s",
|
||||||
|
Ü: "u",
|
||||||
};
|
};
|
||||||
return input
|
return (
|
||||||
.split("")
|
input
|
||||||
.map((c) => map[c] ?? c)
|
.split("")
|
||||||
.join("")
|
.map((c) => map[c] ?? c)
|
||||||
.toLowerCase()
|
.join("")
|
||||||
.normalize("NFD")
|
.toLowerCase()
|
||||||
.replace(/[̀-ͯ]/g, "")
|
.normalize("NFD")
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
.replace(/[̀-ͯ]/g, "")
|
||||||
.replace(/^-+|-+$/g, "")
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
.slice(0, 160) || "yazi";
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 160) || "yazi"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -95,7 +102,9 @@ export class BlogService {
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await res.text().catch(() => "");
|
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 };
|
const json = (await res.json()) as { data: T };
|
||||||
return json.data;
|
return json.data;
|
||||||
@@ -136,7 +145,8 @@ export class BlogService {
|
|||||||
"filter[project][_eq]": DIRECTUS_PROJECT,
|
"filter[project][_eq]": DIRECTUS_PROJECT,
|
||||||
"filter[status][_eq]": "published",
|
"filter[status][_eq]": "published",
|
||||||
sort: "-published_at",
|
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",
|
limit: "-1",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
192
apps/api/src/catalog/catalog-browse-heal.spec.ts
Normal file
192
apps/api/src/catalog/catalog-browse-heal.spec.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { isStaleBrowseArchitecture } from "../integrations/pl24/pl24-tree";
|
||||||
|
import { CatalogService } from "./catalog.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression lock for the pinned-browse-rows bug (plv2.md, finding consumers-09).
|
||||||
|
*
|
||||||
|
* `catalog_vehicles` is a permanent cache: `getModels` returns early as soon as
|
||||||
|
* a brand has any row, so `fetchVehicleList` never runs again for that brand.
|
||||||
|
* The 188 rows listed while PSA and Volvo were still P4 (127 LEGACY_PSA + 61
|
||||||
|
* LEGACY_VOLVO on prod, 2026-09-20) were therefore stuck serving the frozen
|
||||||
|
* 2024-02-13 PSA snapshot and a Volvo endpoint that answers HTTP 503.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
serviceName: string;
|
||||||
|
architecture: string | null;
|
||||||
|
brandId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const psaRow = (id: string): Row => ({
|
||||||
|
id,
|
||||||
|
serviceName: "peugeot_parts",
|
||||||
|
architecture: "LEGACY_PSA",
|
||||||
|
brandId: "b1",
|
||||||
|
});
|
||||||
|
const freshRow = (id: string): Row => ({
|
||||||
|
id,
|
||||||
|
serviceName: "peugeot_parts",
|
||||||
|
architecture: "P5_MODERN",
|
||||||
|
brandId: "b1",
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeService(opts: {
|
||||||
|
lockFree?: boolean;
|
||||||
|
fetched?: Array<{ model: string; vehicleId: string }>;
|
||||||
|
fetchThrows?: boolean;
|
||||||
|
refetched?: Row[];
|
||||||
|
}) {
|
||||||
|
const deleted: string[][] = [];
|
||||||
|
const inserted: unknown[][] = [];
|
||||||
|
|
||||||
|
const tx = {
|
||||||
|
insert: () => ({
|
||||||
|
values: (v: unknown[]) => {
|
||||||
|
inserted.push(v);
|
||||||
|
return { onConflictDoNothing: async () => undefined };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
delete: () => ({
|
||||||
|
where: async (cond: unknown) => {
|
||||||
|
// drizzle's inArray() puts its bound values in a `queryChunks` entry
|
||||||
|
// that is itself an array of Param objects; pull the plain ids back out
|
||||||
|
// so a test can assert WHICH rows were removed, not just how many.
|
||||||
|
const chunks = (cond as { queryChunks?: unknown[] })?.queryChunks ?? [];
|
||||||
|
const params = chunks.find((c): c is unknown[] => Array.isArray(c)) ?? [];
|
||||||
|
const ids = params
|
||||||
|
.map((param) => (param as { value?: unknown })?.value)
|
||||||
|
.filter((v): v is string => typeof v === "string");
|
||||||
|
deleted.push(ids);
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const db = {
|
||||||
|
select: () => ({ from: () => ({ where: async () => opts.refetched ?? [] }) }),
|
||||||
|
transaction: async (cb: (t: typeof tx) => Promise<void>) => cb(tx),
|
||||||
|
};
|
||||||
|
const redis = { setNx: vi.fn(async () => opts.lockFree !== false) };
|
||||||
|
const pl24Service = {
|
||||||
|
fetchVehicleList: vi.fn(async () => {
|
||||||
|
if (opts.fetchThrows) throw new Error("upstream 503");
|
||||||
|
return opts.fetched ?? [];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const svc = new CatalogService(
|
||||||
|
db as never,
|
||||||
|
redis as never,
|
||||||
|
pl24Service as never,
|
||||||
|
{} as never,
|
||||||
|
) as never as {
|
||||||
|
healStaleBrowseRows: (brand: string, rows: Row[], where: unknown[]) => Promise<Row[] | null>;
|
||||||
|
};
|
||||||
|
return { svc, redis, pl24Service, deleted, inserted };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isStaleBrowseArchitecture", () => {
|
||||||
|
it("flags a row whose stored architecture is not the current one", () => {
|
||||||
|
expect(
|
||||||
|
isStaleBrowseArchitecture({
|
||||||
|
storedArchitecture: "LEGACY_PSA",
|
||||||
|
currentArchitecture: "P5_MODERN",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isStaleBrowseArchitecture({
|
||||||
|
storedArchitecture: "LEGACY_VOLVO",
|
||||||
|
currentArchitecture: "P5_MODERN",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a matching row alone", () => {
|
||||||
|
expect(
|
||||||
|
isStaleBrowseArchitecture({
|
||||||
|
storedArchitecture: "P5_MODERN",
|
||||||
|
currentArchitecture: "P5_MODERN",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
// Brands that are still P4 upstream must not be touched.
|
||||||
|
expect(
|
||||||
|
isStaleBrowseArchitecture({
|
||||||
|
storedArchitecture: "LEGACY_FORD",
|
||||||
|
currentArchitecture: "LEGACY_FORD",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing without both sides (unknown service / unlabelled row)", () => {
|
||||||
|
expect(
|
||||||
|
isStaleBrowseArchitecture({ storedArchitecture: null, currentArchitecture: "P5_MODERN" }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isStaleBrowseArchitecture({ storedArchitecture: "LEGACY_PSA", currentArchitecture: null }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("healStaleBrowseRows", () => {
|
||||||
|
it("does not touch PL24 when every row is current", async () => {
|
||||||
|
const { svc, pl24Service, redis } = makeService({});
|
||||||
|
const out = await svc.healStaleBrowseRows("Peugeot", [freshRow("a"), freshRow("b")], []);
|
||||||
|
expect(out).toBeNull();
|
||||||
|
expect(pl24Service.fetchVehicleList).not.toHaveBeenCalled();
|
||||||
|
expect(redis.setNx).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-lists a stale brand and replaces its rows", async () => {
|
||||||
|
const refetched = [freshRow("new1"), freshRow("new2")];
|
||||||
|
const { svc, pl24Service, deleted, inserted } = makeService({
|
||||||
|
fetched: [
|
||||||
|
{ model: "208", vehicleId: "p5-208" },
|
||||||
|
{ model: "308", vehicleId: "p5-308" },
|
||||||
|
],
|
||||||
|
refetched,
|
||||||
|
});
|
||||||
|
const out = await svc.healStaleBrowseRows("Peugeot", [psaRow("old1"), psaRow("old2")], []);
|
||||||
|
expect(pl24Service.fetchVehicleList).toHaveBeenCalledWith("peugeot_parts");
|
||||||
|
expect(inserted).toHaveLength(1);
|
||||||
|
expect(inserted[0]).toHaveLength(2);
|
||||||
|
expect(deleted).toHaveLength(1);
|
||||||
|
expect(out).toEqual(refetched);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes only the stale rows when a service holds a mix", async () => {
|
||||||
|
// A row already re-listed under the new architecture conflicts on insert and
|
||||||
|
// is skipped, so deleting it would drop it for good.
|
||||||
|
const { svc, deleted } = makeService({
|
||||||
|
fetched: [{ model: "208", vehicleId: "p5-208" }],
|
||||||
|
refetched: [freshRow("keep")],
|
||||||
|
});
|
||||||
|
await svc.healStaleBrowseRows("Peugeot", [psaRow("old1"), freshRow("keep")], []);
|
||||||
|
expect(deleted).toHaveLength(1);
|
||||||
|
expect(deleted).toEqual([["old1"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the stale rows when the re-list throws", async () => {
|
||||||
|
const { svc, deleted, inserted } = makeService({ fetchThrows: true });
|
||||||
|
const out = await svc.healStaleBrowseRows("Volvo", [psaRow("old1")], []);
|
||||||
|
expect(out).toBeNull();
|
||||||
|
expect(inserted).toHaveLength(0);
|
||||||
|
expect(deleted).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the stale rows when upstream returns an empty model list", async () => {
|
||||||
|
const { svc, deleted, inserted } = makeService({ fetched: [] });
|
||||||
|
const out = await svc.healStaleBrowseRows("Volvo", [psaRow("old1")], []);
|
||||||
|
expect(out).toBeNull();
|
||||||
|
expect(inserted).toHaveLength(0);
|
||||||
|
expect(deleted).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attempts at most once a day per brand", async () => {
|
||||||
|
const { svc, pl24Service } = makeService({ lockFree: false });
|
||||||
|
const out = await svc.healStaleBrowseRows("Peugeot", [psaRow("old1")], []);
|
||||||
|
expect(out).toBeNull();
|
||||||
|
expect(pl24Service.fetchVehicleList).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,6 +81,19 @@ export class CatalogController {
|
|||||||
return this.catalogService.getCategoryTree(id, user.id, body, engine, gearbox, mainGroupsPath);
|
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")
|
@Get("vehicles/:id/categories/:categoryId")
|
||||||
getCategoryWithParts(
|
getCategoryWithParts(
|
||||||
@Param("id") id: string,
|
@Param("id") id: string,
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
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 { DATABASE, type Database } from "../database/database.provider";
|
||||||
import {
|
import {
|
||||||
brands,
|
brands,
|
||||||
|
canonicalCategories,
|
||||||
|
canonicalStructureTemplate,
|
||||||
|
canonicalTemplateEntries,
|
||||||
catalogVehicles,
|
catalogVehicles,
|
||||||
categories,
|
categories,
|
||||||
parts,
|
parts,
|
||||||
@@ -11,6 +19,7 @@ import {
|
|||||||
userBrands,
|
userBrands,
|
||||||
userSubscriptions,
|
userSubscriptions,
|
||||||
} from "../database/schema/core";
|
} from "../database/schema/core";
|
||||||
|
import { isPl24LeafNode, isStaleBrowseArchitecture } from "../integrations/pl24/pl24-tree";
|
||||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||||
import {
|
import {
|
||||||
type PL24DecodedCategory,
|
type PL24DecodedCategory,
|
||||||
@@ -161,7 +170,8 @@ export class CatalogService {
|
|||||||
.where(and(...whereConditions));
|
.where(and(...whereConditions));
|
||||||
|
|
||||||
if (dbVehicles.length > 0) {
|
if (dbVehicles.length > 0) {
|
||||||
return dbVehicles;
|
const healed = await this.healStaleBrowseRows(brandName, dbVehicles, whereConditions);
|
||||||
|
return healed ?? dbVehicles;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch from PL24 for each service
|
// Fetch from PL24 for each service
|
||||||
@@ -216,6 +226,118 @@ export class CatalogService {
|
|||||||
return allVehicles;
|
return allVehicles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-list a brand whose stored browse rows were created under a retired PL24
|
||||||
|
* architecture, and replace them with the current ones.
|
||||||
|
*
|
||||||
|
* WHY (plv2.md, finding consumers-09): `getModels` treats `catalog_vehicles`
|
||||||
|
* as a permanent cache — one row for the brand and `fetchVehicleList` is never
|
||||||
|
* called again. The rows listed while PSA and Volvo were still P4 are pinned
|
||||||
|
* forever, so Peugeot/Citroën browse keeps serving the frozen 2024-02-13
|
||||||
|
* snapshot and Volvo/Polestar browse keeps calling an endpoint that answers
|
||||||
|
* HTTP 503. A code fix alone never reaches these rows.
|
||||||
|
*
|
||||||
|
* Deliberately conservative, mirroring `healStalePl24Architecture` on the
|
||||||
|
* VIN side:
|
||||||
|
* - only rows whose stored architecture disagrees with the service table;
|
||||||
|
* - one attempt per brand per day (Redis lock) so a permanently failing
|
||||||
|
* re-list cannot hammer the one surviving account on every page view;
|
||||||
|
* - a failed or empty re-list leaves the old rows in place — stale data
|
||||||
|
* beats an empty catalog;
|
||||||
|
* - the stale rows are deleted only once the replacements are committed,
|
||||||
|
* and per service, so one broken sub-catalog cannot wipe a working one.
|
||||||
|
*
|
||||||
|
* Deleting a browse row cascades to its categories and parts. That is
|
||||||
|
* intended: those rows describe the retired tree and would otherwise survive
|
||||||
|
* as unreachable orphans under the new listing.
|
||||||
|
*
|
||||||
|
* Returns the refreshed rows, or null when nothing was migrated (caller keeps
|
||||||
|
* what it already had).
|
||||||
|
*/
|
||||||
|
private async healStaleBrowseRows(
|
||||||
|
brandName: string,
|
||||||
|
dbVehicles: (typeof catalogVehicles.$inferSelect)[],
|
||||||
|
whereConditions: ReturnType<typeof eq>[],
|
||||||
|
): Promise<(typeof catalogVehicles.$inferSelect)[] | null> {
|
||||||
|
const isStale = (v: typeof catalogVehicles.$inferSelect) =>
|
||||||
|
isStaleBrowseArchitecture({
|
||||||
|
storedArchitecture: v.architecture,
|
||||||
|
currentArchitecture: PL24_SERVICE_CATALOGS[v.serviceName]?.architecture,
|
||||||
|
});
|
||||||
|
const staleServices = [...new Set(dbVehicles.filter(isStale).map((v) => v.serviceName))];
|
||||||
|
if (staleServices.length === 0) return null;
|
||||||
|
|
||||||
|
if (!(await this.redis.setNx(`pl24:browse-heal:${brandName}`, "1", 86_400))) return null;
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[pl24-browse-heal] ${brandName}: ${staleServices.join(", ")} listed under a retired architecture — re-listing`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let migrated = 0;
|
||||||
|
for (const svc of staleServices) {
|
||||||
|
// ONLY the stale rows. A service can hold a mix — some rows already
|
||||||
|
// re-listed under the new architecture — and those must survive: the
|
||||||
|
// insert below skips them on conflict, so deleting them here would drop
|
||||||
|
// them for good.
|
||||||
|
const staleIds = dbVehicles
|
||||||
|
.filter((v) => v.serviceName === svc && isStale(v))
|
||||||
|
.map((v) => v.id);
|
||||||
|
try {
|
||||||
|
const fetched = await this.pl24Service.fetchVehicleList(svc);
|
||||||
|
if (fetched.length === 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[pl24-browse-heal] ${svc}: upstream returned no models — keeping ${staleIds.length} stale row(s)`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const config = PL24_SERVICE_CATALOGS[svc];
|
||||||
|
const brandId = dbVehicles.find((v) => v.serviceName === svc)?.brandId ?? null;
|
||||||
|
await this.db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.insert(catalogVehicles)
|
||||||
|
.values(
|
||||||
|
fetched.map((v) => ({
|
||||||
|
source: "pl24" as const,
|
||||||
|
serviceName: svc,
|
||||||
|
brandName,
|
||||||
|
brandId,
|
||||||
|
model: v.model,
|
||||||
|
year: v.year || null,
|
||||||
|
engine: v.engine || null,
|
||||||
|
bodyType: v.bodyType || null,
|
||||||
|
transmission: v.transmission || null,
|
||||||
|
market: v.market || null,
|
||||||
|
serviceVehicleId: v.vehicleId,
|
||||||
|
catalogPath: v.catalogPath || null,
|
||||||
|
architecture: config?.architecture || "P5_MODERN",
|
||||||
|
metadata: v.metadata || null,
|
||||||
|
categoriesFetched: false,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing();
|
||||||
|
if (staleIds.length > 0) {
|
||||||
|
await tx.delete(catalogVehicles).where(inArray(catalogVehicles.id, staleIds));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
migrated += staleIds.length;
|
||||||
|
this.logger.log(
|
||||||
|
`[pl24-browse-heal] ${svc}: ${staleIds.length} stale row(s) → ${fetched.length} fresh model(s)`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[pl24-browse-heal] ${svc} re-list failed, keeping stale rows: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrated === 0) return null;
|
||||||
|
return await this.db
|
||||||
|
.select()
|
||||||
|
.from(catalogVehicles)
|
||||||
|
.where(and(...whereConditions));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single catalog vehicle by ID.
|
* Get a single catalog vehicle by ID.
|
||||||
*/
|
*/
|
||||||
@@ -349,6 +471,130 @@ export class CatalogService {
|
|||||||
/**
|
/**
|
||||||
* Get category tree for a catalog vehicle (on-demand fetch from PL24).
|
* 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(
|
async getCategoryTree(
|
||||||
catalogVehicleId: string,
|
catalogVehicleId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -1117,18 +1363,10 @@ export class CatalogService {
|
|||||||
return this.pl24Service.fetchFordModelConfig(vehicle.serviceName, familyId, mode, upds);
|
return this.pl24Service.fetchFordModelConfig(vehicle.serviceName, familyId, mode, upds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shared PL24 leaf classifier — see integrations/pl24/pl24-tree. */
|
||||||
private isLeafPath(linkPath: string): boolean {
|
private isLeafPath(linkPath: string): boolean {
|
||||||
const lower = linkPath.toLowerCase();
|
|
||||||
return (
|
return (
|
||||||
lower.includes("/bom/") ||
|
isPl24LeafNode({ linkPath }) || linkPath.toLowerCase().includes("json-vin-bom-detail.action")
|
||||||
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")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,13 +1473,9 @@ export class CatalogService {
|
|||||||
|
|
||||||
return cats.map((c) => {
|
return cats.map((c) => {
|
||||||
const dbChildCount = childCountMap.get(c.id) || 0;
|
const dbChildCount = childCountMap.get(c.id) || 0;
|
||||||
const isLeaf =
|
const isLeaf = c.linkPath
|
||||||
c.linkPath?.includes("/bom/") ||
|
? isPl24LeafNode({ linkPath: c.linkPath, linkWid: c.linkWid })
|
||||||
c.linkPath?.includes("/bomdetails") ||
|
: dbChildCount === 0;
|
||||||
c.linkPath?.includes("/partinfo/") ||
|
|
||||||
c.linkPath?.includes("/servicepart/vin_items") ||
|
|
||||||
c.linkPath?.includes("image-board.action") || // PSA illustration leaf
|
|
||||||
(!c.linkPath && dbChildCount === 0);
|
|
||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
schemaImageUrl: picMap.get(c.id) || null,
|
schemaImageUrl: picMap.get(c.id) || null,
|
||||||
|
|||||||
368
apps/api/src/categories/canonical-grouping.ts
Normal file
368
apps/api/src/categories/canonical-grouping.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -5,9 +5,16 @@ import { CategoriesService } from "./categories.service";
|
|||||||
export class CategoriesController {
|
export class CategoriesController {
|
||||||
constructor(private categoriesService: CategoriesService) {}
|
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")
|
@Get("tree/:vehicleId")
|
||||||
async getCategoryTree(@Param("vehicleId") vehicleId: string) {
|
async getCategoryTree(@Param("vehicleId") vehicleId: string, @Query("source") source?: string) {
|
||||||
return this.categoriesService.getCategoryTree(vehicleId);
|
return this.categoriesService.getCategoryTree(vehicleId, source);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("search/:vehicleId")
|
@Get("search/:vehicleId")
|
||||||
@@ -15,6 +22,27 @@ export class CategoriesController {
|
|||||||
return this.categoriesService.searchCatalog(vehicleId, q ?? "");
|
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")
|
@Get(":id/children")
|
||||||
async getChildren(@Param("id") id: string) {
|
async getChildren(@Param("id") id: string) {
|
||||||
return this.categoriesService.getChildren(id);
|
return this.categoriesService.getChildren(id);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ function createService(db: any) {
|
|||||||
};
|
};
|
||||||
const pl24Service = {
|
const pl24Service = {
|
||||||
getCategories: vi.fn().mockResolvedValue([]),
|
getCategories: vi.fn().mockResolvedValue([]),
|
||||||
|
// P4→P5 onarma yolu bunu çağırır (plv2 Faz 2).
|
||||||
|
decodeVin: vi.fn().mockResolvedValue(null),
|
||||||
};
|
};
|
||||||
const emexService = {
|
const emexService = {
|
||||||
isSupported: vi.fn().mockReturnValue(false),
|
isSupported: vi.fn().mockReturnValue(false),
|
||||||
@@ -320,6 +322,84 @@ describe("CategoriesService", () => {
|
|||||||
"drill-load-error",
|
"drill-load-error",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes a P4 Ford VIN node (json-vin-sub-group.action, positional link_wid) to children, not an empty leaf", async () => {
|
||||||
|
// link_wid "1" is a positional code (not a *Group* table) so the link_wid
|
||||||
|
// group-drill branch misses it; the path is …group.action (not /psa/, not
|
||||||
|
// vin-group.action) so the PSA / Volvo branches miss it too. Before the
|
||||||
|
// dedicated gate these fell through to the leaf parts path → silent empty
|
||||||
|
// panel (Ford/Nissan/Opel analog of the PSA-parent-gate gap).
|
||||||
|
const category = {
|
||||||
|
id: "ford1",
|
||||||
|
name: "elektrik sistemi",
|
||||||
|
nameOriginal: "elektrik sistemi",
|
||||||
|
parentId: null,
|
||||||
|
vehicleId: "v1",
|
||||||
|
source: "pl24",
|
||||||
|
linkPath: "/ford/fordp_parts/json-vin-sub-group.action?mainGroupId=CAP1&vin=WF0FXX",
|
||||||
|
linkWid: "1",
|
||||||
|
hasSubgroups: null,
|
||||||
|
hasParts: null,
|
||||||
|
};
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
|
||||||
|
c.limit = vi.fn().mockReturnValue([category]);
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
execute: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
const discovered = [{ id: "sg1", name: "kablolar", children: undefined }];
|
||||||
|
vi.spyOn(service, "getChildren").mockResolvedValue(discovered as any);
|
||||||
|
|
||||||
|
const result = await service.getCategoryWithParts("ford1");
|
||||||
|
|
||||||
|
expect(service.getChildren).toHaveBeenCalledWith("ford1");
|
||||||
|
expect(result.parts).toEqual([]);
|
||||||
|
expect((result as { children?: unknown }).children).toEqual(discovered);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces loadError when a P4 group.action drill comes back empty (not a silent leaf)", async () => {
|
||||||
|
const category = {
|
||||||
|
id: "ford2",
|
||||||
|
name: "kaporta",
|
||||||
|
nameOriginal: "kaporta",
|
||||||
|
parentId: null,
|
||||||
|
vehicleId: "v1",
|
||||||
|
source: "pl24",
|
||||||
|
linkPath: "/ford/fordp_parts/json-vin-sub-group.action?mainGroupId=GDB&vin=WF0FXX",
|
||||||
|
linkWid: "2",
|
||||||
|
hasSubgroups: null,
|
||||||
|
hasParts: null,
|
||||||
|
};
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
|
||||||
|
c.limit = vi.fn().mockReturnValue([category]);
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
execute: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
vi.spyOn(service, "getChildren").mockResolvedValue([] as any);
|
||||||
|
|
||||||
|
const result = await service.getCategoryWithParts("ford2");
|
||||||
|
|
||||||
|
expect(service.getChildren).toHaveBeenCalledWith("ford2");
|
||||||
|
expect((result as { loadError?: boolean }).loadError).toBe(true);
|
||||||
|
expect((result as { children?: unknown }).children).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getAncestors", () => {
|
describe("getAncestors", () => {
|
||||||
@@ -384,3 +464,81 @@ describe("CategoriesService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── P4 → P5 kendi kendini onarma (plv2 Faz 2, bulgu psa-07) ──
|
||||||
|
// PSA/Volvo upstream'de P5'e taşındı; daha önce decode edilmiş araçlar donmuş
|
||||||
|
// 2024-02-13 PSA anlık görüntüsüne / ölü P4 Volvo ucuna işaret etmeye devam
|
||||||
|
// ediyor ve decodeVin'in db_hit kısa devresi kod düzeltmesini onlara ulaştırmıyor.
|
||||||
|
describe("CategoriesService — bayat PL24 mimarisini onarma", () => {
|
||||||
|
const makeVehicle = (catalogPath: string) => ({
|
||||||
|
id: "veh-1",
|
||||||
|
vin: "VF3MCBHZWHS150390",
|
||||||
|
rawData: { catalogInfo: { serviceName: "peugeot_parts", catalogPath } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const setup = (catalogPath: string, decodeResult: unknown) => {
|
||||||
|
const { service, redis, pl24Service } = createService({} as never);
|
||||||
|
const p = service as unknown as {
|
||||||
|
healStalePl24Architecture(
|
||||||
|
id: string,
|
||||||
|
v: { vin: string | null; rawData: unknown },
|
||||||
|
n: number,
|
||||||
|
): Promise<boolean>;
|
||||||
|
db: unknown;
|
||||||
|
};
|
||||||
|
// setNx: ilk denemeye izin ver (günlük tek deneme kilidi)
|
||||||
|
(redis as unknown as { setNx: unknown }).setNx = vi.fn().mockResolvedValue(true);
|
||||||
|
(redis as unknown as { del: unknown }).del = vi.fn().mockResolvedValue(undefined);
|
||||||
|
pl24Service.decodeVin = vi.fn().mockResolvedValue(decodeResult);
|
||||||
|
return { service, p, redis, pl24Service, vehicle: makeVehicle(catalogPath) };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("P4 PSA yolundaki araç yeniden decode edilir ve ağaç değişir", async () => {
|
||||||
|
const fresh = {
|
||||||
|
catalogInfo: { serviceName: "peugeot_parts", catalogPath: "/p5psa" },
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
code: "_FCT0001",
|
||||||
|
nameTr: "Mekanik",
|
||||||
|
nameEn: "Mechanical",
|
||||||
|
linkPath: "/p5psa/x",
|
||||||
|
linkWid: "mainGroupTable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { p, pl24Service, vehicle } = setup("/psa/peugeot_parts", fresh);
|
||||||
|
const inserted: unknown[] = [];
|
||||||
|
(p as unknown as { db: unknown }).db = {
|
||||||
|
transaction: async (fn: (tx: unknown) => Promise<void>) => {
|
||||||
|
await fn({
|
||||||
|
delete: () => ({ where: async () => undefined }),
|
||||||
|
insert: () => ({ values: async (rows: unknown[]) => inserted.push(...rows) }),
|
||||||
|
update: () => ({ set: () => ({ where: async () => undefined }) }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(p.healStalePl24Architecture("veh-1", vehicle, 120)).resolves.toBe(true);
|
||||||
|
expect(pl24Service.decodeVin).toHaveBeenCalledWith("VF3MCBHZWHS150390");
|
||||||
|
expect(inserted).toHaveLength(1);
|
||||||
|
expect((inserted[0] as { name: string }).name).toBe("Mekanik");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zaten P5 olan araca dokunulmaz (decode çağrılmaz)", async () => {
|
||||||
|
const { p, pl24Service, vehicle } = setup("/p5psa", null);
|
||||||
|
await expect(p.healStalePl24Architecture("veh-1", vehicle, 6)).resolves.toBe(false);
|
||||||
|
expect(pl24Service.decodeVin).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yeniden decode boş dönerse eski ağaç korunur", async () => {
|
||||||
|
const { p, vehicle } = setup("/psa/peugeot_parts", { categories: [] });
|
||||||
|
await expect(p.healStalePl24Architecture("veh-1", vehicle, 120)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("günde bir denenir (setNx kilidi)", async () => {
|
||||||
|
const { p, redis, pl24Service, vehicle } = setup("/psa/peugeot_parts", { categories: [] });
|
||||||
|
(redis as unknown as { setNx: unknown }).setNx = vi.fn().mockResolvedValue(false);
|
||||||
|
await expect(p.healStalePl24Architecture("veh-1", vehicle, 120)).resolves.toBe(false);
|
||||||
|
expect(pl24Service.decodeVin).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
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 {
|
import {
|
||||||
type CatalogDegradationKind,
|
type CatalogDegradationKind,
|
||||||
reportCatalogDegradation,
|
reportCatalogDegradation,
|
||||||
} from "../common/catalog-degradation";
|
} from "../common/catalog-degradation";
|
||||||
import { DATABASE, type Database } from "../database/database.provider";
|
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 { EmexSourceDbService } from "../integrations/catalog-source-db/emex-source-db.service";
|
||||||
import { PcatSourceDbService } from "../integrations/catalog-source-db/pcat-source-db.service";
|
import { PcatSourceDbService } from "../integrations/catalog-source-db/pcat-source-db.service";
|
||||||
import { EmexService } from "../integrations/emex/emex.service";
|
import { EmexService } from "../integrations/emex/emex.service";
|
||||||
@@ -13,10 +21,22 @@ import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catal
|
|||||||
import { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
import { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
||||||
import { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
|
import { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
|
||||||
import { PL24PsaService } from "../integrations/pl24/pl24-psa.service";
|
import { PL24PsaService } from "../integrations/pl24/pl24-psa.service";
|
||||||
|
import {
|
||||||
|
isPl24GroupNode,
|
||||||
|
isPl24LeafNode,
|
||||||
|
isStalePl24Architecture,
|
||||||
|
} from "../integrations/pl24/pl24-tree";
|
||||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||||
|
import { getServiceApiPath } from "../integrations/pl24/pl24.types";
|
||||||
|
import { classifyNode, foldName, mapToCanonical } from "../jobs/canonical-lexicon";
|
||||||
import { RedisService } from "../redis/redis.service";
|
import { RedisService } from "../redis/redis.service";
|
||||||
import { StorageService } from "../storage/storage.service";
|
import { StorageService } from "../storage/storage.service";
|
||||||
import { TranslationsService } from "../translations/translations.service";
|
import { TranslationsService } from "../translations/translations.service";
|
||||||
|
import {
|
||||||
|
buildCanonicalInput,
|
||||||
|
groupCategoriesByCanonical,
|
||||||
|
overlayTemplate,
|
||||||
|
} from "./canonical-grouping";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CategoriesService {
|
export class CategoriesService {
|
||||||
@@ -36,8 +56,8 @@ export class CategoriesService {
|
|||||||
private emexSourceDb: EmexSourceDbService,
|
private emexSourceDb: EmexSourceDbService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getCategoryTree(vehicleId: string) {
|
async getCategoryTree(vehicleId: string, sourceFilter?: string) {
|
||||||
const cacheKey = `cat:tree:${vehicleId}`;
|
const cacheKey = `cat:tree:${vehicleId}${sourceFilter ? `:${sourceFilter}` : ""}`;
|
||||||
const cached = await this.redis.getJson<any[]>(cacheKey);
|
const cached = await this.redis.getJson<any[]>(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
@@ -56,6 +76,20 @@ export class CategoriesService {
|
|||||||
.from(categories)
|
.from(categories)
|
||||||
.where(eq(categories.vehicleId, vehicleId));
|
.where(eq(categories.vehicleId, vehicleId));
|
||||||
|
|
||||||
|
// ── P4 → P5 self-healing (plv2.md Faz 2, bulgu psa-07) ──
|
||||||
|
// PSA and Volvo moved to P5 upstream; rows decoded before that still point at
|
||||||
|
// the frozen 2024-02-13 PSA snapshot or the dead P4 Volvo endpoint, and
|
||||||
|
// decodeVin's db_hit short-circuit means no code fix ever reaches them. Heal
|
||||||
|
// one vehicle per view instead of a bulk re-decode storm against the single
|
||||||
|
// surviving account.
|
||||||
|
const healed = await this.healStalePl24Architecture(vehicleId, vehicle, dbCategories.length);
|
||||||
|
if (healed) {
|
||||||
|
dbCategories = await this.db
|
||||||
|
.select()
|
||||||
|
.from(categories)
|
||||||
|
.where(eq(categories.vehicleId, vehicleId));
|
||||||
|
}
|
||||||
|
|
||||||
// If no categories in DB, fetch from PL24
|
// If no categories in DB, fetch from PL24
|
||||||
if (dbCategories.length === 0 && vehicle.rawData) {
|
if (dbCategories.length === 0 && vehicle.rawData) {
|
||||||
const rawData = vehicle.rawData as any;
|
const rawData = vehicle.rawData as any;
|
||||||
@@ -120,6 +154,10 @@ export class CategoriesService {
|
|||||||
externalId: s.code,
|
externalId: s.code,
|
||||||
linkPath: s.linkPath || null,
|
linkPath: s.linkPath || null,
|
||||||
linkWid: null as string | null,
|
linkWid: null as string | null,
|
||||||
|
// PSA scopes are always parents (they hold main-groups, never direct
|
||||||
|
// parts) — flag them so the tree UI signposts them as folders and the
|
||||||
|
// prefetch worker pre-drills them instead of treating them as leaves.
|
||||||
|
hasSubgroups: true,
|
||||||
source: "pl24" as const,
|
source: "pl24" as const,
|
||||||
}));
|
}));
|
||||||
dbCategories = await this.db
|
dbCategories = await this.db
|
||||||
@@ -181,6 +219,10 @@ export class CategoriesService {
|
|||||||
externalId: c.code,
|
externalId: c.code,
|
||||||
linkPath: c.linkPath || null,
|
linkPath: c.linkPath || null,
|
||||||
linkWid: c.linkWid || null,
|
linkWid: c.linkWid || null,
|
||||||
|
// Mark P4 VIN/legacy group-drill nodes (…group.action) as parents so
|
||||||
|
// the tree UI shows them as folders and prefetch pre-drills them. Leaf
|
||||||
|
// pages (image-board.action/bom) stay null (unknown→leaf downstream).
|
||||||
|
hasSubgroups: c.linkPath?.includes("group.action") ? true : null,
|
||||||
source: "pl24" as const,
|
source: "pl24" as const,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -435,8 +477,23 @@ export class CategoriesService {
|
|||||||
partCountRows.map((r) => [r.categoryId, r.count]),
|
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
|
// 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
|
// 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 —
|
// 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
|
// it self-heals on the next request after the source recovers, while still
|
||||||
@@ -458,6 +515,210 @@ export class CategoriesService {
|
|||||||
return tree;
|
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.
|
* Get sub-categories (sub-groups) for a category.
|
||||||
* Fetches from PL24 on-demand if not cached.
|
* Fetches from PL24 on-demand if not cached.
|
||||||
@@ -655,17 +916,13 @@ export class CategoriesService {
|
|||||||
// subgroups. Drilling into them used to insert per-part endpoints as
|
// subgroups. Drilling into them used to insert per-part endpoints as
|
||||||
// ghost child categories — keep the regex wide so any /extern/{kind}/
|
// ghost child categories — keep the regex wide so any /extern/{kind}/
|
||||||
// (vin|mdl)_items endpoint is recognised, not just /servicepart/.
|
// (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 (
|
if (
|
||||||
lp.includes("/bom/") ||
|
isPl24LeafNode({ linkPath }) ||
|
||||||
lp.includes("/bomdetails") ||
|
linkPath.toLowerCase().includes("json-vin-bom-detail.action")
|
||||||
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")
|
|
||||||
) {
|
) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -750,6 +1007,104 @@ export class CategoriesService {
|
|||||||
* The per-node trail excludes the node itself, ordered root-first. Used by the
|
* The per-node trail excludes the node itself, ordered root-first. Used by the
|
||||||
* catalog search so each hit can show where it sits in the tree.
|
* catalog search so each hit can show where it sits in the tree.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Re-decode a vehicle whose stored catalogInfo still points at a retired PL24
|
||||||
|
* architecture (P4 PSA/Volvo) and replace its category tree with the P5 one.
|
||||||
|
*
|
||||||
|
* Returns true when the tree was rebuilt. Deliberately conservative:
|
||||||
|
* - only PSA/Volvo rows whose service is P5 today are touched;
|
||||||
|
* - a failed or empty re-decode leaves the old tree in place (stale data beats
|
||||||
|
* no data), and the attempt is remembered for a day so a permanently
|
||||||
|
* unresolvable VIN cannot re-hit PL24 on every page view;
|
||||||
|
* - the old rows are deleted only once the new tree is in hand, because the
|
||||||
|
* unique (vehicle_id, name, source) index would otherwise reject the insert.
|
||||||
|
*/
|
||||||
|
private async healStalePl24Architecture(
|
||||||
|
vehicleId: string,
|
||||||
|
vehicle: { vin: string | null; rawData: unknown },
|
||||||
|
existingCategoryCount: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const rawData = vehicle.rawData as {
|
||||||
|
catalogInfo?: { serviceName?: string; catalogPath?: string };
|
||||||
|
} | null;
|
||||||
|
const catalogInfo = rawData?.catalogInfo;
|
||||||
|
const serviceName = catalogInfo?.serviceName;
|
||||||
|
if (!vehicle.vin || !serviceName) return false;
|
||||||
|
if (
|
||||||
|
!isStalePl24Architecture({
|
||||||
|
catalogPath: catalogInfo?.catalogPath,
|
||||||
|
currentApiPath: getServiceApiPath(serviceName),
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attemptKey = `pl24:heal:${vehicleId}`;
|
||||||
|
if (!(await this.redis.setNx(attemptKey, "1", 86_400))) return false;
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[pl24-heal] ${vehicle.vin} (${serviceName}) was decoded on ${catalogInfo?.catalogPath} — re-decoding on P5`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let decoded: Awaited<ReturnType<PL24Service["decodeVin"]>> | null = null;
|
||||||
|
try {
|
||||||
|
await this.redis.del(`pl24:vehicle:${vehicle.vin}`);
|
||||||
|
decoded = await this.pl24Service.decodeVin(vehicle.vin);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[pl24-heal] ${vehicle.vin} re-decode failed: ${(err as Error).message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!decoded?.categories?.length) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[pl24-heal] ${vehicle.vin} re-decode returned no categories — keeping old tree`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.transaction(async (tx) => {
|
||||||
|
await tx.delete(categories).where(eq(categories.vehicleId, vehicleId));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const rows = decoded.categories
|
||||||
|
.filter((c) => {
|
||||||
|
const name = c.nameTr || c.nameEn;
|
||||||
|
if (!name || seen.has(name)) return false;
|
||||||
|
seen.add(name);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((c) => ({
|
||||||
|
vehicleId,
|
||||||
|
catalogVehicleId: null as string | null,
|
||||||
|
name: c.nameTr || c.nameEn,
|
||||||
|
nameOriginal: c.nameEn,
|
||||||
|
parentId: null as string | null,
|
||||||
|
externalId: c.code,
|
||||||
|
linkPath: c.linkPath || null,
|
||||||
|
linkWid: c.linkWid || null,
|
||||||
|
source: "pl24" as const,
|
||||||
|
}));
|
||||||
|
if (rows.length) await tx.insert(categories).values(rows);
|
||||||
|
await tx
|
||||||
|
.update(vehicles)
|
||||||
|
.set({
|
||||||
|
rawData: { ...(rawData ?? {}), catalogInfo: decoded.catalogInfo },
|
||||||
|
fullyFetched: false,
|
||||||
|
fullyFetchedAt: null,
|
||||||
|
})
|
||||||
|
.where(eq(vehicles.id, vehicleId));
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
this.redis.del(`cat:tree:${vehicleId}`),
|
||||||
|
this.redis.del(`prefetch:complete:${vehicleId}`),
|
||||||
|
this.redis.del(`prefetch:noresult:${vehicleId}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[pl24-heal] ${vehicle.vin} migrated to ${decoded.catalogInfo?.catalogPath}: ${existingCategoryCount} stale → ${decoded.categories.length} fresh categories`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private async buildBreadcrumbs(
|
private async buildBreadcrumbs(
|
||||||
ids: string[],
|
ids: string[],
|
||||||
): Promise<Map<string, Array<{ id: string; name: string }>>> {
|
): Promise<Map<string, Array<{ id: string; name: string }>>> {
|
||||||
@@ -1075,9 +1430,13 @@ export class CategoriesService {
|
|||||||
// AND lowercase (groupReferenceTable, groupTable, groupsTable). The old
|
// AND lowercase (groupReferenceTable, groupTable, groupsTable). The old
|
||||||
// case-sensitive includes("Group") missed the lowercase ones (~1157 nodes),
|
// case-sensitive includes("Group") missed the lowercase ones (~1157 nodes),
|
||||||
// so they skipped this group-drill branch and fell to the parts path.
|
// 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 (
|
if (
|
||||||
category.source === "pl24" &&
|
category.source === "pl24" &&
|
||||||
category.linkWid?.toLowerCase().includes("group") &&
|
isPl24GroupNode({ linkPath: category.linkPath, linkWid: category.linkWid }) &&
|
||||||
category.vehicleId
|
category.vehicleId
|
||||||
) {
|
) {
|
||||||
const groupChildren = await this.getChildren(categoryId);
|
const groupChildren = await this.getChildren(categoryId);
|
||||||
@@ -1123,6 +1482,39 @@ export class CategoriesService {
|
|||||||
// No subgroups → leaf; fall through to the parts path below.
|
// No subgroups → leaf; fall through to the parts path below.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PL24 P4 VIN / legacy group node whose link_wid is a positional code
|
||||||
|
// ("1","2","CAP1"…) rather than a *Group* table, so the link_wid branch above
|
||||||
|
// misses it, and which is neither PSA- nor Volvo-special. Its linkPath is a
|
||||||
|
// sub-group / main-group drill action (…group.action — never a parts leaf like
|
||||||
|
// image-board.action). This is the Ford / Nissan / Opel / Hyundai-Kia analog of
|
||||||
|
// the PSA-parent-gate gap: without it these VIN scopes ("mekanik", "kaporta",
|
||||||
|
// "Elektrikli aksam"…) fell through every parent branch to the leaf parts path,
|
||||||
|
// fetched no parts and rendered a silent empty panel — confirmed the dominant
|
||||||
|
// current "0 parça" cluster. Volvo's vin-group.action keeps its own branch above
|
||||||
|
// (it may legitimately bottom out in parts), so exclude it here. Drill on demand:
|
||||||
|
// subgroups → parent; an empty drill is a transient upstream failure → retryable
|
||||||
|
// load error (a group node is never legitimately a parts leaf).
|
||||||
|
if (
|
||||||
|
category.source === "pl24" &&
|
||||||
|
category.linkPath?.includes("group.action") &&
|
||||||
|
!category.linkPath.includes("vin-group.action") &&
|
||||||
|
category.vehicleId
|
||||||
|
) {
|
||||||
|
const p4Children = await this.getChildren(categoryId);
|
||||||
|
const base = {
|
||||||
|
id: category.id,
|
||||||
|
name: category.name,
|
||||||
|
description: category.nameOriginal || null,
|
||||||
|
parentId: category.parentId || null,
|
||||||
|
parts: [],
|
||||||
|
schemaPics: [],
|
||||||
|
hotspots: [],
|
||||||
|
};
|
||||||
|
return p4Children.length > 0
|
||||||
|
? { ...base, children: p4Children }
|
||||||
|
: { ...base, loadError: true };
|
||||||
|
}
|
||||||
|
|
||||||
// EMEX Vehicle.aspx group node (linkWid="emex-group") — a parent, never a
|
// EMEX Vehicle.aspx group node (linkWid="emex-group") — a parent, never a
|
||||||
// parts leaf. Return its children (seeded sub-groups, or Unit leaves drilled
|
// parts leaf. Return its children (seeded sub-groups, or Unit leaves drilled
|
||||||
// on demand by getChildren). Mirrors the pl24 group-node branch above; an
|
// on demand by getChildren). Mirrors the pl24 group-node branch above; an
|
||||||
@@ -1899,15 +2291,8 @@ export class CategoriesService {
|
|||||||
return !!c.linkPath?.startsWith("pcat:"); // unknown → lazy-leaf heuristic
|
return !!c.linkPath?.startsWith("pcat:"); // unknown → lazy-leaf heuristic
|
||||||
})()
|
})()
|
||||||
: (() => {
|
: (() => {
|
||||||
const lp = c.linkPath?.toLowerCase() ?? "";
|
if (!c.linkPath) return dbChildCount === 0;
|
||||||
return (
|
return isPl24LeafNode({ linkPath: c.linkPath, linkWid: c.linkWid });
|
||||||
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)
|
|
||||||
);
|
|
||||||
})();
|
})();
|
||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
|
|||||||
96
apps/api/src/common/guards/api-key.guard.ts
Normal file
96
apps/api/src/common/guards/api-key.guard.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import {
|
||||||
|
type CanActivate,
|
||||||
|
type ExecutionContext,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { type ApiKeyRecord, getApiKeyApi } from "../../auth/auth";
|
||||||
|
import { DATABASE, type Database } from "../../database/database.provider";
|
||||||
|
import { users } from "../../database/schema/core";
|
||||||
|
|
||||||
|
export type VerifiedApiKey = ApiKeyRecord;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public API (/api/v1) kimlik doğrulaması. Cookie tabanlı AuthGuard'dan
|
||||||
|
* tamamen ayrı: anahtar `Authorization: Bearer sase_...` veya `x-api-key`
|
||||||
|
* header'ında gelir, better-auth apiKey plugin'i ile doğrulanır (hash
|
||||||
|
* karşılaştırma + enabled/expiry/anahtar-başına rate-limit). Geçerliyse
|
||||||
|
* anahtarın sahibi kullanıcı yüklenir ve `request.user` + `request.apiKey`
|
||||||
|
* doldurulur — böylece mevcut servisler (decodeVin'in abonelik/marka
|
||||||
|
* yetki kontrolü dahil) hiçbir değişiklik olmadan çalışır.
|
||||||
|
*
|
||||||
|
* Controller'lar bu guard'ı `@Public()` ile birlikte kullanmalı; @Public
|
||||||
|
* global AuthGuard'ı atlatır, kimlik işini bu guard devralır.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ApiKeyGuard implements CanActivate {
|
||||||
|
constructor(@Inject(DATABASE) private readonly db: Database) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const rawKey = this.extractKey(request.headers);
|
||||||
|
if (!rawKey) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
"API anahtarı gerekli. Anahtarınızı 'Authorization: Bearer <anahtar>' header'ında gönderin.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<ReturnType<typeof getApiKeyApi>["verifyApiKey"]>>;
|
||||||
|
try {
|
||||||
|
result = await getApiKeyApi().verifyApiKey({ body: { key: rawKey } });
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException("Geçersiz API anahtarı.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.valid || !result.key) {
|
||||||
|
const code = result.error?.code ?? "INVALID_API_KEY";
|
||||||
|
if (code === "RATE_LIMITED" || code === "USAGE_EXCEEDED" || code === "TOO_MANY_REQUESTS") {
|
||||||
|
throw new HttpException(
|
||||||
|
"İstek limiti aşıldı. Lütfen bekleyip tekrar deneyin.",
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (code === "KEY_EXPIRED") {
|
||||||
|
throw new UnauthorizedException("API anahtarının süresi dolmuş.");
|
||||||
|
}
|
||||||
|
if (code === "KEY_DISABLED") {
|
||||||
|
throw new UnauthorizedException("API anahtarı devre dışı bırakılmış.");
|
||||||
|
}
|
||||||
|
throw new UnauthorizedException("Geçersiz API anahtarı.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = result.key;
|
||||||
|
const [user] = await this.db.select().from(users).where(eq(users.id, key.userId)).limit(1);
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException("API anahtarının sahibi bulunamadı.");
|
||||||
|
}
|
||||||
|
if (user.status !== "active") {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
"Hesabınız askıya alınmış durumda. Destek ile iletişime geçin.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.user = user;
|
||||||
|
request.apiKey = key;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorization: Bearer <key> (tercih edilen) veya x-api-key header'ı. */
|
||||||
|
private extractKey(headers: Record<string, unknown>): string | null {
|
||||||
|
const authHeader = headers.authorization;
|
||||||
|
if (typeof authHeader === "string" && authHeader.length > 0) {
|
||||||
|
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||||
|
const value = (match ? match[1] : authHeader).trim();
|
||||||
|
if (value.length > 0) return value;
|
||||||
|
}
|
||||||
|
const apiKeyHeader = headers["x-api-key"];
|
||||||
|
if (typeof apiKeyHeader === "string" && apiKeyHeader.trim().length > 0) {
|
||||||
|
return apiKeyHeader.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
type CanActivate,
|
type CanActivate,
|
||||||
type ExecutionContext,
|
type ExecutionContext,
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { timingSafeEqual } from "node:crypto";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InternalTokenGuard implements CanActivate {
|
export class InternalTokenGuard implements CanActivate {
|
||||||
|
|||||||
10
apps/api/src/common/telegram.module.ts
Normal file
10
apps/api/src/common/telegram.module.ts
Normal 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 {}
|
||||||
56
apps/api/src/common/telegram.service.ts
Normal file
56
apps/api/src/common/telegram.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,22 @@ export default () => ({
|
|||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
},
|
},
|
||||||
|
p: {
|
||||||
|
// Read-only lookup against the imported P snapshot (db `td`). When
|
||||||
|
// enabled + url set, the OEM detail page resolves a part's OEM code to
|
||||||
|
// P aftermarket equivalents + OE cross-references. Disabled → endpoint
|
||||||
|
// returns { matched: false } and the UI shows an empty state.
|
||||||
|
enabled: process.env.P_DB_ENABLED === "true",
|
||||||
|
url: process.env.P_DB_URL,
|
||||||
|
},
|
||||||
|
supplierPrices: {
|
||||||
|
// Tedarikçi fiyat geçmişi (takip MySQL, Tailscale). Kapalı/boş URL →
|
||||||
|
// part-prices uçları { matched: false } / boş batch döner; UI fiyat
|
||||||
|
// bölümünü hiç render etmez. Tedarikçi kimliği API cevabına asla çıkmaz —
|
||||||
|
// yalnızca stoktaki tekliflerin p50/p95/p99 dağılımı servis edilir.
|
||||||
|
enabled: process.env.SUPPLIER_PRICE_DB_ENABLED === "true",
|
||||||
|
url: process.env.SUPPLIER_PRICE_DB_URL,
|
||||||
|
},
|
||||||
otel: {
|
otel: {
|
||||||
enabled: process.env.OTEL_ENABLED === "true",
|
enabled: process.env.OTEL_ENABLED === "true",
|
||||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
|
date,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
jsonb,
|
jsonb,
|
||||||
numeric,
|
numeric,
|
||||||
pgTable,
|
pgTable,
|
||||||
|
primaryKey,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
@@ -37,6 +39,10 @@ export const users = pgTable(
|
|||||||
// subscription yet (referrer had no active/trial sub at grant time).
|
// subscription yet (referrer had no active/trial sub at grant time).
|
||||||
// Consumed when the user next starts a trial or activates a subscription.
|
// Consumed when the user next starts a trial or activates a subscription.
|
||||||
referralCreditDays: integer("referral_credit_days").default(0).notNull(),
|
referralCreditDays: integer("referral_credit_days").default(0).notNull(),
|
||||||
|
// Stripe Customer backing this user's recurring billing. Set on the first
|
||||||
|
// completed checkout and reused on later checkouts so saved cards and
|
||||||
|
// invoices stay on one customer record.
|
||||||
|
stripeCustomerId: text("stripe_customer_id"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
@@ -110,6 +116,46 @@ export const verifications = pgTable(
|
|||||||
(table) => [index("verifications_identifier_idx").on(table.identifier)],
|
(table) => [index("verifications_identifier_idx").on(table.identifier)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Better Auth: API Keys (public API) ──────────────
|
||||||
|
// Backing table for the better-auth `apiKey` plugin (model "apikey", plural).
|
||||||
|
// Column set mirrors the plugin's schema exactly — the drizzle adapter matches
|
||||||
|
// by property name, so renaming a property here breaks key verification.
|
||||||
|
// `key` holds the SHA256 hash of the secret; the plaintext is shown once at
|
||||||
|
// creation. `metadata` is a JSON string (enableMetadata) carrying
|
||||||
|
// per-key overrides such as dailyDecodeLimit.
|
||||||
|
export const apikeys = pgTable(
|
||||||
|
"apikeys",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name"),
|
||||||
|
start: text("start"),
|
||||||
|
prefix: text("prefix"),
|
||||||
|
key: text("key").notNull(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
refillInterval: integer("refill_interval"),
|
||||||
|
refillAmount: integer("refill_amount"),
|
||||||
|
lastRefillAt: timestamp("last_refill_at", { withTimezone: true }),
|
||||||
|
enabled: boolean("enabled").default(true).notNull(),
|
||||||
|
rateLimitEnabled: boolean("rate_limit_enabled").default(true).notNull(),
|
||||||
|
rateLimitTimeWindow: integer("rate_limit_time_window"),
|
||||||
|
rateLimitMax: integer("rate_limit_max"),
|
||||||
|
requestCount: integer("request_count").default(0).notNull(),
|
||||||
|
remaining: integer("remaining"),
|
||||||
|
lastRequest: timestamp("last_request", { withTimezone: true }),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
permissions: text("permissions"),
|
||||||
|
metadata: text("metadata"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("apikeys_key_idx").on(table.key),
|
||||||
|
index("apikeys_user_id_idx").on(table.userId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Brands ─────────────────────────────────────────
|
// ─── Brands ─────────────────────────────────────────
|
||||||
export const brands = pgTable(
|
export const brands = pgTable(
|
||||||
"brands",
|
"brands",
|
||||||
@@ -153,12 +199,23 @@ export const userSubscriptions = pgTable(
|
|||||||
startDate: timestamp("start_date", { withTimezone: true }),
|
startDate: timestamp("start_date", { withTimezone: true }),
|
||||||
endDate: timestamp("end_date", { withTimezone: true }),
|
endDate: timestamp("end_date", { withTimezone: true }),
|
||||||
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
|
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
|
||||||
|
// Stripe Subscription id for recurring (mode:"subscription") billing.
|
||||||
|
// NULL for trials and legacy one-time purchases. Renewal invoices resolve
|
||||||
|
// our row through this.
|
||||||
|
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||||
|
// Dunning state — set on the first failed renewal charge, cleared when the
|
||||||
|
// invoice recovers (invoice.paid) or the sub is finally cancelled. The URL
|
||||||
|
// is Stripe's hosted invoice page: the ONLY self-serve surface where the
|
||||||
|
// user can pay the open invoice / enter a new card / pass 3DS.
|
||||||
|
dunningSince: timestamp("dunning_since", { withTimezone: true }),
|
||||||
|
dunningInvoiceUrl: text("dunning_invoice_url"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("user_subscriptions_user_id_idx").on(table.userId),
|
index("user_subscriptions_user_id_idx").on(table.userId),
|
||||||
index("user_subscriptions_status_idx").on(table.status),
|
index("user_subscriptions_status_idx").on(table.status),
|
||||||
|
index("user_subscriptions_stripe_sub_idx").on(table.stripeSubscriptionId),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -184,7 +241,9 @@ export const userBrands = pgTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Bank Accounts (EFT/Havale destination accounts, one active at a time) ────
|
// ─── Bank Accounts ───────────────────────────────────
|
||||||
|
// @deprecated EFT/Havale was retired (Stripe is the sole payment method). Table
|
||||||
|
// kept only to preserve historical FK integrity; no rows are created anymore.
|
||||||
export const bankAccounts = pgTable(
|
export const bankAccounts = pgTable(
|
||||||
"bank_accounts",
|
"bank_accounts",
|
||||||
{
|
{
|
||||||
@@ -225,7 +284,12 @@ export const payments = pgTable(
|
|||||||
iyzicoPaymentId: text("iyzico_payment_id"),
|
iyzicoPaymentId: text("iyzico_payment_id"),
|
||||||
stripeSessionId: text("stripe_session_id"),
|
stripeSessionId: text("stripe_session_id"),
|
||||||
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
||||||
|
// Stripe Invoice behind a recurring charge (renewals). Dedupe key against
|
||||||
|
// invoice.paid webhook retries.
|
||||||
|
stripeInvoiceId: text("stripe_invoice_id"),
|
||||||
|
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
|
||||||
bankAccountId: uuid("bank_account_id").references(() => bankAccounts.id),
|
bankAccountId: uuid("bank_account_id").references(() => bankAccounts.id),
|
||||||
|
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
|
||||||
eftReceiptUrl: text("eft_receipt_url"),
|
eftReceiptUrl: text("eft_receipt_url"),
|
||||||
adminNote: text("admin_note"),
|
adminNote: text("admin_note"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
@@ -235,6 +299,7 @@ export const payments = pgTable(
|
|||||||
index("payments_user_id_idx").on(table.userId),
|
index("payments_user_id_idx").on(table.userId),
|
||||||
index("payments_status_idx").on(table.status),
|
index("payments_status_idx").on(table.status),
|
||||||
index("payments_stripe_session_id_idx").on(table.stripeSessionId),
|
index("payments_stripe_session_id_idx").on(table.stripeSessionId),
|
||||||
|
index("payments_stripe_invoice_id_idx").on(table.stripeInvoiceId),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -318,6 +383,62 @@ export const catalogVehicles = pgTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Vinpin decodes (ePER decode-oracle cache — one row per VIN) ──
|
||||||
|
// Feature-flagged (VINPIN_ENABLED). Populated by the vinpin-decode BullMQ job
|
||||||
|
// which drives the Vinpin ePER web catalog for Fiat VINs the normal decode chain
|
||||||
|
// (PL24/pcat/emex) can't identify. On success the decoded model is matched to an
|
||||||
|
// EXISTING PL24 catalog_vehicle (catalog_vehicle_id) and parts are served from
|
||||||
|
// there — Vinpin is ONLY a decode oracle, never a parts source.
|
||||||
|
export const vinpinDecodes = pgTable("vinpin_decodes", {
|
||||||
|
vin: text("vin").primaryKey(),
|
||||||
|
// 'pending' | 'decoded' | 'not_found' | 'failed'
|
||||||
|
status: text("status").notNull().default("pending"),
|
||||||
|
brandName: text("brand_name"),
|
||||||
|
model: text("model"),
|
||||||
|
sincom: text("sincom"),
|
||||||
|
trim: text("trim"),
|
||||||
|
modelYear: text("model_year"),
|
||||||
|
engine: text("engine"),
|
||||||
|
catalogVehicleId: uuid("catalog_vehicle_id").references(() => catalogVehicles.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
raw: jsonb("raw"),
|
||||||
|
attempts: integer("attempts").notNull().default(0),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
decodedAt: timestamp("decoded_at", { withTimezone: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── RPartStore decodes (Renault/Dacia decode-oracle cache — one row per VIN) ──
|
||||||
|
// Feature-flagged (RPARTSTORE_ENABLED). Populated by the rpartstore-decode BullMQ
|
||||||
|
// job which asks the RPartStore BFF (rpartstore.renault.com, STOMP/WebSocket) for
|
||||||
|
// Renault/Dacia VINs the normal chain (pcat/PL24/emex) can't identify. On success
|
||||||
|
// the decoded model is matched to an EXISTING PL24 catalog_vehicle and parts are
|
||||||
|
// served from there — RPartStore is ONLY a decode oracle. Hard daily cap on
|
||||||
|
// searches (RPARTSTORE_DAILY_CAP); over-cap VINs are 'capped' and retried after 24 h.
|
||||||
|
export const rpartstoreDecodes = pgTable("rpartstore_decodes", {
|
||||||
|
vin: text("vin").primaryKey(),
|
||||||
|
// 'pending' | 'decoded' | 'not_found' | 'capped' | 'failed'
|
||||||
|
status: text("status").notNull().default("pending"),
|
||||||
|
brandName: text("brand_name"),
|
||||||
|
model: text("model"),
|
||||||
|
modelCode: text("model_code"),
|
||||||
|
familyCode: text("family_code"),
|
||||||
|
modelYear: text("model_year"),
|
||||||
|
engine: text("engine"),
|
||||||
|
gearbox: text("gearbox"),
|
||||||
|
energyType: text("energy_type"),
|
||||||
|
manufacturingDate: text("manufacturing_date"),
|
||||||
|
catalogVehicleId: uuid("catalog_vehicle_id").references(() => catalogVehicles.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
raw: jsonb("raw"),
|
||||||
|
attempts: integer("attempts").notNull().default(0),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
decodedAt: timestamp("decoded_at", { withTimezone: true }),
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Vehicles (shared config — one record per VIN) ──
|
// ─── Vehicles (shared config — one record per VIN) ──
|
||||||
export const vehicles = pgTable(
|
export const vehicles = pgTable(
|
||||||
"vehicles",
|
"vehicles",
|
||||||
@@ -334,10 +455,20 @@ export const vehicles = pgTable(
|
|||||||
market: varchar("market", { length: 100 }),
|
market: varchar("market", { length: 100 }),
|
||||||
rawData: jsonb("raw_data"),
|
rawData: jsonb("raw_data"),
|
||||||
source: varchar("source", { length: 20 }).default("pl24").notNull(),
|
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(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_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) ─
|
// ─── User Vehicles (junction — user ↔ shared vehicle) ─
|
||||||
@@ -386,12 +517,27 @@ export const categories = pgTable(
|
|||||||
hasParts: boolean("has_parts"),
|
hasParts: boolean("has_parts"),
|
||||||
unavailable: boolean("unavailable").default(false).notNull(),
|
unavailable: boolean("unavailable").default(false).notNull(),
|
||||||
source: varchar("source", { length: 20 }).default("pl24").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(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("categories_vehicle_id_idx").on(table.vehicleId),
|
index("categories_vehicle_id_idx").on(table.vehicleId),
|
||||||
index("categories_catalog_vehicle_id_idx").on(table.catalogVehicleId),
|
index("categories_catalog_vehicle_id_idx").on(table.catalogVehicleId),
|
||||||
index("categories_parent_id_idx").on(table.parentId),
|
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(
|
uniqueIndex("categories_vehicle_name_source_idx").on(
|
||||||
table.vehicleId,
|
table.vehicleId,
|
||||||
table.catalogVehicleId,
|
table.catalogVehicleId,
|
||||||
@@ -613,3 +759,339 @@ export const emexCategoryTranslations = pgTable(
|
|||||||
},
|
},
|
||||||
(table) => [uniqueIndex("emex_translations_original_name_idx").on(table.originalName)],
|
(table) => [uniqueIndex("emex_translations_original_name_idx").on(table.originalName)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Proxy Logs ──────────────────────────────────────
|
||||||
|
// One row per proxied upstream attempt (pcat call/capture/validate, emex http).
|
||||||
|
// Written fire-and-forget by ProxyTelemetryService so the Süper Panel can grade
|
||||||
|
// proxy providers (success/ban/latency per service) and track banned exit IPs.
|
||||||
|
// `banned` = HTTP 403/429 from the upstream (IP-level block signal); `session_key`
|
||||||
|
// identifies a sticky Floxy session or a DataImpulse port so bans are attributable
|
||||||
|
// even when the exact exit IP is unknown (rotating call legs).
|
||||||
|
export const proxyLogs = pgTable(
|
||||||
|
"proxy_logs",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
// Which integration leg made the request: pcat_call | pcat_capture |
|
||||||
|
// pcat_validate | emex_http
|
||||||
|
service: varchar("service", { length: 30 }).notNull(),
|
||||||
|
// floxy | dataimpulse | none (direct)
|
||||||
|
provider: varchar("provider", { length: 20 }).notNull(),
|
||||||
|
// Sticky Floxy session id ("s-ab12cd34") or DataImpulse port ("di:10042");
|
||||||
|
// null on per-request rotating legs (exit IP changes every call).
|
||||||
|
sessionKey: varchar("session_key", { length: 60 }),
|
||||||
|
// Resolved residential exit IP (sticky sessions only — one cheap ipify probe
|
||||||
|
// per session). Null when rotation makes the IP unknowable.
|
||||||
|
exitIp: varchar("exit_ip", { length: 45 }),
|
||||||
|
targetHost: varchar("target_host", { length: 120 }),
|
||||||
|
statusCode: integer("status_code"),
|
||||||
|
// Transport failure class when no HTTP answer arrived:
|
||||||
|
// timeout | connect | reset | dns | aborted | transport
|
||||||
|
errorKind: varchar("error_kind", { length: 30 }),
|
||||||
|
success: boolean("success").default(false).notNull(),
|
||||||
|
banned: boolean("banned").default(false).notNull(),
|
||||||
|
durationMs: integer("duration_ms"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("proxy_logs_created_at_idx").on(table.createdAt),
|
||||||
|
index("proxy_logs_banned_created_at_idx").on(table.banned, table.createdAt),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── OEM Votes (community compatibility verdicts) ─────
|
||||||
|
// One row per (user, oemCode): a parts seller's uyumlu/uyumsuz verdict on an
|
||||||
|
// OEM code, cast from the catalog part rows. Votes pool globally per code —
|
||||||
|
// the part/vehicle/category columns are analytics breadcrumbs only (mirrors
|
||||||
|
// oem_code_copies: catalog routes pass catalog_vehicles ids here, so no FK).
|
||||||
|
// Re-voting updates `vote` in place; points are only ever granted on the
|
||||||
|
// first insert (see oem_vote_points).
|
||||||
|
export const oemVotes = pgTable(
|
||||||
|
"oem_votes",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
oemCode: varchar("oem_code", { length: 100 }).notNull(),
|
||||||
|
// compatible | incompatible
|
||||||
|
vote: varchar("vote", { length: 12 }).notNull(),
|
||||||
|
partId: uuid("part_id"),
|
||||||
|
vehicleId: uuid("vehicle_id"),
|
||||||
|
categoryId: uuid("category_id"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("oem_votes_user_oem_idx").on(table.userId, table.oemCode),
|
||||||
|
index("oem_votes_oem_code_idx").on(table.oemCode),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── OEM Vote Points (gamification ledger) ────────────
|
||||||
|
// Append-only: exactly one row per oem_votes row (vote_id unique), written in
|
||||||
|
// the same transaction as the vote insert. `points` = 1 for voting +2 when
|
||||||
|
// the vote agreed with the strict majority at cast time (first voter on a
|
||||||
|
// code always agrees with themselves → 3). Awards are never retro-adjusted
|
||||||
|
// when the majority later flips. Leaderboard = SUM(points) per user.
|
||||||
|
export const oemVotePoints = pgTable(
|
||||||
|
"oem_vote_points",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
voteId: uuid("vote_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => oemVotes.id, { onDelete: "cascade" }),
|
||||||
|
oemCode: varchar("oem_code", { length: 100 }).notNull(),
|
||||||
|
points: integer("points").notNull(),
|
||||||
|
correct: boolean("correct").default(false).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("oem_vote_points_vote_id_idx").on(table.voteId),
|
||||||
|
index("oem_vote_points_user_id_idx").on(table.userId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── OEM Suggestions (community cross-references) ─────
|
||||||
|
// Crowd-sourced equivalents for an OEM code: "this code's compatible part is
|
||||||
|
// <brand> <suggestedCode>", collected on the OEM detail page — primarily for
|
||||||
|
// codes the P snapshot can't match. suggestedCodeNorm (upper, alphanumerics
|
||||||
|
// only) powers grouping and the per-user dedup constraint; display keeps the
|
||||||
|
// submitted casing. `status` is a moderation hook (active|hidden) — no rows
|
||||||
|
// are hidden automatically today.
|
||||||
|
export const oemSuggestions = pgTable(
|
||||||
|
"oem_suggestions",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
oemCode: varchar("oem_code", { length: 100 }).notNull(),
|
||||||
|
brand: varchar("brand", { length: 80 }).notNull(),
|
||||||
|
suggestedCode: varchar("suggested_code", { length: 100 }).notNull(),
|
||||||
|
suggestedCodeNorm: varchar("suggested_code_norm", { length: 100 }).notNull(),
|
||||||
|
status: varchar("status", { length: 16 }).default("active").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("oem_suggestions_user_oem_code_idx").on(
|
||||||
|
table.userId,
|
||||||
|
table.oemCode,
|
||||||
|
table.suggestedCodeNorm,
|
||||||
|
),
|
||||||
|
index("oem_suggestions_oem_code_idx").on(table.oemCode),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── OEM Expert Rewards (monthly leaderboard prizes) ──
|
||||||
|
// One row per (TR-month, rank): the expert-rewards cron (1st of month 00:00
|
||||||
|
// Europe/Istanbul) closes the finished season, e-mails the top 3 voters to
|
||||||
|
// admin@sase.tr (name/e-mail/points + prize 30/15/7 days — EXPERT_REWARD_
|
||||||
|
// LADDER) and records them here. Subscription days are NOT granted
|
||||||
|
// automatically — the admin applies them manually from the mail. The unique
|
||||||
|
// index doubles as the run-once guard: a second run for the same period
|
||||||
|
// inserts nothing and sends no second mail.
|
||||||
|
export const oemExpertRewards = pgTable(
|
||||||
|
"oem_expert_rewards",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
// TR-ayının başlangıcı (UTC timestamptz) — ödüllendirilen sezon.
|
||||||
|
periodStart: timestamp("period_start", { withTimezone: true }).notNull(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
rank: integer("rank").notNull(),
|
||||||
|
points: integer("points").notNull(),
|
||||||
|
rewardDays: integer("reward_days").notNull(),
|
||||||
|
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("oem_expert_rewards_period_rank_idx").on(table.periodStart, table.rank),
|
||||||
|
index("oem_expert_rewards_user_id_idx").on(table.userId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Part Price History (tedarikçi fiyat verisi) ──────
|
||||||
|
// Parça bazlı piyasa fiyat geçmişi; kaynak takip MySQL'i (Tailscale). Kimlik
|
||||||
|
// (kod, marka): kısa sayısal kodlar markalar arası çakışır (FEBI 27155 ≠
|
||||||
|
// GROS 27155) — marka olmadan percentile anlamsızlaşır; markasız ('' brand)
|
||||||
|
// izleme yalnızca uzun/benzersiz kodlar içindir (OEM detayın ana kodu).
|
||||||
|
// tracks = izlenen parçalar: ilk seriyi API lazy-backfill yazar, günlük cron
|
||||||
|
// yalnızca buradakilere bugünün satırını ekler. daily = (kod, marka, kaynak,
|
||||||
|
// gün) başına stoktaki tekliflerin p50/p95/p99 + teklif sayısı; sıfır-teklif
|
||||||
|
// günleri null fiyat + offerCount 0 ile saklanır. source şimdilik hep
|
||||||
|
// 'supplier'; perakende geldiğinde aynı tabloya 'retail' girer. Tedarikçi
|
||||||
|
// kimliği bilinçli olarak hiçbir yerde tutulmaz.
|
||||||
|
export const partPriceTracks = pgTable(
|
||||||
|
"part_price_tracks",
|
||||||
|
{
|
||||||
|
codeNorm: varchar("code_norm", { length: 64 }).notNull(),
|
||||||
|
brandNorm: varchar("brand_norm", { length: 64 }).default("").notNull(),
|
||||||
|
firstSeenAt: timestamp("first_seen_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
backfilledAt: timestamp("backfilled_at", { withTimezone: true }),
|
||||||
|
lastRefreshedAt: timestamp("last_refreshed_at", { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(table) => [primaryKey({ columns: [table.codeNorm, table.brandNorm] })],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const partPriceDaily = pgTable(
|
||||||
|
"part_price_daily",
|
||||||
|
{
|
||||||
|
codeNorm: varchar("code_norm", { length: 64 }).notNull(),
|
||||||
|
brandNorm: varchar("brand_norm", { length: 64 }).default("").notNull(),
|
||||||
|
source: varchar("source", { length: 16 }).default("supplier").notNull(),
|
||||||
|
date: date("date").notNull(),
|
||||||
|
p50: numeric("p50", { precision: 15, scale: 4 }),
|
||||||
|
p95: numeric("p95", { precision: 15, scale: 4 }),
|
||||||
|
p99: numeric("p99", { precision: 15, scale: 4 }),
|
||||||
|
offerCount: integer("offer_count").default(0).notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
primaryKey({ columns: [table.codeNorm, table.brandNorm, table.source, table.date] }),
|
||||||
|
index("part_price_daily_code_date_idx").on(table.codeNorm, table.brandNorm, table.date),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Discount Codes (Stripe-backed checkout promotions) ───────────────────
|
||||||
|
// Admin-generated promo codes. Each row MIRRORS a Stripe Coupon + Promotion
|
||||||
|
// Code pair: Stripe is the source of truth for redemption (it enforces the cap
|
||||||
|
// + expiry at checkout and owns the canonical times_redeemed), while this row
|
||||||
|
// lets the admin panel list/track codes and lets us preview/validate a code at
|
||||||
|
// checkout without a per-render Stripe call. Amounts are in kuruş (TRY smallest
|
||||||
|
// unit), matching plans.price* / payments.amount.
|
||||||
|
export const discountCodes = pgTable(
|
||||||
|
"discount_codes",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
// Customer-facing code, stored UPPERCASE; matched case-insensitively.
|
||||||
|
code: varchar("code", { length: 50 }).notNull(),
|
||||||
|
// Stripe linkage — both set together at creation. Checkout attaches the
|
||||||
|
// promotion code id; deactivation archives it on Stripe too.
|
||||||
|
stripeCouponId: text("stripe_coupon_id"),
|
||||||
|
stripePromotionCodeId: text("stripe_promotion_code_id"),
|
||||||
|
// 'percentage' (percentOff 1..100) | 'fixed' (amountOff in kuruş, TRY).
|
||||||
|
discountType: varchar("discount_type", { length: 12 }).notNull(),
|
||||||
|
percentOff: integer("percent_off"),
|
||||||
|
amountOff: integer("amount_off"),
|
||||||
|
// How long the discount rides a subscription, mirroring Stripe Coupon.duration:
|
||||||
|
// 'once' (first invoice only), 'repeating' (durationInMonths invoices),
|
||||||
|
// 'forever' (every renewal).
|
||||||
|
duration: varchar("duration", { length: 12 }).default("once").notNull(),
|
||||||
|
durationInMonths: integer("duration_in_months"),
|
||||||
|
// Total redemption cap across all users (null = unlimited). Stripe enforces.
|
||||||
|
maxRedemptions: integer("max_redemptions"),
|
||||||
|
// Mirror of Stripe's times_redeemed; bumped from the checkout webhook and
|
||||||
|
// reconcilable from Stripe.
|
||||||
|
timesRedeemed: integer("times_redeemed").default(0).notNull(),
|
||||||
|
// Code stops working after this instant (null = no expiry). Stripe enforces.
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||||
|
// Admin soft-toggle. Deactivating also archives the Stripe promotion code.
|
||||||
|
isActive: boolean("is_active").default(true).notNull(),
|
||||||
|
// Free-text admin note (campaign name, who it's for…).
|
||||||
|
note: text("note"),
|
||||||
|
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("discount_codes_code_idx").on(table.code),
|
||||||
|
index("discount_codes_active_idx").on(table.isActive),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Canonical Categories (unified cross-source taxonomy) ──────────────────
|
||||||
|
// Single manufacturer-independent parts taxonomy that every source's OEM
|
||||||
|
// microfiche tree maps INTO. The raw per-source trees (`categories.parent_id`
|
||||||
|
// chains, pl24/emex/pcat mirrors) stay untouched; this is an orthogonal spine
|
||||||
|
// so VW, Opel, Ford etc. can be browsed/searched under one set of headings.
|
||||||
|
// Seeded from the Turkish retail taxonomy (aloparca 17 tops) extended with
|
||||||
|
// OEM-only tops the retail sites don't sell (interior trim/koltuk, body).
|
||||||
|
// Two levels: parentId NULL = top bucket, else a sub-category under a top.
|
||||||
|
export const canonicalCategories = pgTable(
|
||||||
|
"canonical_categories",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
parentId: uuid("parent_id"),
|
||||||
|
slug: varchar("slug", { length: 100 }).notNull(),
|
||||||
|
name: varchar("name", { length: 200 }).notNull(),
|
||||||
|
sortOrder: integer("sort_order").default(0).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [uniqueIndex("canonical_categories_slug_idx").on(table.slug)],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Category → Canonical map (NAME-level, not row-level) ──────────────────
|
||||||
|
// Mapping key is (source, normalized_name), NOT category.id — because the same
|
||||||
|
// category name repeats across thousands of vehicles (704k rows collapse to
|
||||||
|
// ~111k distinct names), so mapping at the name level is ~6× cheaper and a
|
||||||
|
// single human decision fans out to every matching row. `method` records how
|
||||||
|
// the row was produced (keyword | fuzzy | embedding | manual) and `confidence`
|
||||||
|
// gates auto-apply vs review queue; `isManual` protects human-approved rows
|
||||||
|
// from being overwritten by re-runs (same pattern as emex_category_translations).
|
||||||
|
export const categoryCanonicalMap = pgTable(
|
||||||
|
"category_canonical_map",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
source: varchar("source", { length: 20 }).notNull(),
|
||||||
|
normalizedName: varchar("normalized_name", { length: 500 }).notNull(),
|
||||||
|
canonicalCategoryId: uuid("canonical_category_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => canonicalCategories.id, { onDelete: "cascade" }),
|
||||||
|
// Second level materialized alongside the top bucket (name→bucket→sub is
|
||||||
|
// deterministic). Nullable: no sub-leaf matched → served at top level.
|
||||||
|
canonicalSubcategoryId: uuid("canonical_subcategory_id"),
|
||||||
|
method: varchar("method", { length: 20 }).default("keyword").notNull(),
|
||||||
|
confidence: numeric("confidence", { precision: 4, scale: 3 }),
|
||||||
|
isManual: boolean("is_manual").default(false).notNull(),
|
||||||
|
ambiguous: boolean("ambiguous").default(false).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("category_canonical_map_source_name_idx").on(table.source, table.normalizedName),
|
||||||
|
index("category_canonical_map_canonical_idx").on(table.canonicalCategoryId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Canonical structure template (per source+brand) ──────────────────────
|
||||||
|
// The canonical STRUCTURE (which buckets/sub-leaves a platform has) is shared
|
||||||
|
// across vehicles of the same brand — only the parts differ. This table records,
|
||||||
|
// per (source, brand), each (bucket, sub-leaf) seen across already-drilled
|
||||||
|
// vehicles with a vehicle_count (support). On decode the serving overlays the
|
||||||
|
// brand template so the user sees the FULL canonical structure instantly, before
|
||||||
|
// this vehicle's own tree is deep-drilled by the prefetch backfill. Rebuilt at
|
||||||
|
// the end of each global canonical-backfill run (derived, so full-refresh).
|
||||||
|
// Entry-level companion of canonicalStructureTemplate: the browsable source-
|
||||||
|
// category NAMES every vehicle of (source, brand) shares under a bucket/sub.
|
||||||
|
// A freshly-decoded vehicle renders these immediately (placeholder until its
|
||||||
|
// own row exists) — only parts/schemas are vehicle-specific.
|
||||||
|
export const canonicalTemplateEntries = pgTable(
|
||||||
|
"canonical_template_entries",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
source: varchar("source", { length: 20 }).notNull(),
|
||||||
|
brand: varchar("brand", { length: 120 }).notNull(),
|
||||||
|
canonicalCategoryId: uuid("canonical_category_id").notNull(),
|
||||||
|
canonicalSubcategoryId: uuid("canonical_subcategory_id"),
|
||||||
|
name: varchar("name", { length: 500 }).notNull(),
|
||||||
|
vehicleCount: integer("vehicle_count").default(0).notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [index("canonical_template_entries_sb_idx").on(table.source, table.brand)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const canonicalStructureTemplate = pgTable(
|
||||||
|
"canonical_structure_template",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
source: varchar("source", { length: 20 }).notNull(),
|
||||||
|
brand: varchar("brand", { length: 120 }).notNull(),
|
||||||
|
canonicalCategoryId: uuid("canonical_category_id").notNull(),
|
||||||
|
canonicalSubcategoryId: uuid("canonical_subcategory_id"),
|
||||||
|
vehicleCount: integer("vehicle_count").default(0).notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [index("canonical_structure_template_source_brand_idx").on(table.source, table.brand)],
|
||||||
|
);
|
||||||
|
|||||||
58
apps/api/src/discount-codes/discount-codes.controller.ts
Normal file
58
apps/api/src/discount-codes/discount-codes.controller.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { BadRequestException, Body, Controller, Get, Param, Patch, Post } from "@nestjs/common";
|
||||||
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
|
import { Roles } from "../common/decorators/roles.decorator";
|
||||||
|
import { type CreateDiscountInput, DiscountCodesService } from "./discount-codes.service";
|
||||||
|
|
||||||
|
interface AuthedUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller("discount-codes")
|
||||||
|
export class DiscountCodesController {
|
||||||
|
constructor(private discountCodes: DiscountCodesService) {}
|
||||||
|
|
||||||
|
// ── Any logged-in user: validate + price a code on the checkout page ──
|
||||||
|
// No @Roles → only the global AuthGuard applies (must be signed in).
|
||||||
|
@Post("validate")
|
||||||
|
async validate(
|
||||||
|
@Body() body: { code: string; planKey: string; billingPeriod: "monthly" | "yearly" },
|
||||||
|
) {
|
||||||
|
if (!body?.code || !body?.planKey || !body?.billingPeriod) {
|
||||||
|
throw new BadRequestException("code, planKey ve billingPeriod zorunlu");
|
||||||
|
}
|
||||||
|
return this.discountCodes.preview(body.code, body.planKey, body.billingPeriod);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin-only management ──
|
||||||
|
@Roles("admin")
|
||||||
|
@Get()
|
||||||
|
async list() {
|
||||||
|
return this.discountCodes.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles("admin")
|
||||||
|
@Get(":id")
|
||||||
|
async getOne(@Param("id") id: string) {
|
||||||
|
return this.discountCodes.getOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles("admin")
|
||||||
|
@Post()
|
||||||
|
async create(@CurrentUser() user: AuthedUser, @Body() body: CreateDiscountInput) {
|
||||||
|
return this.discountCodes.create(body, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles("admin")
|
||||||
|
@Patch(":id/deactivate")
|
||||||
|
async deactivate(@Param("id") id: string) {
|
||||||
|
return this.discountCodes.deactivate(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles("admin")
|
||||||
|
@Post("sync")
|
||||||
|
async sync() {
|
||||||
|
return this.discountCodes.syncRedemptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/discount-codes/discount-codes.module.ts
Normal file
14
apps/api/src/discount-codes/discount-codes.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { StripeModule } from "../payments/stripe/stripe.module";
|
||||||
|
import { DiscountCodesController } from "./discount-codes.controller";
|
||||||
|
import { DiscountCodesService } from "./discount-codes.service";
|
||||||
|
|
||||||
|
// One-way dependency on StripeModule (StripeService creates the Stripe
|
||||||
|
// Coupon/Promotion objects + owns checkout-time preview). StripeModule does NOT
|
||||||
|
// import us back, so there is no cycle.
|
||||||
|
@Module({
|
||||||
|
imports: [StripeModule],
|
||||||
|
controllers: [DiscountCodesController],
|
||||||
|
providers: [DiscountCodesService],
|
||||||
|
})
|
||||||
|
export class DiscountCodesModule {}
|
||||||
237
apps/api/src/discount-codes/discount-codes.service.ts
Normal file
237
apps/api/src/discount-codes/discount-codes.service.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
|
import { discountCodes } from "../database/schema/core";
|
||||||
|
import { StripeService } from "../payments/stripe/stripe.service";
|
||||||
|
|
||||||
|
export type DiscountType = "percentage" | "fixed";
|
||||||
|
export type DiscountDuration = "once" | "repeating" | "forever";
|
||||||
|
|
||||||
|
export interface CreateDiscountInput {
|
||||||
|
/** Optional custom code; auto-generated when omitted. */
|
||||||
|
code?: string;
|
||||||
|
discountType: DiscountType;
|
||||||
|
/** 1..100, required for `percentage`. */
|
||||||
|
percentOff?: number;
|
||||||
|
/** kuruş (TRY smallest unit), required for `fixed`. */
|
||||||
|
amountOff?: number;
|
||||||
|
duration?: DiscountDuration;
|
||||||
|
/** required when duration === "repeating" */
|
||||||
|
durationInMonths?: number;
|
||||||
|
/** total redemption cap (null/omitted = unlimited) */
|
||||||
|
maxRedemptions?: number;
|
||||||
|
/** ISO date string (null/omitted = no expiry) */
|
||||||
|
expiresAt?: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscountRow = typeof discountCodes.$inferSelect;
|
||||||
|
|
||||||
|
// 3..50 chars, starts alphanumeric, then alphanumeric / dash / underscore.
|
||||||
|
const CODE_RE = /^[A-Z0-9][A-Z0-9_-]{2,49}$/;
|
||||||
|
// Crockford-ish alphabet — no 0/O/1/I confusion in shared codes.
|
||||||
|
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DiscountCodesService {
|
||||||
|
private readonly logger = new Logger(DiscountCodesService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DATABASE) private db: Database,
|
||||||
|
private stripe: StripeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private generateCode(): string {
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
s += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)];
|
||||||
|
}
|
||||||
|
return `SASE${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeStatus(row: DiscountRow): "active" | "inactive" | "expired" | "exhausted" {
|
||||||
|
if (!row.isActive) return "inactive";
|
||||||
|
if (row.expiresAt && row.expiresAt.getTime() < Date.now()) return "expired";
|
||||||
|
if (row.maxRedemptions != null && row.timesRedeemed >= row.maxRedemptions) return "exhausted";
|
||||||
|
return "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateDiscountInput, createdBy: string | null) {
|
||||||
|
const discountType = input.discountType;
|
||||||
|
if (discountType !== "percentage" && discountType !== "fixed") {
|
||||||
|
throw new BadRequestException("discountType 'percentage' veya 'fixed' olmalı");
|
||||||
|
}
|
||||||
|
|
||||||
|
let percentOff: number | null = null;
|
||||||
|
let amountOff: number | null = null;
|
||||||
|
if (discountType === "percentage") {
|
||||||
|
percentOff = Number(input.percentOff);
|
||||||
|
if (!Number.isInteger(percentOff) || percentOff < 1 || percentOff > 100) {
|
||||||
|
throw new BadRequestException("Yüzde indirim 1-100 arası tam sayı olmalı");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
amountOff = Number(input.amountOff);
|
||||||
|
if (!Number.isInteger(amountOff) || amountOff < 1) {
|
||||||
|
throw new BadRequestException("Tutar indirimi (kuruş) 1'den büyük tam sayı olmalı");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration: DiscountDuration = input.duration ?? "once";
|
||||||
|
if (duration !== "once" && duration !== "repeating" && duration !== "forever") {
|
||||||
|
throw new BadRequestException("Geçersiz süre türü");
|
||||||
|
}
|
||||||
|
let durationInMonths: number | null = null;
|
||||||
|
if (duration === "repeating") {
|
||||||
|
durationInMonths = Number(input.durationInMonths);
|
||||||
|
if (!Number.isInteger(durationInMonths) || durationInMonths < 1 || durationInMonths > 36) {
|
||||||
|
throw new BadRequestException("Tekrar eden indirimde ay sayısı 1-36 arası olmalı");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxRedemptions: number | null = null;
|
||||||
|
if (
|
||||||
|
input.maxRedemptions !== undefined &&
|
||||||
|
input.maxRedemptions !== null &&
|
||||||
|
String(input.maxRedemptions) !== ""
|
||||||
|
) {
|
||||||
|
maxRedemptions = Number(input.maxRedemptions);
|
||||||
|
if (!Number.isInteger(maxRedemptions) || maxRedemptions < 1) {
|
||||||
|
throw new BadRequestException("Kullanım limiti 1'den büyük tam sayı olmalı");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let expiresAt: Date | null = null;
|
||||||
|
if (input.expiresAt) {
|
||||||
|
const d = new Date(input.expiresAt);
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
throw new BadRequestException("Geçersiz son kullanma tarihi");
|
||||||
|
}
|
||||||
|
if (d.getTime() < Date.now()) {
|
||||||
|
throw new BadRequestException("Son kullanma tarihi gelecekte olmalı");
|
||||||
|
}
|
||||||
|
expiresAt = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = (input.code ?? "").trim().toUpperCase();
|
||||||
|
if (code) {
|
||||||
|
if (!CODE_RE.test(code)) {
|
||||||
|
throw new BadRequestException("Kod 3-50 karakter olmalı; harf, rakam, - ve _ içerebilir");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
code = this.generateCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local uniqueness check BEFORE creating Stripe objects, so a duplicate code
|
||||||
|
// doesn't leave an orphan Stripe coupon behind a failing row insert.
|
||||||
|
const [existing] = await this.db
|
||||||
|
.select({ id: discountCodes.id })
|
||||||
|
.from(discountCodes)
|
||||||
|
.where(eq(discountCodes.code, code))
|
||||||
|
.limit(1);
|
||||||
|
if (existing) throw new BadRequestException(`'${code}' kodu zaten mevcut`);
|
||||||
|
|
||||||
|
let couponId: string;
|
||||||
|
let promotionCodeId: string;
|
||||||
|
try {
|
||||||
|
const created = await this.stripe.createPromotion({
|
||||||
|
code,
|
||||||
|
discountType,
|
||||||
|
percentOff: percentOff ?? undefined,
|
||||||
|
amountOff: amountOff ?? undefined,
|
||||||
|
duration,
|
||||||
|
durationInMonths: durationInMonths ?? undefined,
|
||||||
|
maxRedemptions: maxRedemptions ?? undefined,
|
||||||
|
expiresAt: expiresAt ?? undefined,
|
||||||
|
});
|
||||||
|
couponId = created.couponId;
|
||||||
|
promotionCodeId = created.promotionCodeId;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as Error).message || String(err);
|
||||||
|
if (msg.toLowerCase().includes("already exists")) {
|
||||||
|
throw new BadRequestException(`'${code}' kodu Stripe'da zaten kullanımda`);
|
||||||
|
}
|
||||||
|
this.logger.error(`Stripe promotion create failed for ${code}: ${msg}`);
|
||||||
|
throw new BadRequestException(`İndirim kodu oluşturulamadı: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await this.db
|
||||||
|
.insert(discountCodes)
|
||||||
|
.values({
|
||||||
|
code,
|
||||||
|
stripeCouponId: couponId,
|
||||||
|
stripePromotionCodeId: promotionCodeId,
|
||||||
|
discountType,
|
||||||
|
percentOff,
|
||||||
|
amountOff,
|
||||||
|
duration,
|
||||||
|
durationInMonths,
|
||||||
|
maxRedemptions,
|
||||||
|
expiresAt,
|
||||||
|
note: input.note?.slice(0, 500) ?? null,
|
||||||
|
createdBy,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
this.logger.log(`Discount code created: ${code} (${discountType}) by ${createdBy ?? "?"}`);
|
||||||
|
return { ...row, status: this.computeStatus(row) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async list() {
|
||||||
|
const rows = await this.db.select().from(discountCodes).orderBy(desc(discountCodes.createdAt));
|
||||||
|
return rows.map((r) => ({ ...r, status: this.computeStatus(r) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOne(id: string) {
|
||||||
|
const [row] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(discountCodes)
|
||||||
|
.where(eq(discountCodes.id, id))
|
||||||
|
.limit(1);
|
||||||
|
if (!row) throw new NotFoundException("İndirim kodu bulunamadı");
|
||||||
|
return { ...row, status: this.computeStatus(row) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivate(id: string) {
|
||||||
|
const [row] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(discountCodes)
|
||||||
|
.where(eq(discountCodes.id, id))
|
||||||
|
.limit(1);
|
||||||
|
if (!row) throw new NotFoundException("İndirim kodu bulunamadı");
|
||||||
|
|
||||||
|
// Must archive on Stripe too: checkout resolves the live promotion code, so
|
||||||
|
// a code left active there would keep working after a local-only deactivate.
|
||||||
|
if (row.stripePromotionCodeId) {
|
||||||
|
await this.stripe.deactivatePromotion(row.stripePromotionCodeId);
|
||||||
|
}
|
||||||
|
await this.db
|
||||||
|
.update(discountCodes)
|
||||||
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
|
.where(eq(discountCodes.id, id));
|
||||||
|
this.logger.log(`Discount code deactivated: ${row.code}`);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile mirrored redemption counts from Stripe (panel "sync" button). */
|
||||||
|
async syncRedemptions() {
|
||||||
|
const rows = await this.db.select().from(discountCodes);
|
||||||
|
let updated = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.stripePromotionCodeId) continue;
|
||||||
|
const count = await this.stripe.getPromotionRedemptions(row.stripePromotionCodeId);
|
||||||
|
if (count != null && count !== row.timesRedeemed) {
|
||||||
|
await this.db
|
||||||
|
.update(discountCodes)
|
||||||
|
.set({ timesRedeemed: count, updatedAt: new Date() })
|
||||||
|
.where(eq(discountCodes.id, row.id));
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { synced: rows.length, updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate + price a code for the checkout page. */
|
||||||
|
preview(code: string, planKey: string, billingPeriod: "monthly" | "yearly") {
|
||||||
|
return this.stripe.previewDiscount(code, planKey, billingPeriod);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,11 @@ export class CorgiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extractYear(vin: string): number | null {
|
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 {
|
getBrandFromWmi(wmi: string): string | null {
|
||||||
|
|||||||
@@ -14,6 +14,11 @@
|
|||||||
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Browser, BrowserContext, Page } from "playwright";
|
import { Browser, BrowserContext, Page } from "playwright";
|
||||||
|
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
|
||||||
|
import {
|
||||||
|
classifyTransportError,
|
||||||
|
isProxyConnectFailure,
|
||||||
|
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||||
|
|
||||||
const SESSION_TTL_MS = 25 * 60 * 1000; // 25 minutes
|
const SESSION_TTL_MS = 25 * 60 * 1000; // 25 minutes
|
||||||
const MAX_CONCURRENT_PAGES = 3;
|
const MAX_CONCURRENT_PAGES = 3;
|
||||||
@@ -61,22 +66,54 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private launching: Promise<void> | null = null;
|
private launching: Promise<void> | null = null;
|
||||||
|
|
||||||
private readonly useProxy: boolean;
|
private readonly useProxy: boolean;
|
||||||
|
// "floxy" residential (default) | "dataimpulse" legacy | "none".
|
||||||
|
private readonly proxyProvider: "floxy" | "dataimpulse" | "none";
|
||||||
private readonly proxyHost: string;
|
private readonly proxyHost: string;
|
||||||
private readonly proxyPortStart: number;
|
private readonly proxyPortStart: number;
|
||||||
private readonly proxyPortEnd: number;
|
private readonly proxyPortEnd: number;
|
||||||
private readonly proxyUsername: string;
|
private readonly proxyUsername: string;
|
||||||
private readonly proxyPassword: string;
|
private readonly proxyPassword: string;
|
||||||
|
// Floxy residential — a sticky session pins ONE exit IP for the browser's whole
|
||||||
|
// lifetime (relaunch mints a fresh session id), which suits the chained scrape.
|
||||||
|
private readonly floxyHost: string;
|
||||||
|
private readonly floxyPort: number;
|
||||||
|
private readonly floxyUser: string;
|
||||||
|
private readonly floxyPass: string;
|
||||||
|
private readonly floxyLifetime: number;
|
||||||
private startedAt = 0;
|
private startedAt = 0;
|
||||||
|
// Provider the live browser was actually launched with. Tracks failover: when
|
||||||
|
// the configured provider is "floxy" but the shared health gate is tripped,
|
||||||
|
// (re)launch picks "dataimpulse" instead. Whole-browser scope because Playwright
|
||||||
|
// binds the proxy at launch — a relaunch is what swaps the exit.
|
||||||
|
private activeProvider: "floxy" | "dataimpulse" | "none" = "none";
|
||||||
|
|
||||||
constructor(private configService: ConfigService) {
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private readonly proxyHealth: ProxyHealthService,
|
||||||
|
) {
|
||||||
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
|
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
|
||||||
|
|
||||||
this.useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
|
this.useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
|
||||||
|
// Default dataimpulse: Floxy retired 2026-07 (permanently down). Set
|
||||||
|
// EMEX_PROXY_PROVIDER=floxy to re-enable it.
|
||||||
|
const rawProvider = this.configService
|
||||||
|
.get<string>("EMEX_PROXY_PROVIDER", "dataimpulse")
|
||||||
|
.toLowerCase();
|
||||||
|
this.proxyProvider = !this.useProxy
|
||||||
|
? "none"
|
||||||
|
: rawProvider === "floxy" || rawProvider === "none"
|
||||||
|
? (rawProvider as "floxy" | "none")
|
||||||
|
: "dataimpulse";
|
||||||
this.proxyHost = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81");
|
this.proxyHost = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81");
|
||||||
this.proxyPortStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001);
|
this.proxyPortStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001);
|
||||||
this.proxyPortEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099);
|
this.proxyPortEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099);
|
||||||
this.proxyUsername = this.configService.get<string>("EMEX_PROXY_USER", "1726bbe361918676d44e");
|
this.proxyUsername = this.configService.get<string>("EMEX_PROXY_USER", "1726bbe361918676d44e");
|
||||||
this.proxyPassword = this.configService.get<string>("EMEX_PROXY_PASS", "f11c7b6128cc86c6");
|
this.proxyPassword = this.configService.get<string>("EMEX_PROXY_PASS", "f11c7b6128cc86c6");
|
||||||
|
this.floxyHost = this.configService.get<string>("EMEX_FLOXY_HOST", "residential.floxy.io");
|
||||||
|
this.floxyPort = Number(this.configService.get("EMEX_FLOXY_PORT", 12321)) || 12321;
|
||||||
|
this.floxyUser = this.configService.get<string>("EMEX_FLOXY_USER", "cac4b0d96a80");
|
||||||
|
this.floxyPass = this.configService.get<string>("EMEX_FLOXY_PASS", "dfc38fe467a3");
|
||||||
|
this.floxyLifetime = Number(this.configService.get("EMEX_FLOXY_LIFETIME", 300)) || 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
@@ -184,14 +221,38 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||||
if (executablePath) launchOptions.executablePath = executablePath;
|
if (executablePath) launchOptions.executablePath = executablePath;
|
||||||
|
|
||||||
if (this.useProxy) {
|
// Pick the provider for THIS launch: honour the configured one, but while the
|
||||||
|
// shared Floxy-health gate is tripped, a "floxy" config launches on DataImpulse.
|
||||||
|
this.activeProvider = this.pickLaunchProvider();
|
||||||
|
|
||||||
|
if (this.activeProvider === "floxy") {
|
||||||
|
// Sticky Floxy session for the browser's lifetime → one residential exit IP
|
||||||
|
// for the whole chained scrape; a fresh id is minted on each (re)launch.
|
||||||
|
const sid = Math.random().toString(36).slice(2, 10);
|
||||||
|
const pass =
|
||||||
|
this.floxyLifetime > 0
|
||||||
|
? `${this.floxyPass}_session-${sid}_lifetime-${this.floxyLifetime}`
|
||||||
|
: this.floxyPass;
|
||||||
|
launchOptions.proxy = {
|
||||||
|
server: `http://${this.floxyHost}:${this.floxyPort}`,
|
||||||
|
username: this.floxyUser,
|
||||||
|
password: pass,
|
||||||
|
};
|
||||||
|
this.logger.log(
|
||||||
|
`Using Floxy residential proxy: ${this.floxyHost}:${this.floxyPort} (sticky ${this.floxyLifetime}s)`,
|
||||||
|
);
|
||||||
|
} else if (this.activeProvider === "dataimpulse") {
|
||||||
const port = this.randomProxyPort();
|
const port = this.randomProxyPort();
|
||||||
launchOptions.proxy = {
|
launchOptions.proxy = {
|
||||||
server: `http://${this.proxyHost}:${port}`,
|
server: `http://${this.proxyHost}:${port}`,
|
||||||
username: this.proxyUsername,
|
username: this.proxyUsername,
|
||||||
password: this.proxyPassword,
|
password: this.proxyPassword,
|
||||||
};
|
};
|
||||||
this.logger.log(`Using proxy: ${this.proxyHost}:${port}`);
|
this.logger.log(
|
||||||
|
`Using DataImpulse proxy: ${this.proxyHost}:${port}${
|
||||||
|
this.proxyProvider === "floxy" ? " (Floxy failover)" : ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.browser = await chromium.launch(launchOptions);
|
this.browser = await chromium.launch(launchOptions);
|
||||||
@@ -201,6 +262,42 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
"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.startedAt = Date.now();
|
||||||
this.sessionExpiry = 0; // force session establish on first acquirePage
|
this.sessionExpiry = 0; // force session establish on first acquirePage
|
||||||
|
|
||||||
@@ -232,34 +329,94 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
await this.launchBrowser();
|
await this.launchBrowser();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Provider for the next launch: DataImpulse while the Floxy gate is tripped. */
|
||||||
|
private pickLaunchProvider(): "floxy" | "dataimpulse" | "none" {
|
||||||
|
if (this.proxyProvider === "floxy" && this.proxyHealth.isFloxyDown()) {
|
||||||
|
return "dataimpulse"; // DI creds are always present (env defaults) when proxy on
|
||||||
|
}
|
||||||
|
return this.proxyProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async relaunchBrowser(): Promise<void> {
|
||||||
|
await this.closeBrowser();
|
||||||
|
await this.launchBrowser();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Visit baseUrl to establish/refresh ASP.NET session cookie
|
* Trip Floxy failover from a scrape-level transport failure (a connect error
|
||||||
|
* that surfaced AFTER the session was established, so ensureSession's own gate
|
||||||
|
* didn't catch it). Opens the shared cooldown and forces the next acquirePage
|
||||||
|
* to re-establish the session — which switches the browser to DataImpulse.
|
||||||
|
*/
|
||||||
|
async tripFloxyFailover(reason?: string): Promise<void> {
|
||||||
|
this.proxyHealth.reportFloxyFailure(reason);
|
||||||
|
if (this.activeProvider === "floxy") {
|
||||||
|
this.sessionExpiry = 0; // force ensureSession → provider switch on next acquire
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visit baseUrl to establish/refresh ASP.NET session cookie. Doubles as the
|
||||||
|
* Floxy health gate for the browser path: only at a session boundary, switch
|
||||||
|
* the browser's proxy to match pickLaunchProvider() (fail over to DataImpulse
|
||||||
|
* when Floxy is down, probe Floxy again once the gate clears). A connect
|
||||||
|
* failure during the probe trips the gate and retries once on DataImpulse.
|
||||||
*/
|
*/
|
||||||
private async ensureSession(): Promise<void> {
|
private async ensureSession(): Promise<void> {
|
||||||
|
// Only re-evaluate the provider when establishing a fresh session, so an
|
||||||
|
// in-flight scrape on a working browser is never interrupted mid-flow.
|
||||||
|
if (Date.now() >= this.sessionExpiry && this.proxyProvider !== "none") {
|
||||||
|
const desired = this.pickLaunchProvider();
|
||||||
|
if (this.browser?.isConnected() && desired !== this.activeProvider) {
|
||||||
|
this.logger.log(`EMEX switching browser proxy: ${this.activeProvider} → ${desired}`);
|
||||||
|
await this.relaunchBrowser();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Date.now() < this.sessionExpiry) return;
|
if (Date.now() < this.sessionExpiry) return;
|
||||||
|
|
||||||
this.logger.log("Establishing EMEX session...");
|
// Up to 2 attempts: a Floxy tunnel failure trips the gate, relaunches on
|
||||||
if (!this.context) throw new Error("EMEX browser context not initialized");
|
// DataImpulse, and tries once more so the caller still gets a live session.
|
||||||
const ctx = this.context;
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||||
const page = await ctx.newPage();
|
if (!this.context) throw new Error("EMEX browser context not initialized");
|
||||||
try {
|
const ctx = this.context;
|
||||||
await page.goto(EMEX_BASE_URL, {
|
const page = await ctx.newPage();
|
||||||
waitUntil: "networkidle",
|
try {
|
||||||
timeout: 30000,
|
this.logger.log(`Establishing EMEX session (${this.activeProvider})...`);
|
||||||
});
|
await page.goto(EMEX_BASE_URL, {
|
||||||
|
waitUntil: "networkidle",
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
const cookies = await ctx.cookies();
|
if (this.activeProvider === "floxy") this.proxyHealth.reportFloxySuccess();
|
||||||
const session = cookies.find((c) => c.name === "ASP.NET_SessionId");
|
|
||||||
if (session) {
|
const cookies = await ctx.cookies();
|
||||||
this.sessionExpiry = Date.now() + SESSION_TTL_MS;
|
const session = cookies.find((c) => c.name === "ASP.NET_SessionId");
|
||||||
this.logger.log("Session established, TTL 25 min");
|
if (session) {
|
||||||
} else {
|
this.sessionExpiry = Date.now() + SESSION_TTL_MS;
|
||||||
this.logger.warn("No session cookie found after visiting baseUrl");
|
this.logger.log("Session established, TTL 25 min");
|
||||||
// Still set a short TTL to avoid hammering
|
} else {
|
||||||
this.sessionExpiry = Date.now() + 60_000;
|
this.logger.warn("No session cookie found after visiting baseUrl");
|
||||||
|
// Still set a short TTL to avoid hammering
|
||||||
|
this.sessionExpiry = Date.now() + 60_000;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
await page.close().catch(() => {});
|
||||||
|
const canFailover =
|
||||||
|
attempt < 2 &&
|
||||||
|
this.proxyProvider === "floxy" &&
|
||||||
|
this.activeProvider === "floxy" &&
|
||||||
|
isProxyConnectFailure(err);
|
||||||
|
if (canFailover) {
|
||||||
|
this.proxyHealth.reportFloxyFailure(`emex session: ${classifyTransportError(err)}`);
|
||||||
|
await this.relaunchBrowser(); // gate now tripped → relaunch picks DataImpulse
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
await page.close().catch(() => {});
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
await page.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ProxyTelemetryModule } from "../proxy-telemetry/proxy-telemetry.module";
|
||||||
import { EmexBrowserService } from "./emex.browser";
|
import { EmexBrowserService } from "./emex.browser";
|
||||||
import { EmexService } from "./emex.service";
|
import { EmexService } from "./emex.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [ProxyTelemetryModule],
|
||||||
providers: [EmexBrowserService, EmexService],
|
providers: [EmexBrowserService, EmexService],
|
||||||
exports: [EmexService],
|
exports: [EmexService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ function makeConfig(overrides: Record<string, string>): ConfigService {
|
|||||||
const browser = {} as never;
|
const browser = {} as never;
|
||||||
const redis = { set: vi.fn() } as never;
|
const redis = { set: vi.fn() } as never;
|
||||||
const posthog = {} as never;
|
const posthog = {} as never;
|
||||||
|
const proxyTelemetry = { record: vi.fn() } as never;
|
||||||
|
const proxyHealth = {
|
||||||
|
isFloxyDown: vi.fn(() => false),
|
||||||
|
reportFloxyFailure: vi.fn(),
|
||||||
|
reportFloxySuccess: vi.fn(),
|
||||||
|
} as never;
|
||||||
|
|
||||||
describe("EmexService proxy-port coercion (regression)", () => {
|
describe("EmexService proxy-port coercion (regression)", () => {
|
||||||
it("does not throw 'Invalid URL' when ports arrive as strings over a real range", () => {
|
it("does not throw 'Invalid URL' when ports arrive as strings over a real range", () => {
|
||||||
@@ -28,7 +34,9 @@ describe("EmexService proxy-port coercion (regression)", () => {
|
|||||||
EMEX_PROXY_PORT_END: "10099",
|
EMEX_PROXY_PORT_END: "10099",
|
||||||
});
|
});
|
||||||
// Pre-fix: threw "Invalid URL" here (port "4510001" > 65535).
|
// Pre-fix: threw "Invalid URL" here (port "4510001" > 65535).
|
||||||
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
|
expect(
|
||||||
|
() => new EmexService(config, browser, redis, posthog, proxyTelemetry, proxyHealth),
|
||||||
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("constructs with a single-port range (string env)", () => {
|
it("constructs with a single-port range (string env)", () => {
|
||||||
@@ -37,7 +45,9 @@ describe("EmexService proxy-port coercion (regression)", () => {
|
|||||||
EMEX_PROXY_PORT_START: "823",
|
EMEX_PROXY_PORT_START: "823",
|
||||||
EMEX_PROXY_PORT_END: "823",
|
EMEX_PROXY_PORT_END: "823",
|
||||||
});
|
});
|
||||||
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
|
expect(
|
||||||
|
() => new EmexService(config, browser, redis, posthog, proxyTelemetry, proxyHealth),
|
||||||
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to a valid default when the port env is garbage", () => {
|
it("falls back to a valid default when the port env is garbage", () => {
|
||||||
@@ -46,11 +56,15 @@ describe("EmexService proxy-port coercion (regression)", () => {
|
|||||||
EMEX_PROXY_PORT_START: "not-a-number",
|
EMEX_PROXY_PORT_START: "not-a-number",
|
||||||
EMEX_PROXY_PORT_END: "999999",
|
EMEX_PROXY_PORT_END: "999999",
|
||||||
});
|
});
|
||||||
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
|
expect(
|
||||||
|
() => new EmexService(config, browser, redis, posthog, proxyTelemetry, proxyHealth),
|
||||||
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("constructs cleanly with the proxy disabled", () => {
|
it("constructs cleanly with the proxy disabled", () => {
|
||||||
const config = makeConfig({ EMEX_USE_PROXY: "false" });
|
const config = makeConfig({ EMEX_USE_PROXY: "false" });
|
||||||
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
|
expect(
|
||||||
|
() => new EmexService(config, browser, redis, posthog, proxyTelemetry, proxyHealth),
|
||||||
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ import { ProxyAgent } from "undici";
|
|||||||
import { isBackfillContext } from "../../jobs/prefetch-context";
|
import { isBackfillContext } from "../../jobs/prefetch-context";
|
||||||
import { PostHogService } from "../../posthog/posthog.service";
|
import { PostHogService } from "../../posthog/posthog.service";
|
||||||
import { RedisService } from "../../redis/redis.service";
|
import { RedisService } from "../../redis/redis.service";
|
||||||
|
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
|
||||||
|
import {
|
||||||
|
ProxyTelemetryService,
|
||||||
|
classifyTransportError,
|
||||||
|
isProxyConnectFailure,
|
||||||
|
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||||
import { parseUnitLeaves, parseVehicleTree } from "./emex-tree.parser";
|
import { parseUnitLeaves, parseVehicleTree } from "./emex-tree.parser";
|
||||||
import { EmexBrowserService } from "./emex.browser";
|
import { EmexBrowserService } from "./emex.browser";
|
||||||
import { createEmptyDecodedVehicle, mapEmexResponse } from "./emex.mapper";
|
import { createEmptyDecodedVehicle, mapEmexResponse } from "./emex.mapper";
|
||||||
@@ -132,12 +138,18 @@ export class EmexService {
|
|||||||
private floxySessionId = Math.random().toString(36).slice(2, 10);
|
private floxySessionId = Math.random().toString(36).slice(2, 10);
|
||||||
private floxySessionBornMs = Date.now();
|
private floxySessionBornMs = Date.now();
|
||||||
private readonly emexDirectFallback: boolean;
|
private readonly emexDirectFallback: boolean;
|
||||||
|
// Transport provider for EMEX egress (HTTP + image-dims). "floxy" residential
|
||||||
|
// is primary by default (sticky session pins one exit IP across a chained flow),
|
||||||
|
// DataImpulse is the last-ditch fallback. See constructor.
|
||||||
|
private readonly emexProxyProvider: "floxy" | "dataimpulse" | "none";
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private browserService: EmexBrowserService,
|
private browserService: EmexBrowserService,
|
||||||
private redis: RedisService,
|
private redis: RedisService,
|
||||||
private posthog: PostHogService,
|
private posthog: PostHogService,
|
||||||
|
private proxyTelemetry: ProxyTelemetryService,
|
||||||
|
private proxyHealth: ProxyHealthService,
|
||||||
) {
|
) {
|
||||||
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
|
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
|
||||||
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
|
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
|
||||||
@@ -169,15 +181,11 @@ export class EmexService {
|
|||||||
portStart: toPort("EMEX_PROXY_PORT_START", 10001),
|
portStart: toPort("EMEX_PROXY_PORT_START", 10001),
|
||||||
portEnd: toPort("EMEX_PROXY_PORT_END", 10099),
|
portEnd: toPort("EMEX_PROXY_PORT_END", 10099),
|
||||||
};
|
};
|
||||||
// Default agent for the low-stakes image-dims path; fetchEmexHtml builds a
|
|
||||||
// fresh agent per request so a flaky port can't pin every call.
|
|
||||||
this.proxyAgent = this.newProxyAgent();
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`EMEX HTTP proxy enabled: ${this.emexProxy.host}:${this.emexProxy.portStart}-${this.emexProxy.portEnd}`,
|
`EMEX DataImpulse fallback enabled: ${this.emexProxy.host}:${this.emexProxy.portStart}-${this.emexProxy.portEnd}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.emexProxy = null;
|
this.emexProxy = null;
|
||||||
this.proxyAgent = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floxy residential fallback for when the DataImpulse pool flakes (connect
|
// Floxy residential fallback for when the DataImpulse pool flakes (connect
|
||||||
@@ -189,8 +197,8 @@ export class EmexService {
|
|||||||
this.emexFloxy = {
|
this.emexFloxy = {
|
||||||
host: this.configService.get<string>("EMEX_FLOXY_HOST", "residential.floxy.io"),
|
host: this.configService.get<string>("EMEX_FLOXY_HOST", "residential.floxy.io"),
|
||||||
port: Number.isInteger(fport) && fport >= 1 && fport <= 65535 ? fport : 12321,
|
port: Number.isInteger(fport) && fport >= 1 && fport <= 65535 ? fport : 12321,
|
||||||
user: this.configService.get<string>("EMEX_FLOXY_USER", "d739255e819b"),
|
user: this.configService.get<string>("EMEX_FLOXY_USER", "cac4b0d96a80"),
|
||||||
pass: this.configService.get<string>("EMEX_FLOXY_PASS", "9092873ba4e0"),
|
pass: this.configService.get<string>("EMEX_FLOXY_PASS", "dfc38fe467a3"),
|
||||||
lifetime: Number.isInteger(flife) && flife >= 0 ? flife : 300,
|
lifetime: Number.isInteger(flife) && flife >= 0 ? flife : 300,
|
||||||
};
|
};
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -200,6 +208,32 @@ export class EmexService {
|
|||||||
this.emexFloxy = null;
|
this.emexFloxy = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Transport provider. EMEX_PROXY_PROVIDER: "floxy" (default) → Floxy residential
|
||||||
|
// sticky primary + a single DataImpulse last-ditch fallback; "dataimpulse" →
|
||||||
|
// legacy DataImpulse-first order; "none" → direct. ssd tokens are replayed from
|
||||||
|
// emex's own HTML and aren't strictly IP-bound on the .aspx endpoints (the old
|
||||||
|
// random-port-per-call primary proved that), but a sticky Floxy IP across the
|
||||||
|
// chained flow is still the safest choice — and avoids DataImpulse's ~50% dead
|
||||||
|
// ports that were eating the decode budget before reaching the Floxy fallback.
|
||||||
|
// Default dataimpulse: Floxy retired 2026-07 (permanently down). Set
|
||||||
|
// EMEX_PROXY_PROVIDER=floxy to re-enable it.
|
||||||
|
this.emexProxyProvider = !useProxy
|
||||||
|
? "none"
|
||||||
|
: (() => {
|
||||||
|
const p = this.configService
|
||||||
|
.get<string>("EMEX_PROXY_PROVIDER", "dataimpulse")
|
||||||
|
.toLowerCase();
|
||||||
|
return p === "floxy" || p === "none" ? p : "dataimpulse";
|
||||||
|
})();
|
||||||
|
// Default agent for the low-stakes image-dims path; fetchEmexHtml builds a
|
||||||
|
// fresh agent per request so a flaky exit can't pin every call.
|
||||||
|
this.proxyAgent =
|
||||||
|
this.emexProxyProvider === "none"
|
||||||
|
? null
|
||||||
|
: (this.newProxyAgent(this.emexProxyProvider === "dataimpulse" ? "dataimpulse" : "floxy") ??
|
||||||
|
this.newProxyAgent("dataimpulse"));
|
||||||
|
this.logger.log(`EMEX transport provider: ${this.emexProxyProvider}`);
|
||||||
|
|
||||||
this.logger.log(`EMEX Service initialized with scraper path: ${this.scraperPath}`);
|
this.logger.log(`EMEX Service initialized with scraper path: ${this.scraperPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,29 +381,56 @@ export class EmexService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async fetchEmexHtml(url: string): Promise<string> {
|
private async fetchEmexHtml(url: string): Promise<string> {
|
||||||
// Attempt schedule: DataImpulse (rotating port, fresh agent each try to dodge
|
// Attempt schedule by provider. floxy (default): Floxy residential sticky
|
||||||
// a flaky sticky port — ~42% blip rate, undecoded-vin-rca.md EMEX #1) first,
|
// primary (one exit IP across the flow; rolls to a fresh IP on transport
|
||||||
// then the Floxy residential fallback when the DataImpulse pool throws
|
// failure), then a single DataImpulse last-ditch try — this avoids DataImpulse's
|
||||||
// transport errors. A definitive HTTP answer (e.g. 404) stops the schedule —
|
// ~50% dead ports that used to burn the decode budget before reaching Floxy.
|
||||||
// it's a real result, and a different proxy IP must not "retry" it away.
|
// dataimpulse (legacy): DataImpulse-first (rotating port each try), Floxy after.
|
||||||
|
// A definitive HTTP answer (e.g. 404) stops the schedule — it's a real result,
|
||||||
|
// and a different proxy IP must not "retry" it away.
|
||||||
const schedule: Array<"dataimpulse" | "floxy"> = [];
|
const schedule: Array<"dataimpulse" | "floxy"> = [];
|
||||||
if (this.emexProxy) schedule.push("dataimpulse", "dataimpulse", "dataimpulse");
|
if (this.emexProxyProvider === "floxy") {
|
||||||
if (this.emexFloxy) schedule.push("floxy", "floxy");
|
if (this.emexFloxy && this.proxyHealth.isFloxyDown() && this.emexProxy) {
|
||||||
|
// Floxy gate tripped → DataImpulse-first (rotating ports cover its ~50%
|
||||||
|
// dead ports), with one Floxy probe last so a recovery is still detected.
|
||||||
|
schedule.push("dataimpulse", "dataimpulse", "floxy");
|
||||||
|
} else {
|
||||||
|
if (this.emexFloxy) schedule.push("floxy", "floxy");
|
||||||
|
if (this.emexProxy) schedule.push("dataimpulse");
|
||||||
|
}
|
||||||
|
} else if (this.emexProxyProvider === "dataimpulse") {
|
||||||
|
if (this.emexProxy) schedule.push("dataimpulse", "dataimpulse", "dataimpulse");
|
||||||
|
if (this.emexFloxy) schedule.push("floxy", "floxy");
|
||||||
|
}
|
||||||
const maxAttempts = schedule.length || 1; // 0 → single proxy-less attempt
|
const maxAttempts = schedule.length || 1; // 0 → single proxy-less attempt
|
||||||
let lastErr: unknown;
|
let lastErr: unknown;
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
const provider = schedule[attempt - 1]; // undefined when no proxy → direct
|
const provider = schedule[attempt - 1]; // undefined when no proxy → direct
|
||||||
const agent = provider ? this.newProxyAgent(provider) : null;
|
const agent = provider ? this.newProxyAgent(provider) : null;
|
||||||
|
const attemptStart = Date.now();
|
||||||
|
// Sticky Floxy sessions are the attributable identity here; DataImpulse
|
||||||
|
// rotates a random port per agent, so there is nothing stable to pin.
|
||||||
|
const sessionKey = provider === "floxy" ? `s-${this.floxySessionId}` : null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { "User-Agent": EMEX_UA, Accept: "text/html,application/xhtml+xml" },
|
headers: { "User-Agent": EMEX_UA, Accept: "text/html,application/xhtml+xml" },
|
||||||
signal: AbortSignal.timeout(this.timeout),
|
signal: AbortSignal.timeout(this.timeout),
|
||||||
...(agent ? { dispatcher: agent } : {}),
|
...(agent ? { dispatcher: agent } : {}),
|
||||||
} as RequestInit);
|
} as RequestInit);
|
||||||
|
this.proxyTelemetry.record({
|
||||||
|
service: "emex_http",
|
||||||
|
provider: provider ?? "none",
|
||||||
|
sessionKey,
|
||||||
|
targetHost: new URL(url).hostname,
|
||||||
|
statusCode: res.status,
|
||||||
|
success: res.ok,
|
||||||
|
durationMs: Date.now() - attemptStart,
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
|
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
|
||||||
}
|
}
|
||||||
if (provider === "floxy") {
|
if (provider === "floxy") {
|
||||||
|
this.proxyHealth.reportFloxySuccess(); // Floxy answered — clear cooldown
|
||||||
this.logger.log(`EMEX fetch via Floxy fallback succeeded for ${url}`);
|
this.logger.log(`EMEX fetch via Floxy fallback succeeded for ${url}`);
|
||||||
}
|
}
|
||||||
return await res.text();
|
return await res.text();
|
||||||
@@ -378,6 +439,19 @@ export class EmexService {
|
|||||||
const e = err as Error & { cause?: unknown };
|
const e = err as Error & { cause?: unknown };
|
||||||
// A real HTTP response ("EMEX HTTP 404") is a definitive answer — never retry.
|
// A real HTTP response ("EMEX HTTP 404") is a definitive answer — never retry.
|
||||||
const httpAnswer = /^EMEX HTTP \d/.test(e.message);
|
const httpAnswer = /^EMEX HTTP \d/.test(e.message);
|
||||||
|
// HTTP answers were already recorded right after fetch; only transport
|
||||||
|
// failures (no answer at all) still need a row.
|
||||||
|
if (!httpAnswer) {
|
||||||
|
this.proxyTelemetry.record({
|
||||||
|
service: "emex_http",
|
||||||
|
provider: provider ?? "none",
|
||||||
|
sessionKey,
|
||||||
|
targetHost: new URL(url).hostname,
|
||||||
|
errorKind: classifyTransportError(e),
|
||||||
|
success: false,
|
||||||
|
durationMs: Date.now() - attemptStart,
|
||||||
|
});
|
||||||
|
}
|
||||||
const transient =
|
const transient =
|
||||||
!httpAnswer &&
|
!httpAnswer &&
|
||||||
(e.name === "TimeoutError" ||
|
(e.name === "TimeoutError" ||
|
||||||
@@ -386,6 +460,11 @@ export class EmexService {
|
|||||||
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|other side closed|terminated|UND_ERR/i.test(
|
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|other side closed|terminated|UND_ERR/i.test(
|
||||||
`${e.message} ${String(e.cause ?? "")}`,
|
`${e.message} ${String(e.cause ?? "")}`,
|
||||||
));
|
));
|
||||||
|
// A Floxy connect failure (dead exit / tunnel refused) trips the shared
|
||||||
|
// gate so EMEX + pcat fail over to DataImpulse for the cooldown window.
|
||||||
|
if (provider === "floxy" && isProxyConnectFailure(e)) {
|
||||||
|
this.proxyHealth.reportFloxyFailure(`emex http: ${classifyTransportError(e)}`);
|
||||||
|
}
|
||||||
if (!transient || attempt === maxAttempts) break;
|
if (!transient || attempt === maxAttempts) break;
|
||||||
// A Floxy transport failure means the current sticky exit IP is dead —
|
// A Floxy transport failure means the current sticky exit IP is dead —
|
||||||
// roll to a fresh session so the next Floxy attempt gets a new IP.
|
// roll to a fresh session so the next Floxy attempt gets a new IP.
|
||||||
@@ -849,6 +928,12 @@ export class EmexService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
|
|
||||||
|
// Floxy tunnel dead mid-scrape → trip failover so the next acquirePage
|
||||||
|
// re-establishes the session on DataImpulse.
|
||||||
|
if (isProxyConnectFailure(err)) {
|
||||||
|
void this.browserService.tripFloxyFailover(`emex decode: ${classifyTransportError(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
err instanceof BadRequestException ||
|
err instanceof BadRequestException ||
|
||||||
err instanceof ServiceUnavailableException ||
|
err instanceof ServiceUnavailableException ||
|
||||||
@@ -992,6 +1077,10 @@ export class EmexService {
|
|||||||
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
|
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
|
// Floxy tunnel dead mid-scrape → trip failover for the next acquirePage.
|
||||||
|
if (isProxyConnectFailure(err)) {
|
||||||
|
void this.browserService.tripFloxyFailover(`emex parts: ${classifyTransportError(err)}`);
|
||||||
|
}
|
||||||
this.logger.error(`Failed to fetch category parts: ${err.message}`);
|
this.logger.error(`Failed to fetch category parts: ${err.message}`);
|
||||||
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
|
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ export const CATALOG_MAP: Record<string, CatalogEntry> = {
|
|||||||
// Opel
|
// Opel
|
||||||
W0L: { code: "GM_OP201809", brand: "Opel" },
|
W0L: { code: "GM_OP201809", brand: "Opel" },
|
||||||
W0V: { code: "GM_OP201809", brand: "Opel" }, // newer Stellantis-era WMI
|
W0V: { code: "GM_OP201809", brand: "Opel" }, // newer Stellantis-era WMI
|
||||||
|
VXK: { code: "GM_OP201809", brand: "Opel" }, // PSA/Stellantis-platform Opel (Corsa F, Mokka B)
|
||||||
// Chevrolet
|
// Chevrolet
|
||||||
KL1: { code: "GM_C201809", brand: "Chevrolet" },
|
KL1: { code: "GM_C201809", brand: "Chevrolet" },
|
||||||
// SsangYong
|
// SsangYong
|
||||||
|
|||||||
262
apps/api/src/integrations/p/p-source-db.service.ts
Normal file
262
apps/api/src/integrations/p/p-source-db.service.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import postgres, { type Sql } from "postgres";
|
||||||
|
import type { PArticle, PCompatible, POeNumber, POemResult } from "./p.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only lookup against the imported P snapshot (db `td` — a selective
|
||||||
|
* copy of articles + OE numbers + aftermarket compatibility + images/eans; the
|
||||||
|
* 29 GB vehicle-fitment table is intentionally excluded). Given an OEM code from
|
||||||
|
* the sase catalog, returns the P articles that carry it as an OE number,
|
||||||
|
* with their aftermarket equivalents and OE cross-references.
|
||||||
|
*
|
||||||
|
* Matching is normalisation-based, not exact: P stores OE codes with
|
||||||
|
* spaces/dashes (`1J0 973 702`) while the catalog gives `1J0973702`, so both
|
||||||
|
* sides are reduced to `[A-Z0-9]` uppercase before comparison (a precomputed
|
||||||
|
* `code_norm` column, indexed, holds the P side). Exact matching recovers
|
||||||
|
* almost nothing — verified ~1/10 vs normalised ~5/10 on real catalog codes.
|
||||||
|
*
|
||||||
|
* Never throws: disabled feature, too-short code, connection blip or no match
|
||||||
|
* all collapse to `matched: false` so the UI has a single empty-state path.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PSourceDbService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(PSourceDbService.name);
|
||||||
|
private sql: Sql | null = null;
|
||||||
|
private enabled = false;
|
||||||
|
|
||||||
|
// Short normalised codes (e.g. "NA" from "N/A", single digits) collide across
|
||||||
|
// unrelated parts — refuse to match below this length. Real OE numbers are 5+.
|
||||||
|
private static readonly MIN_NORM_LEN = 5;
|
||||||
|
private static readonly MAX_ARTICLES = 60;
|
||||||
|
private static readonly MAX_AGG = 300;
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
const enabled = this.config.get<boolean>("p.enabled");
|
||||||
|
const url = this.config.get<string>("p.url");
|
||||||
|
if (!enabled || !url) {
|
||||||
|
this.logger.log(`[p] disabled (enabled=${enabled}, urlSet=${Boolean(url)})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sql = postgres(url, {
|
||||||
|
max: 5,
|
||||||
|
idle_timeout: 30,
|
||||||
|
connect_timeout: 10,
|
||||||
|
prepare: false,
|
||||||
|
});
|
||||||
|
this.enabled = true;
|
||||||
|
this.logger.log("[p] connected, OEM cross-reference lookup enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
if (this.sql) {
|
||||||
|
await this.sql.end({ timeout: 5 });
|
||||||
|
this.sql = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `1J0 973 702` / `1j0-973-702` → `1J0973702`. Used for both the input code
|
||||||
|
* and JS-side dedupe; the P side is matched against the stored
|
||||||
|
* `code_norm` (built with the identical rule at import time). */
|
||||||
|
private static norm(code: string): string {
|
||||||
|
return code.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch membership test for the schema page: given the OEM codes in a parts
|
||||||
|
* list, return the subset that has at least one OE cross-reference in the
|
||||||
|
* snapshot. The panel links only these (and renders the rest as plain text),
|
||||||
|
* so a click never lands on an empty "no equivalents" detail page. One indexed
|
||||||
|
* query (`code_norm` btree). Fail-open to [] (→ no links) when disabled / on
|
||||||
|
* error, echoing back the caller's original spelling for the matched codes.
|
||||||
|
*/
|
||||||
|
async matchedCodes(rawCodes: string[]): Promise<string[]> {
|
||||||
|
if (!this.enabled || !this.sql || !rawCodes?.length) return [];
|
||||||
|
const rawByNorm = new Map<string, string>();
|
||||||
|
for (const raw of rawCodes) {
|
||||||
|
const n = PSourceDbService.norm(raw ?? "");
|
||||||
|
if (n.length >= PSourceDbService.MIN_NORM_LEN && !rawByNorm.has(n)) rawByNorm.set(n, raw);
|
||||||
|
}
|
||||||
|
if (rawByNorm.size === 0) return [];
|
||||||
|
try {
|
||||||
|
const norms = [...rawByNorm.keys()];
|
||||||
|
const rows = await this.sql<Array<{ code_norm: string }>>`
|
||||||
|
SELECT DISTINCT code_norm FROM article_oe_numbers
|
||||||
|
WHERE code_norm IN ${this.sql(norms)}
|
||||||
|
`;
|
||||||
|
return rows
|
||||||
|
.map((r) => rawByNorm.get(r.code_norm))
|
||||||
|
.filter((c): c is string => c !== undefined);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[p] matchedCodes failed (${rawByNorm.size} codes): ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async lookupByOem(rawCode: string): Promise<POemResult | null> {
|
||||||
|
const query = (rawCode ?? "").trim();
|
||||||
|
const queryNorm = PSourceDbService.norm(query);
|
||||||
|
const miss: POemResult = {
|
||||||
|
query,
|
||||||
|
queryNorm,
|
||||||
|
matched: false,
|
||||||
|
articles: [],
|
||||||
|
aftermarketParts: [],
|
||||||
|
oeCrossReferences: [],
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.enabled || !this.sql) return miss;
|
||||||
|
if (queryNorm.length < PSourceDbService.MIN_NORM_LEN) return miss;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await this.sql<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
brand: string;
|
||||||
|
article_number: string;
|
||||||
|
name: string | null;
|
||||||
|
spare_info: string | null;
|
||||||
|
oe_numbers: POeNumber[];
|
||||||
|
compatible: PCompatible[];
|
||||||
|
images: Array<{ url: string; thumb: string | null }>;
|
||||||
|
}>
|
||||||
|
>`
|
||||||
|
WITH hit AS (
|
||||||
|
SELECT DISTINCT article_id
|
||||||
|
FROM article_oe_numbers
|
||||||
|
WHERE code_norm = ${queryNorm}
|
||||||
|
LIMIT ${PSourceDbService.MAX_ARTICLES}
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
a.id::text AS id,
|
||||||
|
b.name AS brand,
|
||||||
|
-- Snapshot'ta kolonlar şaşırtmacalı: articles.article_number %96
|
||||||
|
-- upstream'in sayısal scrape-ID'si, GERÇEK üretici parça kodu ise
|
||||||
|
-- articles.name kolonunda ("0 092 S40 300", "JTE2311"; 1,1M satırda
|
||||||
|
-- yalnızca 176 istisna). Kullanıcıya kopyalanabilir gerçek kodu servis
|
||||||
|
-- et; name'i ayrıca döndürme (UI'daki ikincil satır zaten kod ile
|
||||||
|
-- aynıysa gizleniyordu).
|
||||||
|
COALESCE(NULLIF(a.name, ''), a.article_number) AS article_number,
|
||||||
|
NULL AS name,
|
||||||
|
a.spare_info AS spare_info,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(json_build_object('brand', o.brand, 'code', o.code))
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT brand, code FROM article_oe_numbers
|
||||||
|
WHERE article_id = a.id ORDER BY brand LIMIT 200
|
||||||
|
) o
|
||||||
|
), '[]') AS oe_numbers,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(json_build_object('brand', c.compatible_brand, 'article', c.compatible_article))
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT compatible_brand, compatible_article FROM article_compatibility
|
||||||
|
WHERE article_id = a.id ORDER BY compatible_brand LIMIT 200
|
||||||
|
) c
|
||||||
|
), '[]') AS compatible,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(json_build_object('url', i.image_url, 'thumb', i.thumb_url) ORDER BY i.sort_order)
|
||||||
|
FROM (
|
||||||
|
-- Only publicly-resolvable URLs. The current snapshot stores
|
||||||
|
-- scrape-local '/_debug/...' paths (404 off-host) — filtering them
|
||||||
|
-- here keeps the API contract honest so the UI shows no broken
|
||||||
|
-- thumbnails; real CDN URLs surface automatically once present.
|
||||||
|
SELECT image_url, thumb_url, sort_order FROM article_images
|
||||||
|
WHERE article_id = a.id AND image_url LIKE 'http%'
|
||||||
|
ORDER BY sort_order LIMIT 8
|
||||||
|
) i
|
||||||
|
), '[]') AS images
|
||||||
|
FROM hit
|
||||||
|
JOIN articles a ON a.id = hit.article_id
|
||||||
|
JOIN article_brands b ON b.id = a.brand_id
|
||||||
|
ORDER BY b.name, COALESCE(NULLIF(a.name, ''), a.article_number)
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (rows.length === 0) return miss;
|
||||||
|
|
||||||
|
let truncated = rows.length >= PSourceDbService.MAX_ARTICLES;
|
||||||
|
|
||||||
|
const articles: PArticle[] = rows.map((r) => {
|
||||||
|
if (r.oe_numbers.length >= 200 || r.compatible.length >= 200) truncated = true;
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
brand: r.brand,
|
||||||
|
articleNumber: r.article_number,
|
||||||
|
name: r.name,
|
||||||
|
spareInfo: r.spare_info,
|
||||||
|
images: r.images,
|
||||||
|
oeNumbers: r.oe_numbers,
|
||||||
|
compatible: r.compatible,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Aggregate: buyable aftermarket part numbers ──────────────────────
|
||||||
|
// The matched articles are themselves aftermarket parts; their
|
||||||
|
// compatibility rows add equivalent numbers from other supplier brands.
|
||||||
|
const afterSeen = new Set<string>();
|
||||||
|
const aftermarketParts: POemResult["aftermarketParts"] = [];
|
||||||
|
const pushAfter = (brand: string, articleNumber: string, thumb: string | null) => {
|
||||||
|
const key = `${brand.toUpperCase().trim()}␟${PSourceDbService.norm(articleNumber)}`;
|
||||||
|
if (afterSeen.has(key) || !articleNumber.trim()) return;
|
||||||
|
afterSeen.add(key);
|
||||||
|
if (aftermarketParts.length < PSourceDbService.MAX_AGG) {
|
||||||
|
aftermarketParts.push({ brand, articleNumber, thumb });
|
||||||
|
} else {
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const a of articles) {
|
||||||
|
pushAfter(a.brand, a.articleNumber, a.images[0]?.thumb ?? a.images[0]?.url ?? null);
|
||||||
|
}
|
||||||
|
for (const a of articles) {
|
||||||
|
for (const c of a.compatible) pushAfter(c.brand, c.article, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aggregate: OE cross-references (same part, other makes) ───────────
|
||||||
|
// Exclude restatements of the queried code itself (same normalised code).
|
||||||
|
const oeSeen = new Set<string>();
|
||||||
|
const oeCrossReferences: POeNumber[] = [];
|
||||||
|
for (const a of articles) {
|
||||||
|
for (const oe of a.oeNumbers) {
|
||||||
|
const codeNorm = PSourceDbService.norm(oe.code);
|
||||||
|
if (codeNorm === queryNorm) continue;
|
||||||
|
const key = `${oe.brand.toUpperCase().trim()}␟${codeNorm}`;
|
||||||
|
if (oeSeen.has(key)) continue;
|
||||||
|
oeSeen.add(key);
|
||||||
|
if (oeCrossReferences.length < PSourceDbService.MAX_AGG) {
|
||||||
|
oeCrossReferences.push(oe);
|
||||||
|
} else {
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
queryNorm,
|
||||||
|
matched: true,
|
||||||
|
// Ship lean articles — the per-article oeNumbers/compatible lists were
|
||||||
|
// only needed above to build the aggregates; sending them too bloated
|
||||||
|
// heavy responses ~30x (370 KB → ~12 KB for a 60-article code).
|
||||||
|
articles: articles.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
brand: a.brand,
|
||||||
|
articleNumber: a.articleNumber,
|
||||||
|
name: a.name,
|
||||||
|
spareInfo: a.spareInfo,
|
||||||
|
images: a.images,
|
||||||
|
})),
|
||||||
|
aftermarketParts,
|
||||||
|
oeCrossReferences,
|
||||||
|
truncated,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[p] lookup failed (oem=${query}): ${(err as Error).message}`);
|
||||||
|
return miss;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
apps/api/src/integrations/p/p.controller.ts
Normal file
28
apps/api/src/integrations/p/p.controller.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
|
||||||
|
import { PSourceDbService } from "./p-source-db.service";
|
||||||
|
|
||||||
|
@Controller("p")
|
||||||
|
export class PController {
|
||||||
|
constructor(private readonly p: PSourceDbService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an OEM code from the catalog to its P equivalents.
|
||||||
|
* `GET /p/oem?code=1J0973702` → { matched, articles, aftermarketParts,
|
||||||
|
* oeCrossReferences }. Always 200 with `matched: false` on any miss.
|
||||||
|
*/
|
||||||
|
@Get("oem")
|
||||||
|
async oem(@Query("code") code: string) {
|
||||||
|
return this.p.lookupByOem(code ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch: which of these OEM codes have a P cross-reference. The schema page
|
||||||
|
* sends the codes it's about to render and links only the returned ones, so
|
||||||
|
* unmatched codes stay plain text and never open an empty detail page.
|
||||||
|
* `POST /p/matched { codes: [...] }` → { matched: [...] }.
|
||||||
|
*/
|
||||||
|
@Post("matched")
|
||||||
|
async matched(@Body("codes") codes: string[]) {
|
||||||
|
return { matched: await this.p.matchedCodes(Array.isArray(codes) ? codes : []) };
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/integrations/p/p.module.ts
Normal file
15
apps/api/src/integrations/p/p.module.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PSourceDbService } from "./p-source-db.service";
|
||||||
|
import { PController } from "./p.controller";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OEM cross-reference lookup against the imported P snapshot (db `td`).
|
||||||
|
* Raw read-only queries — intentionally no Drizzle schema modelling, mirroring
|
||||||
|
* CatalogSourceDbModule. Self-disables when P_DB_* env is unset.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [PController],
|
||||||
|
providers: [PSourceDbService],
|
||||||
|
exports: [PSourceDbService],
|
||||||
|
})
|
||||||
|
export class PModule {}
|
||||||
57
apps/api/src/integrations/p/p.types.ts
Normal file
57
apps/api/src/integrations/p/p.types.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/** An OE (original-equipment) number cross-reference: the same physical part as
|
||||||
|
* catalogued by a vehicle manufacturer (e.g. VAG `1J0 973 702`). */
|
||||||
|
export interface POeNumber {
|
||||||
|
brand: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An aftermarket equivalent: a buyable part number from a supplier brand
|
||||||
|
* (e.g. FEBI BILSTEIN `171903`). */
|
||||||
|
export interface PCompatible {
|
||||||
|
brand: string;
|
||||||
|
article: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PImage {
|
||||||
|
url: string;
|
||||||
|
thumb: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One P article whose OE number list contains the queried OEM code. Internal
|
||||||
|
* shape — `oeNumbers`/`compatible` feed the aggregates and are NOT shipped to
|
||||||
|
* the client (they're large and unused there; see PArticleSummary). */
|
||||||
|
export interface PArticle {
|
||||||
|
id: string;
|
||||||
|
brand: string;
|
||||||
|
articleNumber: string;
|
||||||
|
name: string | null;
|
||||||
|
spareInfo: string | null;
|
||||||
|
images: PImage[];
|
||||||
|
oeNumbers: POeNumber[];
|
||||||
|
compatible: PCompatible[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the OEM detail page actually renders per matched article — without the
|
||||||
|
* heavy per-article OE/compat lists (those are deduped into the top-level
|
||||||
|
* aggregates). Trimming them cut a 60-article payload from ~370 KB to ~12 KB. */
|
||||||
|
export type PArticleSummary = Omit<PArticle, "oeNumbers" | "compatible">;
|
||||||
|
|
||||||
|
/** Response of the OEM detail lookup. `matched: false` covers every miss —
|
||||||
|
* feature disabled, code too short, or no P article carries that OE
|
||||||
|
* number — so the UI has a single empty-state path. */
|
||||||
|
export interface POemResult {
|
||||||
|
query: string;
|
||||||
|
queryNorm: string;
|
||||||
|
matched: boolean;
|
||||||
|
/** Distinct articles whose OE list contains the queried code (lean — per-article
|
||||||
|
* OE/compat lists are aggregated below, not duplicated here). */
|
||||||
|
articles: PArticleSummary[];
|
||||||
|
/** Deduped buyable aftermarket part numbers across all matched articles
|
||||||
|
* (the matched articles themselves + their compatibility entries). */
|
||||||
|
aftermarketParts: Array<{ brand: string; articleNumber: string; thumb: string | null }>;
|
||||||
|
/** Deduped OE cross-references across all matched articles, excluding the
|
||||||
|
* queried code itself — i.e. the same part's numbers under other makes. */
|
||||||
|
oeCrossReferences: POeNumber[];
|
||||||
|
/** True when any per-article list or the article set hit its cap. */
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Parts-Catalogs capture asset cache.
|
||||||
|
*
|
||||||
|
* Every JWT capture opens a fresh browser context (empty HTTP cache) and
|
||||||
|
* re-downloads the SAME static widget assets through the billed residential
|
||||||
|
* proxy — measured ~2.2 MB/capture, ~93% of it the parts-catalogs widget
|
||||||
|
* bundle (`.../v3/bundle_<hash>.js`, identical across all partner sites) plus
|
||||||
|
* the sites' own versioned JS/CSS. At ~3–4k captures/day that is the single
|
||||||
|
* largest avoidable proxy cost (~20 GB/15d) with zero ban risk: the widget
|
||||||
|
* still runs (served from here) and still fires its token XHR live.
|
||||||
|
*
|
||||||
|
* This is a URL-keyed byte cache with a hot in-memory layer and a best-effort
|
||||||
|
* disk layer (survives redeploys). Only versioned static assets are cached —
|
||||||
|
* their URLs carry a content hash / `?_=<ver>` so a bundle bump is a new URL
|
||||||
|
* (cache miss → refetched once), i.e. invalidation is automatic. The token XHR
|
||||||
|
* (`/v3/api/proxy/*`) and HTML documents are never cached; they stay live.
|
||||||
|
*/
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export interface CachedAsset {
|
||||||
|
contentType: string;
|
||||||
|
body: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PcatAssetCache {
|
||||||
|
private readonly dir: string;
|
||||||
|
private readonly mem = new Map<string, CachedAsset>();
|
||||||
|
private readonly maxMemEntries: number;
|
||||||
|
private readonly ready: Promise<void>;
|
||||||
|
|
||||||
|
constructor(opts: { dir?: string; maxMemEntries?: number } = {}) {
|
||||||
|
this.dir = opts.dir ?? join(tmpdir(), "pcat-asset-cache");
|
||||||
|
this.maxMemEntries = opts.maxMemEntries ?? 300;
|
||||||
|
this.ready = mkdir(this.dir, { recursive: true }).then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only versioned static JS/CSS is cacheable. Never the token/API path, never
|
||||||
|
* HTML documents (may carry a per-session nonce the token call needs).
|
||||||
|
*/
|
||||||
|
static isCacheable(resourceType: string, url: string): boolean {
|
||||||
|
if (resourceType !== "script" && resourceType !== "stylesheet") return false;
|
||||||
|
if (url.includes("/v3/api/proxy/")) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fileKey(url: string): string {
|
||||||
|
return createHash("sha1").update(url).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(url: string): Promise<CachedAsset | null> {
|
||||||
|
const hot = this.mem.get(url);
|
||||||
|
if (hot) return hot;
|
||||||
|
await this.ready;
|
||||||
|
const key = this.fileKey(url);
|
||||||
|
try {
|
||||||
|
const [meta, body] = await Promise.all([
|
||||||
|
readFile(join(this.dir, `${key}.json`), "utf8"),
|
||||||
|
readFile(join(this.dir, `${key}.bin`)),
|
||||||
|
]);
|
||||||
|
const asset: CachedAsset = { contentType: JSON.parse(meta).contentType, body };
|
||||||
|
this.remember(url, asset);
|
||||||
|
return asset;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(url: string, contentType: string, body: Buffer): Promise<void> {
|
||||||
|
if (!body.length) return;
|
||||||
|
this.remember(url, { contentType, body });
|
||||||
|
await this.ready;
|
||||||
|
const key = this.fileKey(url);
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(join(this.dir, `${key}.bin`), body),
|
||||||
|
writeFile(join(this.dir, `${key}.json`), JSON.stringify({ contentType, url })),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
// Disk is best-effort; the in-memory layer still serves this process.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private remember(url: string, asset: CachedAsset): void {
|
||||||
|
// Cheap FIFO bound — versioned URLs keep the working set small anyway.
|
||||||
|
if (this.mem.size >= this.maxMemEntries) {
|
||||||
|
const oldest = this.mem.keys().next().value;
|
||||||
|
if (oldest !== undefined) this.mem.delete(oldest);
|
||||||
|
}
|
||||||
|
this.mem.set(url, asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
|
||||||
|
import type { JwtSlot } from "./parts-catalogs.types";
|
||||||
|
|
||||||
|
function makeService(env: Record<string, string> = {}) {
|
||||||
|
const config = {
|
||||||
|
get: <T>(key: string, def?: T) => (key in env ? (env[key] as unknown as T) : (def as T)),
|
||||||
|
} as unknown as ConfigService;
|
||||||
|
const redis = {} as never;
|
||||||
|
const telemetry = { record: vi.fn() } as never;
|
||||||
|
const health = {
|
||||||
|
isFloxyDown: () => false,
|
||||||
|
reportFloxyFailure: vi.fn(),
|
||||||
|
reportFloxySuccess: vi.fn(),
|
||||||
|
} as never;
|
||||||
|
return new PartsCatalogsAuthService(config, redis, telemetry, health);
|
||||||
|
}
|
||||||
|
|
||||||
|
function slot(capturedAt: number): JwtSlot {
|
||||||
|
return {
|
||||||
|
jwt: {
|
||||||
|
raw: `TWS-${capturedAt}`,
|
||||||
|
exp: 0,
|
||||||
|
apiPath: "",
|
||||||
|
guiVersion: "3",
|
||||||
|
userId: "",
|
||||||
|
origin: "",
|
||||||
|
referer: "",
|
||||||
|
},
|
||||||
|
siteUsed: "https://x.test/#/catalogs",
|
||||||
|
capturedAt,
|
||||||
|
refreshTimer: null,
|
||||||
|
failCount: 0,
|
||||||
|
} as JwtSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PartsCatalogsAuthService", () => {
|
||||||
|
let svc: PartsCatalogsAuthService;
|
||||||
|
beforeEach(() => {
|
||||||
|
svc = makeService();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("captureAllowDirect (env)", () => {
|
||||||
|
it("defaults on when a proxy provider is active", () => {
|
||||||
|
expect(makeService()["captureAllowDirect"]).toBe(true);
|
||||||
|
expect(makeService({ PCAT_PROXY_PROVIDER: "dataimpulse" })["captureAllowDirect"]).toBe(true);
|
||||||
|
});
|
||||||
|
it("off when explicitly disabled", () => {
|
||||||
|
expect(makeService({ PCAT_CAPTURE_ALLOW_DIRECT: "false" })["captureAllowDirect"]).toBe(false);
|
||||||
|
});
|
||||||
|
it("off when there is no proxy to fall back from (provider none)", () => {
|
||||||
|
expect(makeService({ PCAT_USE_PROXY: "false" })["captureAllowDirect"]).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("enforcePoolCap", () => {
|
||||||
|
it("trims to MAX_POOL_SIZE keeping the freshest slots", () => {
|
||||||
|
// 9 slots, capturedAt 1..9 (9 newest). Cap is 5.
|
||||||
|
svc["pool"] = [3, 1, 8, 2, 9, 5, 4, 7, 6].map(slot);
|
||||||
|
svc["enforcePoolCap"]();
|
||||||
|
const kept = svc["pool"].map((s) => s.capturedAt).sort((a, b) => a - b);
|
||||||
|
expect(svc["pool"].length).toBe(5);
|
||||||
|
expect(kept).toEqual([5, 6, 7, 8, 9]); // 5 newest survive
|
||||||
|
});
|
||||||
|
it("is a no-op at or under the cap", () => {
|
||||||
|
svc["pool"] = [1, 2, 3].map(slot);
|
||||||
|
svc["enforcePoolCap"]();
|
||||||
|
expect(svc["pool"].length).toBe(3);
|
||||||
|
});
|
||||||
|
it("clears refresh timers on evicted slots", () => {
|
||||||
|
const all = [1, 2, 3, 4, 5, 6, 7].map((c) => {
|
||||||
|
const s = slot(c);
|
||||||
|
s.refreshTimer = setTimeout(() => {}, 60_000);
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
const [oldest1, oldest2] = all; // capturedAt 1 and 2 — the two evicted
|
||||||
|
svc["pool"] = [...all];
|
||||||
|
svc["enforcePoolCap"]();
|
||||||
|
expect(oldest1.refreshTimer).toBeNull();
|
||||||
|
expect(oldest2.refreshTimer).toBeNull();
|
||||||
|
// Survivors keep their timers.
|
||||||
|
expect(svc["pool"].every((s) => s.refreshTimer !== null)).toBe(true);
|
||||||
|
for (const s of svc["pool"]) if (s.refreshTimer) clearTimeout(s.refreshTimer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Parts-Catalogs Auth Service — v3 token warm pool via Playwright + DataImpulse proxy
|
* Parts-Catalogs Auth Service — v3 token warm pool via Playwright + rotating proxy
|
||||||
*
|
*
|
||||||
* Tokens are captured by navigating to partner sites that embed the v3 widget.
|
* Tokens are captured by navigating to partner sites that embed the v3 widget.
|
||||||
* The widget calls /v3/api/proxy/* with `x-api-key: TWS-{UUID}` and four other
|
* The widget calls /v3/api/proxy/* with `x-api-key: TWS-{UUID}` and four other
|
||||||
* X-* headers (api-path, gui-version, user-id, origin, referer); we intercept
|
* X-* headers (api-path, gui-version, user-id, origin, referer); we intercept
|
||||||
* all of them so backend requests can replay the exact header set.
|
* all of them so backend requests can replay the exact header set.
|
||||||
*
|
*
|
||||||
* Tokens are IP-bound — same proxy port must be reused for the API calls.
|
* Transport (PCAT_PROXY_PROVIDER): "floxy" residential (default) rotates a fresh
|
||||||
|
* exit IP per call (the token is NOT IP-bound — see parts-catalogs.types.ts — so
|
||||||
|
* max IP diversity = min per-IP ban), with a sticky IP only per Playwright
|
||||||
|
* capture; "dataimpulse" is the legacy port-bound datacenter proxy; "none" calls
|
||||||
|
* direct from the server IP.
|
||||||
*
|
*
|
||||||
* Warm pool behavior:
|
* Warm pool behavior:
|
||||||
* 24/7 → proactive: maintain >= 1 slot, auto-refresh ~9.5min before expiry.
|
* 24/7 → proactive: maintain >= 1 slot, auto-refresh ~9.5min before expiry.
|
||||||
@@ -19,29 +23,40 @@ import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@ne
|
|||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Browser, BrowserContext } from "playwright";
|
import { Browser, BrowserContext } from "playwright";
|
||||||
import { RedisService } from "../../redis/redis.service";
|
import { RedisService } from "../../redis/redis.service";
|
||||||
|
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
|
||||||
|
import {
|
||||||
|
ProxyTelemetryService,
|
||||||
|
classifyTransportError,
|
||||||
|
isProxyConnectFailure,
|
||||||
|
resolveExitIp,
|
||||||
|
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||||
|
import { PcatAssetCache } from "./parts-catalogs-asset-cache";
|
||||||
import { JwtSlot, PcatJwtToken, PcatSession } from "./parts-catalogs.types";
|
import { JwtSlot, PcatJwtToken, PcatSession } from "./parts-catalogs.types";
|
||||||
|
|
||||||
// Shared single-slot cache so dev + prod (and any restarted container) can
|
// Shared single-slot cache so dev + prod (and any restarted container) can
|
||||||
// inherit a freshly-captured warm slot instead of paying the Playwright
|
// inherit a freshly-captured warm token instead of paying the Playwright
|
||||||
// capture cost from cold. Token is still IP-bound to its proxyPort; if a
|
// capture cost from cold. The token is portable (not IP-bound), so any
|
||||||
// reader on a different IP gets 401/403, invalidateSession's existing
|
// container can serve it through its own rotating call-leg IPs; if it's dead
|
||||||
// fallback re-captures locally. So worst case = today's behavior.
|
// at the source, invalidateSession drops it and the next read re-captures.
|
||||||
const REDIS_SLOT_KEY = "pcat:jwt:slot";
|
const REDIS_SLOT_KEY = "pcat:jwt:slot";
|
||||||
const REDIS_SAFETY_BUFFER_S = 60; // don't serve from Redis if <60s of life left
|
const REDIS_SAFETY_BUFFER_S = 60; // don't serve from Redis if <60s of life left
|
||||||
|
|
||||||
const TOKEN_TTL = 600; // seconds — TWS- has no built-in expiry, refresh aggressively
|
const TOKEN_TTL = 600; // seconds — TWS- has no built-in expiry, refresh aggressively
|
||||||
const REFRESH_BUFFER = 90; // Refresh 90s before expiry
|
const REFRESH_BUFFER = 90; // Refresh 90s before expiry
|
||||||
const CAPTURE_POLL_INTERVAL = 500; // ms
|
const CAPTURE_POLL_INTERVAL = 500; // ms
|
||||||
// Two-armed post-goto poll: a healthy goto means the widget JS already loaded
|
// Post-goto poll. We now navigate with `domcontentloaded` (reliable, doesn't
|
||||||
// and the API call typically fires within ~1-2s — short cap is enough. A
|
// depend on networkidle settling on JS-heavy catalog sites), then poll for the
|
||||||
// failed goto means we lost the page, but the widget request may still have
|
// widget's token call. Measured through Floxy residential 2026-06-11: DOM ready
|
||||||
// gone through before the navigation timeout — give a brief grace window
|
// ~7s, the /v3/api/proxy token call fires ~11.8s (≈5s after DOM). The poll must
|
||||||
// and bail. Old single-armed 20s cap was the dominant cost on failed-site
|
// comfortably cover that gap, so 12s — too short and every capture misses the
|
||||||
// attempts (e.g. 10s page timeout + 20s blind poll = 30s wasted).
|
// token (seen at the old 5s cap: "No token after 12s", whole pool starved).
|
||||||
const CAPTURE_POLL_AFTER_OK = 10; // 10 × 500ms = 5s after a healthy goto
|
const CAPTURE_POLL_AFTER_OK = Number(process.env.PCAT_CAPTURE_POLL_OK) || 24; // 24 × 500ms = 12s
|
||||||
const CAPTURE_POLL_AFTER_FAIL = 4; // 4 × 500ms = 2s grace after goto failure
|
const CAPTURE_POLL_AFTER_FAIL = 8; // 8 × 500ms = 4s grace after goto failure
|
||||||
const PAGE_TIMEOUT = 10_000; // 10s — healthy partner sites load in <5s through
|
// 25s — residential exit IPs are slow; the token call fired at ~11.8s in the
|
||||||
// the proxy; a longer wait only prolongs dead-port connects in capture retries.
|
// 2026-06-11 measurement, so 10s timed out before it. Capture is background
|
||||||
|
// (warm pool, never blocks a user with cold-pool fast-fail), so a longer cap is
|
||||||
|
// cheap insurance against residential variance. Env-tunable for future rotation.
|
||||||
|
const PAGE_TIMEOUT = Number(process.env.PCAT_PAGE_TIMEOUT_MS) || 25_000;
|
||||||
const CONTEXT_CLOSE_TIMEOUT = 5_000;
|
const CONTEXT_CLOSE_TIMEOUT = 5_000;
|
||||||
const SITE_COOLDOWN = 10 * 60 * 1000; // 10 min per site
|
const SITE_COOLDOWN = 10 * 60 * 1000; // 10 min per site
|
||||||
const MAX_POOL_SIZE = 5;
|
const MAX_POOL_SIZE = 5;
|
||||||
@@ -62,30 +77,48 @@ const RPM_PER_SLOT = 6; // 1 token per 6 req/min
|
|||||||
* Each site uses a different proxy port (IP) to avoid rate limiting.
|
* Each site uses a different proxy port (IP) to avoid rate limiting.
|
||||||
*/
|
*/
|
||||||
// Order matters: round-robin starts from index 0, so the first site is the one
|
// Order matters: round-robin starts from index 0, so the first site is the one
|
||||||
// a cold pool waits on. Sites that consistently load in <10s through the proxy
|
// a cold pool waits on. Sites that consistently load fast stay at the top so
|
||||||
// stay at the top so initial JWT capture finishes in seconds.
|
// initial JWT capture finishes in seconds.
|
||||||
// (e-acca.com was previously included last but removed — the current DataImpulse
|
// Pruned 2026-06-10 to the 6 sites that actually yielded captures on prod —
|
||||||
// rotating proxy 74.81.81.81:10000-10999 cannot reach it; page.goto always
|
// knkauto.ru / autodo.kz / flynestauto.com produced 0 captures yet consumed
|
||||||
// blocked until PAGE_TIMEOUT instead of failing fast like the other sites.)
|
// ~80% of capture attempts (the per-site cooldown kept forcing the retry loop
|
||||||
const JWT_SITES = [
|
// onto them). Override the list at runtime with PCAT_JWT_SITES (comma-separated)
|
||||||
|
// without a deploy, e.g. to give dev a disjoint subset.
|
||||||
|
const DEFAULT_JWT_SITES = [
|
||||||
"https://www.alkatalog.com/cats/#/catalogs",
|
"https://www.alkatalog.com/cats/#/catalogs",
|
||||||
"https://auto-komplekt.ru/goodvin#/catalogs",
|
"https://auto-komplekt.ru/goodvin#/catalogs",
|
||||||
"https://www.autotrade.md/cats/#/catalogs",
|
"https://www.autotrade.md/cats/#/catalogs",
|
||||||
"https://www.e-trak.ru/cats/#/catalogs",
|
"https://www.e-trak.ru/cats/#/catalogs",
|
||||||
"https://www.autopolyus.ru/cats/#/catalogs",
|
"https://www.autopolyus.ru/cats/#/catalogs",
|
||||||
"https://knkauto.ru/goodvin#/catalogs",
|
|
||||||
"https://www.autodo.kz/#/catalogs",
|
|
||||||
"https://avtoman124.ru/goodvin#/catalogs",
|
"https://avtoman124.ru/goodvin#/catalogs",
|
||||||
"https://flynestauto.com/auto-parts-oem-catalog",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// DataImpulse proxy defaults (port-based IP rotation)
|
// DataImpulse proxy defaults (port-based IP rotation) — legacy. Half its ports
|
||||||
|
// were dead at any moment (measured 50% connect-timeout), which drove the bulk of
|
||||||
|
// pcat decode latency + failures. Kept selectable via PCAT_PROXY_PROVIDER for
|
||||||
|
// instant rollback, but Floxy is the default.
|
||||||
const DI_HOST = "gw.dataimpulse.com";
|
const DI_HOST = "gw.dataimpulse.com";
|
||||||
const DI_PORT_MIN = 10000;
|
const DI_PORT_MIN = 10000;
|
||||||
const DI_PORT_MAX = 10999;
|
const DI_PORT_MAX = 10999;
|
||||||
const DI_DEFAULT_USER = "1726bbe361918676d44e";
|
const DI_DEFAULT_USER = "1726bbe361918676d44e";
|
||||||
const DI_DEFAULT_PASS = "78ebc3d881de6ec0";
|
const DI_DEFAULT_PASS = "78ebc3d881de6ec0";
|
||||||
|
|
||||||
|
// Floxy residential defaults (session-based IP rotation; same account EMEX uses).
|
||||||
|
// Call leg → plain creds = a fresh residential exit IP per request (max IP
|
||||||
|
// diversity → minimal per-IP ban exposure, since each IP sees ~1 request).
|
||||||
|
// Capture leg → a sticky session id pins one IP for the duration of a single
|
||||||
|
// Playwright capture so the partner site sees a coherent session.
|
||||||
|
const FLOXY_HOST = "residential.floxy.io";
|
||||||
|
const FLOXY_PORT = 12321;
|
||||||
|
// Floxy account creds (overridable via PCAT_FLOXY_USER/PASS). Rotated 2026-06-11
|
||||||
|
// after the prior account (d739255e819b) hit HTTP 402 (balance exhausted).
|
||||||
|
const FLOXY_DEFAULT_USER = "cac4b0d96a80";
|
||||||
|
const FLOXY_DEFAULT_PASS = "dfc38fe467a3";
|
||||||
|
const FLOXY_CAPTURE_LIFETIME_S = 60; // sticky window for one capture attempt
|
||||||
|
|
||||||
|
type ProxyProvider = "floxy" | "dataimpulse" | "none";
|
||||||
|
type ProxyLeg = "call" | "capture";
|
||||||
|
|
||||||
/** Simple counting semaphore (same pattern as EmexBrowserService) */
|
/** Simple counting semaphore (same pattern as EmexBrowserService) */
|
||||||
class Semaphore {
|
class Semaphore {
|
||||||
private current = 0;
|
private current = 0;
|
||||||
@@ -119,18 +152,20 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
private browser: Browser | null = null;
|
private browser: Browser | null = null;
|
||||||
private launching: Promise<void> | null = null;
|
private launching: Promise<void> | null = null;
|
||||||
// Concurrent captures capped at JWT_SITES.length — the real bottleneck is
|
// Active JWT capture sites (PCAT_JWT_SITES override or DEFAULT_JWT_SITES),
|
||||||
// how many distinct partner sites we can drive in parallel (one capture per
|
// resolved in the constructor.
|
||||||
// site, since each site = a different IP / cookie origin). Ports are
|
private readonly jwtSites: string[];
|
||||||
// ~unlimited (10000-10999) and Playwright contexts are isolated; the
|
// Concurrent captures capped at jwtSites.length — the real bottleneck is how
|
||||||
// synchronous siteIndex round-robin hands each concurrent capture a
|
// many distinct partner sites we can drive in parallel (one capture per site,
|
||||||
// different site so they don't collide.
|
// since each site = a different cookie origin). Playwright contexts are
|
||||||
private readonly semaphore = new Semaphore(JWT_SITES.length);
|
// isolated; the synchronous siteIndex round-robin hands each concurrent
|
||||||
|
// capture a different site so they don't collide. Initialized in constructor.
|
||||||
|
private readonly semaphore: Semaphore;
|
||||||
|
|
||||||
// Pool state
|
// Pool state
|
||||||
private pool: JwtSlot[] = [];
|
private pool: JwtSlot[] = [];
|
||||||
private siteLastUsedAt = new Map<string, number>();
|
private siteLastUsedAt = new Map<string, number>();
|
||||||
private siteIndex = 0; // round-robin across JWT_SITES
|
private siteIndex = 0; // round-robin across jwtSites
|
||||||
private requestRoundRobin = 0; // round-robin across pool slots
|
private requestRoundRobin = 0; // round-robin across pool slots
|
||||||
|
|
||||||
// Business hours scheduling
|
// Business hours scheduling
|
||||||
@@ -139,20 +174,156 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// RPM tracking
|
// RPM tracking
|
||||||
private requestTimestamps: number[] = [];
|
private requestTimestamps: number[] = [];
|
||||||
|
|
||||||
// Config
|
// Transport config — see ProxyProvider. The TWS- token is NOT request-time
|
||||||
private readonly useProxy: boolean;
|
// IP-bound (parts-catalogs.types.ts), so the call leg rotates exit IPs per
|
||||||
private readonly proxyHost: string;
|
// request and only the capture leg holds a sticky IP per attempt.
|
||||||
private readonly proxyUser: string;
|
private readonly proxyProvider: ProxyProvider;
|
||||||
private readonly proxyPass: string;
|
// Floxy residential
|
||||||
|
private readonly floxyHost: string;
|
||||||
|
private readonly floxyPort: number;
|
||||||
|
private readonly floxyUser: string;
|
||||||
|
private readonly floxyPass: string;
|
||||||
|
private readonly floxyCaptureLifetime: number;
|
||||||
|
// DataImpulse legacy (port-rotated)
|
||||||
|
private readonly diHost: string;
|
||||||
|
private readonly diUser: string;
|
||||||
|
private readonly diPass: string;
|
||||||
|
// When a proxied capture dies on a tunnel/connect failure (residential balance
|
||||||
|
// exhausted, DataImpulse ports dead, both providers down), retry that capture
|
||||||
|
// DIRECT from the server IP instead of starving the warm pool. Safe because
|
||||||
|
// the TWS- token is not IP-bound and capture is low-frequency (≤ MAX_POOL_SIZE
|
||||||
|
// refreshes / ~8.5min). This is the survival path for a full proxy outage —
|
||||||
|
// the exact failure seen 2026-06-11 (Floxy 402) and 2026-07-03 (Floxy tunnel
|
||||||
|
// down). Disable with PCAT_CAPTURE_ALLOW_DIRECT=false.
|
||||||
|
private readonly captureAllowDirect: boolean;
|
||||||
|
// Exit-IP telemetry probe (one ipify call per capture). Off by default: it
|
||||||
|
// added ~54k billed proxy requests over 15 days for banned-IP telemetry only.
|
||||||
|
private readonly exitIpProbe: boolean;
|
||||||
|
// Cross-capture cache for the widget's static JS/CSS so each capture stops
|
||||||
|
// re-downloading ~2 MB of identical assets through the residential proxy.
|
||||||
|
// Null when disabled via PCAT_ASSET_CACHE=false (kill switch, no redeploy).
|
||||||
|
private readonly assetCache: PcatAssetCache | null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
|
private readonly proxyTelemetry: ProxyTelemetryService,
|
||||||
|
private readonly proxyHealth: ProxyHealthService,
|
||||||
) {
|
) {
|
||||||
this.useProxy = this.configService.get<string>("PCAT_USE_PROXY", "true") === "true";
|
const cfg = this.configService;
|
||||||
this.proxyHost = this.configService.get<string>("PCAT_PROXY_HOST", DI_HOST);
|
|
||||||
this.proxyUser = this.configService.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
|
// Active capture sites: PCAT_JWT_SITES override (comma-separated) or the
|
||||||
this.proxyPass = this.configService.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS);
|
// pruned default. Empty/blank entries are dropped; falls back to default if
|
||||||
|
// the override resolves to nothing.
|
||||||
|
const sitesRaw = cfg.get<string>("PCAT_JWT_SITES", "");
|
||||||
|
const parsedSites = sitesRaw
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
this.jwtSites = parsedSites.length > 0 ? parsedSites : DEFAULT_JWT_SITES;
|
||||||
|
this.semaphore = new Semaphore(this.jwtSites.length);
|
||||||
|
|
||||||
|
// Provider selection. Back-compat: PCAT_USE_PROXY=false still forces direct.
|
||||||
|
// Default is dataimpulse: Floxy was retired 2026-07 (permanently down —
|
||||||
|
// balance/tunnel dead), so floxy-primary just burned a failed attempt per
|
||||||
|
// call before failing over. Set PCAT_PROXY_PROVIDER=floxy to re-enable it.
|
||||||
|
const rawProvider = cfg.get<string>("PCAT_PROXY_PROVIDER", "dataimpulse").toLowerCase();
|
||||||
|
const useProxy = cfg.get<string>("PCAT_USE_PROXY", "true") === "true";
|
||||||
|
this.proxyProvider = !useProxy
|
||||||
|
? "none"
|
||||||
|
: rawProvider === "floxy" || rawProvider === "none"
|
||||||
|
? (rawProvider as ProxyProvider)
|
||||||
|
: "dataimpulse";
|
||||||
|
|
||||||
|
const toInt = (key: string, def: number): number => {
|
||||||
|
const n = Number(cfg.get(key, def));
|
||||||
|
return Number.isInteger(n) && n >= 0 ? n : def;
|
||||||
|
};
|
||||||
|
const toPort = (key: string, def: number): number => {
|
||||||
|
const n = Number(cfg.get(key, def));
|
||||||
|
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : def;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.floxyHost = cfg.get<string>("PCAT_FLOXY_HOST", FLOXY_HOST);
|
||||||
|
this.floxyPort = toPort("PCAT_FLOXY_PORT", FLOXY_PORT);
|
||||||
|
this.floxyUser = cfg.get<string>("PCAT_FLOXY_USER", FLOXY_DEFAULT_USER);
|
||||||
|
this.floxyPass = cfg.get<string>("PCAT_FLOXY_PASS", FLOXY_DEFAULT_PASS);
|
||||||
|
this.floxyCaptureLifetime = toInt("PCAT_FLOXY_CAPTURE_LIFETIME", FLOXY_CAPTURE_LIFETIME_S);
|
||||||
|
|
||||||
|
this.diHost = cfg.get<string>("PCAT_PROXY_HOST", DI_HOST);
|
||||||
|
this.diUser = cfg.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
|
||||||
|
this.diPass = cfg.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS);
|
||||||
|
this.exitIpProbe = cfg.get<string>("PCAT_EXIT_IP_PROBE", "false") === "true";
|
||||||
|
this.assetCache =
|
||||||
|
cfg.get<string>("PCAT_ASSET_CACHE", "true") === "true" ? new PcatAssetCache() : null;
|
||||||
|
|
||||||
|
this.captureAllowDirect =
|
||||||
|
cfg.get<string>("PCAT_CAPTURE_ALLOW_DIRECT", "true") !== "false" &&
|
||||||
|
this.proxyProvider !== "none";
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
this.proxyProvider === "floxy"
|
||||||
|
? `PCAT transport: floxy residential ${this.floxyHost}:${this.floxyPort} (call=rotating, capture=sticky ${this.floxyCaptureLifetime}s)`
|
||||||
|
: this.proxyProvider === "dataimpulse"
|
||||||
|
? `PCAT transport: dataimpulse ${this.diHost}:${DI_PORT_MIN}-${DI_PORT_MAX}`
|
||||||
|
: "PCAT transport: direct (no proxy)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a proxy for one leg. "call" rotates the exit IP per invocation (the
|
||||||
|
* token is not IP-bound, so each decode/scrape/validate request gets a fresh
|
||||||
|
* residential IP → max IP diversity, min ban). "capture" pins a sticky IP for
|
||||||
|
* the lifetime of a single Playwright capture so the partner site sees a
|
||||||
|
* coherent session. Returns nulls when provider is "none" (direct).
|
||||||
|
*/
|
||||||
|
private buildProxy(leg: ProxyLeg): {
|
||||||
|
proxyUrl: string | null;
|
||||||
|
proxyConfig: { server: string; username: string; password: string } | null;
|
||||||
|
// Telemetry identity: sticky Floxy session id or DataImpulse port. Null on
|
||||||
|
// rotating legs (exit IP changes per request — nothing stable to attribute).
|
||||||
|
sessionKey: string | null;
|
||||||
|
// The provider actually used for this leg (may differ from the configured
|
||||||
|
// provider when Floxy is in failover). Drives accurate telemetry + which
|
||||||
|
// provider's health to report success/failure against.
|
||||||
|
provider: ProxyProvider;
|
||||||
|
} {
|
||||||
|
if (this.proxyProvider === "none")
|
||||||
|
return { proxyUrl: null, proxyConfig: null, sessionKey: null, provider: "none" };
|
||||||
|
|
||||||
|
// Floxy-primary but the shared health gate is tripped (Floxy down — balance
|
||||||
|
// or tunnel) → fail over to the DataImpulse datacenter pool for this leg so
|
||||||
|
// captures/calls keep landing instead of dying on a dead residential proxy.
|
||||||
|
const useDataImpulse =
|
||||||
|
this.proxyProvider === "dataimpulse" ||
|
||||||
|
(this.proxyProvider === "floxy" && this.proxyHealth.isFloxyDown());
|
||||||
|
|
||||||
|
if (useDataImpulse) {
|
||||||
|
const port = this.allocatePort();
|
||||||
|
const server = `http://${this.diHost}:${port}`;
|
||||||
|
return {
|
||||||
|
proxyUrl: `http://${this.diUser}:${this.diPass}@${this.diHost}:${port}`,
|
||||||
|
proxyConfig: { server, username: this.diUser, password: this.diPass },
|
||||||
|
sessionKey: `di:${port}`,
|
||||||
|
provider: "dataimpulse",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// floxy
|
||||||
|
let password = this.floxyPass;
|
||||||
|
let sessionKey: string | null = null;
|
||||||
|
if (leg === "capture" && this.floxyCaptureLifetime > 0) {
|
||||||
|
const sid = Math.random().toString(36).slice(2, 10);
|
||||||
|
password = `${this.floxyPass}_session-${sid}_lifetime-${this.floxyCaptureLifetime}`;
|
||||||
|
sessionKey = `s-${sid}`;
|
||||||
|
}
|
||||||
|
const server = `http://${this.floxyHost}:${this.floxyPort}`;
|
||||||
|
return {
|
||||||
|
proxyUrl: `http://${this.floxyUser}:${password}@${this.floxyHost}:${this.floxyPort}`,
|
||||||
|
proxyConfig: { server, username: this.floxyUser, password },
|
||||||
|
sessionKey,
|
||||||
|
provider: "floxy",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
@@ -193,10 +364,14 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// ─── Public API ───────────────────────────────────────────
|
// ─── Public API ───────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Acquire a matched JWT + proxy session from the pool.
|
* Acquire a JWT session from the pool.
|
||||||
* Returns a valid slot (round-robin) or captures on-demand if pool is empty.
|
* Returns a valid slot (round-robin) or, if the pool is empty, captures
|
||||||
|
* on-demand. With `blockOnEmpty: false` it does NOT block on a cold pool —
|
||||||
|
* it kicks off a background warm-up and returns null so a latency-sensitive
|
||||||
|
* caller (the decode race) can let a faster source answer instead of eating
|
||||||
|
* the multi-second capture cost inline.
|
||||||
*/
|
*/
|
||||||
async acquireSession(): Promise<PcatSession> {
|
async acquireSession(opts?: { blockOnEmpty?: boolean }): Promise<PcatSession | null> {
|
||||||
this.trackRequest();
|
this.trackRequest();
|
||||||
|
|
||||||
// Try to find a valid slot in the pool
|
// Try to find a valid slot in the pool
|
||||||
@@ -207,14 +382,26 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return this.slotToSession(slot);
|
return this.slotToSession(slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No valid slot — capture on-demand
|
// No valid slot. Latency-sensitive callers fast-fail and warm in background.
|
||||||
|
if (opts?.blockOnEmpty === false) {
|
||||||
|
this.logger.log("JWT pool empty — fast-fail (warming in background)");
|
||||||
|
this.captureToPool().catch((err) => {
|
||||||
|
this.logger.warn(`Background warm capture failed: ${(err as Error).message}`);
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture on-demand (blocks the caller).
|
||||||
this.logger.log("JWT pool empty — capturing on-demand...");
|
this.logger.log("JWT pool empty — capturing on-demand...");
|
||||||
const newSlot = await this.captureToPool();
|
const newSlot = await this.captureToPool();
|
||||||
return this.slotToSession(newSlot);
|
return this.slotToSession(newSlot);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidate a session after 401/403, remove the slot, and capture a replacement.
|
* Invalidate a session (401/403, or N consecutive transport failures on
|
||||||
|
* rotating IPs = a token that's dead at the source), remove the slot, and
|
||||||
|
* capture a replacement only if the pool is now empty — a surviving sibling
|
||||||
|
* plus the scheduled refresh/scale-up covers the rest without a capture storm.
|
||||||
*/
|
*/
|
||||||
async invalidateSession(session: PcatSession): Promise<void> {
|
async invalidateSession(session: PcatSession): Promise<void> {
|
||||||
const idx = this.pool.indexOf(session._slot);
|
const idx = this.pool.indexOf(session._slot);
|
||||||
@@ -222,17 +409,18 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.clearSlotTimer(this.pool[idx]);
|
this.clearSlotTimer(this.pool[idx]);
|
||||||
this.pool.splice(idx, 1);
|
this.pool.splice(idx, 1);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`JWT pool: slot invalidated (port ${session._slot.proxyPort}), ${this.pool.length} remaining`,
|
`JWT pool: slot invalidated (${new URL(session._slot.siteUsed).hostname}), ${this.pool.length} remaining`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Redis-shared slot may point at a now-401'd JWT (e.g. IP rotated
|
// The Redis-shared slot may point at this same now-dead token. Drop it so
|
||||||
// away on the proxy). Drop it so other containers re-capture instead of
|
// a cold-booting container re-captures instead of hydrating the dead token.
|
||||||
// inheriting the same dead slot.
|
|
||||||
this.deleteFromRedis().catch(() => {});
|
this.deleteFromRedis().catch(() => {});
|
||||||
|
|
||||||
// Capture replacement in background (don't block the caller's retry)
|
// Only force an immediate replacement when the pool emptied; otherwise let
|
||||||
if (this.isBusinessHours() || this.pool.length === 0) {
|
// the surviving slot serve and the refresh/scale-up timers backfill, so a
|
||||||
|
// burst of evictions can't stampede the capture pipeline.
|
||||||
|
if (this.pool.length === 0) {
|
||||||
this.captureToPool().catch((err) => {
|
this.captureToPool().catch((err) => {
|
||||||
this.logger.error(`Replacement capture failed: ${(err as Error).message}`);
|
this.logger.error(`Replacement capture failed: ${(err as Error).message}`);
|
||||||
});
|
});
|
||||||
@@ -241,6 +429,20 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
// ─── Pool management ──────────────────────────────────────
|
// ─── Pool management ──────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim the pool to MAX_POOL_SIZE, evicting the oldest slots (by capturedAt) and
|
||||||
|
* clearing their refresh timers. Idempotent; a no-op when at/under the cap.
|
||||||
|
*/
|
||||||
|
private enforcePoolCap(): void {
|
||||||
|
if (this.pool.length <= MAX_POOL_SIZE) return;
|
||||||
|
this.pool.sort((a, b) => a.capturedAt - b.capturedAt); // oldest first
|
||||||
|
const evicted = this.pool.splice(0, this.pool.length - MAX_POOL_SIZE);
|
||||||
|
for (const s of evicted) this.clearSlotTimer(s);
|
||||||
|
this.logger.warn(
|
||||||
|
`JWT pool: over cap, evicted ${evicted.length} oldest slot(s), size now ${this.pool.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private pickValidSlot(): JwtSlot | null {
|
private pickValidSlot(): JwtSlot | null {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
// Remove expired slots
|
// Remove expired slots
|
||||||
@@ -262,16 +464,10 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private slotToSession(slot: JwtSlot): PcatSession {
|
private slotToSession(slot: JwtSlot): PcatSession {
|
||||||
const proxyUrl = this.useProxy
|
// Fresh rotating call-leg proxy per request — and per retry, since
|
||||||
? `http://${this.proxyUser}:${this.proxyPass}@${this.proxyHost}:${slot.proxyPort}`
|
// fetchWithAuth re-acquires the session each attempt → a new exit IP each
|
||||||
: null;
|
// time, so a slow/blocked residential IP is shed on the next try.
|
||||||
const proxyConfig = this.useProxy
|
const { proxyUrl, proxyConfig } = this.buildProxy("call");
|
||||||
? {
|
|
||||||
server: `http://${this.proxyHost}:${slot.proxyPort}`,
|
|
||||||
username: this.proxyUser,
|
|
||||||
password: this.proxyPass,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
return {
|
return {
|
||||||
apiKey: slot.jwt.raw,
|
apiKey: slot.jwt.raw,
|
||||||
apiPath: slot.jwt.apiPath,
|
apiPath: slot.jwt.apiPath,
|
||||||
@@ -294,17 +490,16 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
const maxRetries = 4;
|
const maxRetries = 4;
|
||||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
const port = this.allocatePort();
|
|
||||||
const siteUrl = this.getAvailableSite();
|
const siteUrl = this.getAvailableSite();
|
||||||
|
|
||||||
const jwt = await this.attemptCapture(siteUrl, port);
|
const jwt = await this.attemptCapture(siteUrl);
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
// Validate the JWT+proxy combo against the real upstream before
|
// Validate the token against the real upstream (on a fresh rotating
|
||||||
// publishing the slot — see VALIDATION_VIN comment above.
|
// call-leg IP) before publishing the slot — see VALIDATION_VIN.
|
||||||
const valid = await this.validateSlot(jwt, port);
|
const valid = await this.validateSlot(jwt);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`JWT capture attempt ${attempt + 1}/${maxRetries} validation failed (port ${port}, ${new URL(siteUrl).hostname}), trying next site...`,
|
`JWT capture attempt ${attempt + 1}/${maxRetries} validation failed (${new URL(siteUrl).hostname}), trying next site...`,
|
||||||
);
|
);
|
||||||
// Mark site as used anyway so we rotate away rather than retry it.
|
// Mark site as used anyway so we rotate away rather than retry it.
|
||||||
this.siteLastUsedAt.set(siteUrl, Date.now());
|
this.siteLastUsedAt.set(siteUrl, Date.now());
|
||||||
@@ -313,17 +508,24 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
const slot: JwtSlot = {
|
const slot: JwtSlot = {
|
||||||
jwt,
|
jwt,
|
||||||
proxyPort: port,
|
|
||||||
siteUsed: siteUrl,
|
siteUsed: siteUrl,
|
||||||
capturedAt: Date.now(),
|
capturedAt: Date.now(),
|
||||||
refreshTimer: null,
|
refreshTimer: null,
|
||||||
|
failCount: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mark site as used
|
// Mark site as used
|
||||||
this.siteLastUsedAt.set(siteUrl, Date.now());
|
this.siteLastUsedAt.set(siteUrl, Date.now());
|
||||||
|
|
||||||
// Add to pool
|
// Add to pool, then enforce the hard cap. A slot-accounting race in the
|
||||||
|
// refresh path (concurrent captures via the semaphore, or a refresh
|
||||||
|
// whose old slot was already removed) could otherwise let the pool grow
|
||||||
|
// past MAX_POOL_SIZE — observed at 32-39 on prod, which multiplies
|
||||||
|
// capture load and partner-site ban exposure for no benefit. Evicting
|
||||||
|
// the oldest slots keeps the freshest MAX_POOL_SIZE and makes the
|
||||||
|
// documented invariant hold regardless of the race.
|
||||||
this.pool.push(slot);
|
this.pool.push(slot);
|
||||||
|
this.enforcePoolCap();
|
||||||
|
|
||||||
// Publish to Redis so other containers can hydrate from this slot.
|
// Publish to Redis so other containers can hydrate from this slot.
|
||||||
this.persistToRedis(slot).catch(() => {});
|
this.persistToRedis(slot).catch(() => {});
|
||||||
@@ -352,13 +554,14 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Probe the just-captured token + proxy port with a single cheap upstream
|
* Probe the just-captured token with a single cheap upstream call (/car/info
|
||||||
* call (/car/info with a public demo VIN). Returns true iff the upstream
|
* with a public demo VIN) on a fresh rotating call-leg IP — the SAME transport
|
||||||
* accepts the combo with 2xx within VALIDATION_TIMEOUT_MS. A failure here
|
* a real request will use. Returns true iff upstream accepts it with 2xx within
|
||||||
* (proxy dead, IP blocked, token 401/403) is far cheaper to absorb at
|
* VALIDATION_TIMEOUT_MS. Validating on the call-leg transport (not the capture
|
||||||
* capture time than to inherit when a real user clicks a category.
|
* IP) is what makes the check meaningful: it rejects a token only if it can't
|
||||||
|
* be served the way it will actually be served.
|
||||||
*/
|
*/
|
||||||
private async validateSlot(jwt: PcatJwtToken, port: number): Promise<boolean> {
|
private async validateSlot(jwt: PcatJwtToken): Promise<boolean> {
|
||||||
const url = `${PCAT_API_BASE}/car/info?q=${VALIDATION_VIN}`;
|
const url = `${PCAT_API_BASE}/car/info?q=${VALIDATION_VIN}`;
|
||||||
const fetchOptions: RequestInit & { dispatcher?: any } = {
|
const fetchOptions: RequestInit & { dispatcher?: any } = {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -375,17 +578,35 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
},
|
},
|
||||||
signal: AbortSignal.timeout(VALIDATION_TIMEOUT_MS),
|
signal: AbortSignal.timeout(VALIDATION_TIMEOUT_MS),
|
||||||
};
|
};
|
||||||
if (this.useProxy) {
|
const { proxyUrl } = this.buildProxy("call");
|
||||||
|
if (proxyUrl) {
|
||||||
const { ProxyAgent } = await import("undici");
|
const { ProxyAgent } = await import("undici");
|
||||||
fetchOptions.dispatcher = new ProxyAgent({
|
fetchOptions.dispatcher = new ProxyAgent({
|
||||||
uri: `http://${this.proxyUser}:${this.proxyPass}@${this.proxyHost}:${port}`,
|
uri: proxyUrl,
|
||||||
connect: { timeout: 6_000 },
|
connect: { timeout: 6_000 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const start = Date.now();
|
||||||
try {
|
try {
|
||||||
const r = await fetch(url, fetchOptions);
|
const r = await fetch(url, fetchOptions);
|
||||||
|
this.proxyTelemetry.record({
|
||||||
|
service: "pcat_validate",
|
||||||
|
provider: this.proxyProvider,
|
||||||
|
targetHost: "gui.parts-catalogs.com",
|
||||||
|
statusCode: r.status,
|
||||||
|
success: r.ok,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
});
|
||||||
return r.ok;
|
return r.ok;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
this.proxyTelemetry.record({
|
||||||
|
service: "pcat_validate",
|
||||||
|
provider: this.proxyProvider,
|
||||||
|
targetHost: "gui.parts-catalogs.com",
|
||||||
|
errorKind: classifyTransportError(err),
|
||||||
|
success: false,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
});
|
||||||
this.logger.debug(`Slot validation threw: ${(err as Error).message}`);
|
this.logger.debug(`Slot validation threw: ${(err as Error).message}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -539,15 +760,16 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
private getAvailableSite(): string {
|
private getAvailableSite(): string {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const sites = this.jwtSites;
|
||||||
|
|
||||||
// Try round-robin, preferring sites not on cooldown
|
// Try round-robin, preferring sites not on cooldown
|
||||||
for (let i = 0; i < JWT_SITES.length; i++) {
|
for (let i = 0; i < sites.length; i++) {
|
||||||
const idx = (this.siteIndex + i) % JWT_SITES.length;
|
const idx = (this.siteIndex + i) % sites.length;
|
||||||
const site = JWT_SITES[idx];
|
const site = sites[idx];
|
||||||
const lastUsed = this.siteLastUsedAt.get(site) || 0;
|
const lastUsed = this.siteLastUsedAt.get(site) || 0;
|
||||||
|
|
||||||
if (now - lastUsed >= SITE_COOLDOWN) {
|
if (now - lastUsed >= SITE_COOLDOWN) {
|
||||||
this.siteIndex = (idx + 1) % JWT_SITES.length;
|
this.siteIndex = (idx + 1) % sites.length;
|
||||||
return site;
|
return site;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -555,15 +777,15 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// All on cooldown — pick the one with oldest usage
|
// All on cooldown — pick the one with oldest usage
|
||||||
let oldestIdx = 0;
|
let oldestIdx = 0;
|
||||||
let oldestTime = Number.POSITIVE_INFINITY;
|
let oldestTime = Number.POSITIVE_INFINITY;
|
||||||
for (let i = 0; i < JWT_SITES.length; i++) {
|
for (let i = 0; i < sites.length; i++) {
|
||||||
const lastUsed = this.siteLastUsedAt.get(JWT_SITES[i]) || 0;
|
const lastUsed = this.siteLastUsedAt.get(sites[i]) || 0;
|
||||||
if (lastUsed < oldestTime) {
|
if (lastUsed < oldestTime) {
|
||||||
oldestTime = lastUsed;
|
oldestTime = lastUsed;
|
||||||
oldestIdx = i;
|
oldestIdx = i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.siteIndex = (oldestIdx + 1) % JWT_SITES.length;
|
this.siteIndex = (oldestIdx + 1) % sites.length;
|
||||||
return JWT_SITES[oldestIdx];
|
return sites[oldestIdx];
|
||||||
}
|
}
|
||||||
|
|
||||||
private allocatePort(): number {
|
private allocatePort(): number {
|
||||||
@@ -572,19 +794,55 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
// ─── JWT capture via Playwright ───────────────────────────
|
// ─── JWT capture via Playwright ───────────────────────────
|
||||||
|
|
||||||
private async attemptCapture(siteUrl: string, port: number): Promise<PcatJwtToken | null> {
|
private async attemptCapture(siteUrl: string, forceDirect = false): Promise<PcatJwtToken | null> {
|
||||||
let context: BrowserContext | null = null;
|
let context: BrowserContext | null = null;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Sticky capture proxy: one residential exit IP pinned for the lifetime of
|
||||||
|
// this capture so the partner site + widget see a coherent session.
|
||||||
|
// forceDirect (set on retry after a proxy tunnel failure) skips the proxy —
|
||||||
|
// see captureAllowDirect: the token is not IP-bound so a direct capture is a
|
||||||
|
// safe last resort that keeps the pool alive through a full proxy outage.
|
||||||
|
const { proxyConfig, proxyUrl, sessionKey, provider } = forceDirect
|
||||||
|
? {
|
||||||
|
proxyConfig: null,
|
||||||
|
proxyUrl: null as string | null,
|
||||||
|
sessionKey: null as string | null,
|
||||||
|
provider: "none" as ProxyProvider,
|
||||||
|
}
|
||||||
|
: this.buildProxy("capture");
|
||||||
|
|
||||||
|
// Set when a proxied navigation dies on a tunnel/connect failure and direct
|
||||||
|
// capture is allowed — we fall through to a direct retry instead of counting
|
||||||
|
// this as a dead site.
|
||||||
|
let retryDirect = false;
|
||||||
|
|
||||||
|
// The session is sticky, so a parallel ipify probe exits from the SAME IP
|
||||||
|
// the capture will use — that's what makes banned-IP tracking concrete.
|
||||||
|
// Resolves in ~1-2s while the capture itself takes 7s+; never throws.
|
||||||
|
// Gated off by default (PCAT_EXIT_IP_PROBE): the probe is billed proxy
|
||||||
|
// traffic and only feeds exit-IP telemetry, not the capture itself.
|
||||||
|
const exitIpPromise =
|
||||||
|
this.exitIpProbe && proxyUrl ? resolveExitIp(proxyUrl) : Promise.resolve(null);
|
||||||
|
const logCapture = (ok: boolean, errorKind?: string): void => {
|
||||||
|
void exitIpPromise.then((exitIp) =>
|
||||||
|
this.proxyTelemetry.record({
|
||||||
|
service: "pcat_capture",
|
||||||
|
provider,
|
||||||
|
sessionKey,
|
||||||
|
exitIp,
|
||||||
|
targetHost: new URL(siteUrl).hostname,
|
||||||
|
errorKind: errorKind ?? null,
|
||||||
|
success: ok,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build context options with proxy
|
|
||||||
const contextOptions: Record<string, unknown> = {};
|
const contextOptions: Record<string, unknown> = {};
|
||||||
if (this.useProxy) {
|
if (proxyConfig) {
|
||||||
contextOptions.proxy = {
|
contextOptions.proxy = proxyConfig;
|
||||||
server: `http://${this.proxyHost}:${port}`,
|
|
||||||
username: this.proxyUser,
|
|
||||||
password: this.proxyPass,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.browser) throw new Error("PCAT browser not initialized");
|
if (!this.browser) throw new Error("PCAT browser not initialized");
|
||||||
@@ -615,8 +873,15 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.logger.debug(`Token intercepted (key=${apiKey.slice(0, 16)}...)`);
|
this.logger.debug(`Token intercepted (key=${apiKey.slice(0, 16)}...)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Block heavy resources to save proxy bandwidth
|
// Block heavy resources to save proxy bandwidth. Kept intentionally
|
||||||
await page.route("**/*", (route) => {
|
// 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 url = route.request().url();
|
||||||
const type = route.request().resourceType();
|
const type = route.request().resourceType();
|
||||||
|
|
||||||
@@ -637,15 +902,42 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return route.abort();
|
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();
|
return route.continue();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Navigate — networkidle waits for widget JS to load + make API calls
|
// Navigate with domcontentloaded (not networkidle): on JS-heavy catalog
|
||||||
|
// sites through a slow residential proxy networkidle often never settles
|
||||||
|
// and the goto times out before the widget fires its token call. DOM-ready
|
||||||
|
// is reliable (~7s measured); the request listener + post-goto poll then
|
||||||
|
// catch the /v3/api/proxy token call (~11.8s measured).
|
||||||
let gotoOk = true;
|
let gotoOk = true;
|
||||||
try {
|
try {
|
||||||
await page.goto(siteUrl, {
|
await page.goto(siteUrl, {
|
||||||
timeout: PAGE_TIMEOUT,
|
timeout: PAGE_TIMEOUT,
|
||||||
waitUntil: "networkidle",
|
waitUntil: "domcontentloaded",
|
||||||
});
|
});
|
||||||
} catch (navErr) {
|
} catch (navErr) {
|
||||||
// Navigation may timeout but JWT could still be captured if the
|
// Navigation may timeout but JWT could still be captured if the
|
||||||
@@ -653,27 +945,58 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// constants above.
|
// constants above.
|
||||||
gotoOk = false;
|
gotoOk = false;
|
||||||
this.logger.debug(`Navigation ended: ${(navErr as Error).message?.slice(0, 80)}`);
|
this.logger.debug(`Navigation ended: ${(navErr as Error).message?.slice(0, 80)}`);
|
||||||
|
const connectFailed = isProxyConnectFailure(navErr);
|
||||||
|
// A tunnel/connect failure here (not a plain slow-site timeout) means the
|
||||||
|
// Floxy exit is dead — trip the shared gate so this + EMEX fail over to
|
||||||
|
// DataImpulse for the cooldown window.
|
||||||
|
if (provider === "floxy" && connectFailed) {
|
||||||
|
this.proxyHealth.reportFloxyFailure(`pcat capture: ${classifyTransportError(navErr)}`);
|
||||||
|
}
|
||||||
|
// Proxy tunnel is dead (all providers). Rather than return an empty
|
||||||
|
// capture and starve the pool, retry this same site direct.
|
||||||
|
if (connectFailed && proxyConfig && this.captureAllowDirect && !forceDirect) {
|
||||||
|
retryDirect = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll for token — short cap after healthy goto, brief grace after fail
|
if (!retryDirect) {
|
||||||
const pollMax = gotoOk ? CAPTURE_POLL_AFTER_OK : CAPTURE_POLL_AFTER_FAIL;
|
// Poll for token — short cap after healthy goto, brief grace after fail
|
||||||
for (let i = 0; i < pollMax; i++) {
|
const pollMax = gotoOk ? CAPTURE_POLL_AFTER_OK : CAPTURE_POLL_AFTER_FAIL;
|
||||||
if (capturedToken) break;
|
for (let i = 0; i < pollMax; i++) {
|
||||||
await new Promise((r) => setTimeout(r, CAPTURE_POLL_INTERVAL));
|
if (capturedToken) break;
|
||||||
|
await new Promise((r) => setTimeout(r, CAPTURE_POLL_INTERVAL));
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
|
||||||
|
if (capturedToken) {
|
||||||
|
logCapture(true);
|
||||||
|
// Floxy answered — clear any failover cooldown so we resume residential.
|
||||||
|
if (provider === "floxy") this.proxyHealth.reportFloxySuccess();
|
||||||
|
this.logger.log(
|
||||||
|
`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}${forceDirect ? " (direct)" : ""}`,
|
||||||
|
);
|
||||||
|
return capturedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Widget never fired its token call — on a sticky residential IP this is
|
||||||
|
// the classic "partner site blocked/ratelimited this exit IP" signature.
|
||||||
|
logCapture(false, "capture_timeout");
|
||||||
|
this.logger.debug(`No token after ${elapsed}ms from ${siteUrl}`);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = Date.now() - startTime;
|
|
||||||
|
|
||||||
if (capturedToken) {
|
|
||||||
this.logger.log(`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`);
|
|
||||||
return capturedToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug(`No token after ${elapsed}ms from ${siteUrl}`);
|
|
||||||
return null;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Token capture error: ${(err as Error).message}`);
|
logCapture(false, classifyTransportError(err));
|
||||||
return null;
|
const connectFailed = isProxyConnectFailure(err);
|
||||||
|
if (provider === "floxy" && connectFailed) {
|
||||||
|
this.proxyHealth.reportFloxyFailure(`pcat capture: ${classifyTransportError(err)}`);
|
||||||
|
}
|
||||||
|
if (connectFailed && proxyConfig && this.captureAllowDirect && !forceDirect) {
|
||||||
|
retryDirect = true;
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`Token capture error: ${(err as Error).message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (context) {
|
if (context) {
|
||||||
try {
|
try {
|
||||||
@@ -686,6 +1009,13 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reached only when a proxied attempt hit a dead tunnel and direct is
|
||||||
|
// allowed — retry the same site straight from the server IP.
|
||||||
|
this.logger.warn(
|
||||||
|
`Capture proxy tunnel dead (${provider}) for ${new URL(siteUrl).hostname} — retrying direct`,
|
||||||
|
);
|
||||||
|
return this.attemptCapture(siteUrl, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Browser lifecycle ───────────────────────────────────
|
// ─── Browser lifecycle ───────────────────────────────────
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ProxyTelemetryModule } from "../proxy-telemetry/proxy-telemetry.module";
|
||||||
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
|
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
|
||||||
import { PartsCatalogsService } from "./parts-catalogs.service";
|
import { PartsCatalogsService } from "./parts-catalogs.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [ProxyTelemetryModule],
|
||||||
providers: [PartsCatalogsAuthService, PartsCatalogsService],
|
providers: [PartsCatalogsAuthService, PartsCatalogsService],
|
||||||
exports: [PartsCatalogsService],
|
exports: [PartsCatalogsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,14 +2,22 @@
|
|||||||
* Parts-Catalogs API Service — HTTP client for parts-catalogs.com v3
|
* Parts-Catalogs API Service — HTTP client for parts-catalogs.com v3
|
||||||
*
|
*
|
||||||
* Calls the v3 widget proxy (gui.parts-catalogs.com/v3/api/proxy/*) with the
|
* Calls the v3 widget proxy (gui.parts-catalogs.com/v3/api/proxy/*) with the
|
||||||
* captured TWS- token + supporting X-* headers. Requests must go through the
|
* captured TWS- token + supporting X-* headers. The token is portable (NOT
|
||||||
* same DataImpulse proxy port that captured the token (IP-bound).
|
* IP-bound), so each request rotates a fresh exit IP via the auth service's
|
||||||
|
* call-leg proxy (PartsCatalogsAuthService.buildProxy).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { isBackfillContext } from "../../jobs/prefetch-context";
|
import { isBackfillContext } from "../../jobs/prefetch-context";
|
||||||
import { PostHogService } from "../../posthog/posthog.service";
|
import { PostHogService } from "../../posthog/posthog.service";
|
||||||
import { RedisService } from "../../redis/redis.service";
|
import { RedisService } from "../../redis/redis.service";
|
||||||
|
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
|
||||||
|
import {
|
||||||
|
ProxyTelemetryService,
|
||||||
|
classifyTransportError,
|
||||||
|
describeProxyUrl,
|
||||||
|
isProxyConnectFailure,
|
||||||
|
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||||
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
|
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
|
||||||
import {
|
import {
|
||||||
PcatCar,
|
PcatCar,
|
||||||
@@ -20,19 +28,38 @@ import {
|
|||||||
} from "./parts-catalogs.types";
|
} from "./parts-catalogs.types";
|
||||||
|
|
||||||
const API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy";
|
const API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy";
|
||||||
const REQUEST_TIMEOUT = Number(process.env.PCAT_REQUEST_TIMEOUT_MS) || 30_000;
|
// Catalog scrape (groups2/parts2). Lowered 30s→10s (2026-06-10): the old 30s
|
||||||
|
// default could blow the caller's 25s decode budget on a single slow attempt,
|
||||||
|
// and through a rotating residential proxy a healthy call answers in 1-4s. A
|
||||||
|
// slow/blocked exit IP now aborts at 10s and the retry re-acquires a fresh IP.
|
||||||
|
const REQUEST_TIMEOUT = Number(process.env.PCAT_REQUEST_TIMEOUT_MS) || 10_000;
|
||||||
const MAX_RETRIES = Number(process.env.PCAT_MAX_RETRIES) || 2;
|
const MAX_RETRIES = Number(process.env.PCAT_MAX_RETRIES) || 2;
|
||||||
// A dead DataImpulse proxy port otherwise stalls for undici's 10s default connect
|
// undici's ProxyAgent connect.timeout bounds the connect to the proxy entry.
|
||||||
// timeout before the retry rotates to a fresh port — three of those blow the
|
|
||||||
// caller's 25s decode budget. Fail fast so retries reach a live port in time.
|
|
||||||
const PROXY_CONNECT_TIMEOUT = Number(process.env.PCAT_PROXY_CONNECT_TIMEOUT_MS) || 6_000;
|
const PROXY_CONNECT_TIMEOUT = Number(process.env.PCAT_PROXY_CONNECT_TIMEOUT_MS) || 6_000;
|
||||||
// VIN decode (/car/info) answers in <1s on a healthy proxy, so a long timeout only
|
// VIN decode (/car/info) answers in 1-4s through the rotating residential proxy.
|
||||||
// prolongs dead-port connects. undici's ProxyAgent connect.timeout does NOT bound the
|
// A short per-call timeout + retries means a slow exit IP aborts fast and the
|
||||||
// connection to the proxy itself (it stays at undici's 10s default), so we bound it at
|
// next attempt re-acquires the session → a fresh IP, all within the 25s budget.
|
||||||
// the fetch level: a short per-call timeout + extra retries makes a stuck DataImpulse
|
|
||||||
// port abort fast and rotate to a live one within the caller's 25s decode budget.
|
|
||||||
const DECODE_REQUEST_TIMEOUT = Number(process.env.PCAT_DECODE_TIMEOUT_MS) || 6_000;
|
const DECODE_REQUEST_TIMEOUT = Number(process.env.PCAT_DECODE_TIMEOUT_MS) || 6_000;
|
||||||
const DECODE_MAX_RETRIES = Number(process.env.PCAT_DECODE_MAX_RETRIES) || 4;
|
const DECODE_MAX_RETRIES = Number(process.env.PCAT_DECODE_MAX_RETRIES) || 4;
|
||||||
|
// Evict a pooled token after this many consecutive transport failures (reset on
|
||||||
|
// any 2xx). Through the rotating residential proxy each attempt hits a different
|
||||||
|
// exit IP, so N failures in a row on N different IPs ⇒ the token is dead at the
|
||||||
|
// source, not a flaky IP. Pre-rotation this was the "dead slot never evicted"
|
||||||
|
// bug: invalidateSession only fired on 401/403, so a bad token was served until TTL.
|
||||||
|
const TRANSPORT_FAIL_EVICT_THRESHOLD = Number(process.env.PCAT_TRANSPORT_FAIL_EVICT) || 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a fast-fail (blockOnEmpty:false) caller hits a cold pool. NOT a
|
||||||
|
* "VIN not found" — pcat was skipped while the pool warmed, so the caller must
|
||||||
|
* treat it as a transient miss (don't poison the negative cache) and let
|
||||||
|
* another source answer.
|
||||||
|
*/
|
||||||
|
class PcatColdPoolError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("pcat cold pool — skipped to avoid inline capture");
|
||||||
|
this.name = "PcatColdPoolError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PartsCatalogsService {
|
export class PartsCatalogsService {
|
||||||
@@ -42,6 +69,8 @@ export class PartsCatalogsService {
|
|||||||
private authService: PartsCatalogsAuthService,
|
private authService: PartsCatalogsAuthService,
|
||||||
private redis: RedisService,
|
private redis: RedisService,
|
||||||
private posthog: PostHogService,
|
private posthog: PostHogService,
|
||||||
|
private proxyTelemetry: ProxyTelemetryService,
|
||||||
|
private proxyHealth: ProxyHealthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Mark parts-catalogs as actively used (5min TTL) to defer prefetch worker */
|
/** Mark parts-catalogs as actively used (5min TTL) to defer prefetch worker */
|
||||||
@@ -62,7 +91,8 @@ export class PartsCatalogsService {
|
|||||||
async decodeVin(
|
async decodeVin(
|
||||||
vin: string,
|
vin: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
outcome?: { transient: boolean },
|
outcome?: { transient: boolean; coldSkip?: boolean },
|
||||||
|
opts?: { blockOnEmpty?: boolean },
|
||||||
): Promise<PcatVinResult | null> {
|
): Promise<PcatVinResult | null> {
|
||||||
if (!(await this.posthog.isSourceLive("parts-catalogs"))) {
|
if (!(await this.posthog.isSourceLive("parts-catalogs"))) {
|
||||||
this.logger.warn("parts-catalogs disabled by kill switch (kill-source-parts-catalogs)");
|
this.logger.warn("parts-catalogs disabled by kill switch (kill-source-parts-catalogs)");
|
||||||
@@ -72,6 +102,7 @@ export class PartsCatalogsService {
|
|||||||
const data = await this.fetchWithAuth("/car/info", { q: vin }, signal, {
|
const data = await this.fetchWithAuth("/car/info", { q: vin }, signal, {
|
||||||
timeoutMs: DECODE_REQUEST_TIMEOUT,
|
timeoutMs: DECODE_REQUEST_TIMEOUT,
|
||||||
maxRetries: DECODE_MAX_RETRIES,
|
maxRetries: DECODE_MAX_RETRIES,
|
||||||
|
blockOnEmpty: opts?.blockOnEmpty,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data || typeof data !== "object") {
|
if (!data || typeof data !== "object") {
|
||||||
@@ -105,9 +136,23 @@ export class PartsCatalogsService {
|
|||||||
return { cars };
|
return { cars };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error & { cause?: unknown };
|
const e = err as Error & { cause?: unknown };
|
||||||
// Distinguish a transient transport blip (proxy timeout / dropped DataImpulse
|
// Cold-pool fast-fail: pcat was skipped (pool warming), NOT a definitive
|
||||||
// connection) from a genuine "not found". The caller uses `outcome.transient`
|
// miss. Mark transient so the orchestrator doesn't negative-cache a VIN
|
||||||
// to decide whether to poison the negative cache — a blip must not.
|
// pcat hasn't actually evaluated, and stay quiet (this is expected).
|
||||||
|
if (e instanceof PcatColdPoolError) {
|
||||||
|
if (outcome) {
|
||||||
|
outcome.transient = true;
|
||||||
|
// A deliberate cold-pool skip is not a transport fault — flag it so the
|
||||||
|
// circuit breaker doesn't count it (the pool is already warming).
|
||||||
|
outcome.coldSkip = true;
|
||||||
|
}
|
||||||
|
this.logger.debug(`pcat decode skipped for ${vin}: cold pool (warming)`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Distinguish a transient transport blip (proxy timeout / dropped
|
||||||
|
// connection) from a genuine "not found". The caller uses
|
||||||
|
// `outcome.transient` to decide whether to poison the negative cache — a
|
||||||
|
// blip must not.
|
||||||
if (
|
if (
|
||||||
outcome &&
|
outcome &&
|
||||||
(e.name === "TimeoutError" ||
|
(e.name === "TimeoutError" ||
|
||||||
@@ -212,7 +257,7 @@ export class PartsCatalogsService {
|
|||||||
endpoint: string,
|
endpoint: string,
|
||||||
params?: Record<string, string>,
|
params?: Record<string, string>,
|
||||||
externalSignal?: AbortSignal,
|
externalSignal?: AbortSignal,
|
||||||
opts?: { timeoutMs?: number; maxRetries?: number },
|
opts?: { timeoutMs?: number; maxRetries?: number; blockOnEmpty?: boolean },
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const maxRetries = opts?.maxRetries ?? MAX_RETRIES;
|
const maxRetries = opts?.maxRetries ?? MAX_RETRIES;
|
||||||
const timeoutMs = opts?.timeoutMs ?? REQUEST_TIMEOUT;
|
const timeoutMs = opts?.timeoutMs ?? REQUEST_TIMEOUT;
|
||||||
@@ -223,7 +268,12 @@ export class PartsCatalogsService {
|
|||||||
if (externalSignal?.aborted) {
|
if (externalSignal?.aborted) {
|
||||||
throw new Error("Request aborted by caller");
|
throw new Error("Request aborted by caller");
|
||||||
}
|
}
|
||||||
session = await this.authService.acquireSession();
|
session = await this.authService.acquireSession({ blockOnEmpty: opts?.blockOnEmpty });
|
||||||
|
if (!session) {
|
||||||
|
// Cold pool + fast-fail: warming in the background; don't retry, let the
|
||||||
|
// caller fall through to another source.
|
||||||
|
throw new PcatColdPoolError();
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(`${API_BASE}${endpoint}`);
|
const url = new URL(`${API_BASE}${endpoint}`);
|
||||||
if (params) {
|
if (params) {
|
||||||
@@ -232,6 +282,7 @@ export class PartsCatalogsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const attemptStart = Date.now();
|
||||||
try {
|
try {
|
||||||
const signals = [AbortSignal.timeout(timeoutMs)];
|
const signals = [AbortSignal.timeout(timeoutMs)];
|
||||||
if (externalSignal) signals.push(externalSignal);
|
if (externalSignal) signals.push(externalSignal);
|
||||||
@@ -262,7 +313,24 @@ export class PartsCatalogsService {
|
|||||||
|
|
||||||
const response = await fetch(url.toString(), fetchOptions);
|
const response = await fetch(url.toString(), fetchOptions);
|
||||||
|
|
||||||
|
// An HTTP answer arrived — the proxy leg worked at transport level;
|
||||||
|
// log the upstream's verdict (403/429 are the IP-ban signals).
|
||||||
|
this.proxyTelemetry.record({
|
||||||
|
service: "pcat_call",
|
||||||
|
...describeProxyUrl(session.proxyUrl),
|
||||||
|
targetHost: url.hostname,
|
||||||
|
statusCode: response.status,
|
||||||
|
success: response.ok,
|
||||||
|
durationMs: Date.now() - attemptStart,
|
||||||
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
// Token proven good on this IP — clear any accrued failure streak.
|
||||||
|
session._slot.failCount = 0;
|
||||||
|
// Floxy answered at transport level — clear any failover cooldown.
|
||||||
|
if (describeProxyUrl(session.proxyUrl).provider === "floxy") {
|
||||||
|
this.proxyHealth.reportFloxySuccess();
|
||||||
|
}
|
||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +348,23 @@ export class PartsCatalogsService {
|
|||||||
throw new Error(`HTTP ${response.status} from ${endpoint}: ${text.slice(0, 200)}`);
|
throw new Error(`HTTP ${response.status} from ${endpoint}: ${text.slice(0, 200)}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error & { cause?: unknown };
|
const e = err as Error & { cause?: unknown };
|
||||||
|
// Transport failure (no HTTP answer) — the try block already logged any
|
||||||
|
// real HTTP response before rethrowing it as `Error: HTTP <code> …`.
|
||||||
|
if (!/^HTTP \d/.test(e.message ?? "")) {
|
||||||
|
this.proxyTelemetry.record({
|
||||||
|
service: "pcat_call",
|
||||||
|
...describeProxyUrl(session.proxyUrl),
|
||||||
|
targetHost: url.hostname,
|
||||||
|
errorKind: classifyTransportError(e),
|
||||||
|
success: false,
|
||||||
|
durationMs: Date.now() - attemptStart,
|
||||||
|
});
|
||||||
|
// Floxy exit refused/closed the CONNECT → trip the shared gate so the
|
||||||
|
// next capture/call (and EMEX) fails over to DataImpulse.
|
||||||
|
if (describeProxyUrl(session.proxyUrl).provider === "floxy" && isProxyConnectFailure(e)) {
|
||||||
|
this.proxyHealth.reportFloxyFailure(`pcat call: ${classifyTransportError(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Retry transient TRANSPORT failures only: request timeouts and undici
|
// Retry transient TRANSPORT failures only: request timeouts and undici
|
||||||
// network errors ("TypeError: fetch failed" — a dropped/reset DataImpulse
|
// network errors ("TypeError: fetch failed" — a dropped/reset DataImpulse
|
||||||
// proxy connection). A definitive HTTP response (e.g. 400 "list of parts
|
// proxy connection). A definitive HTTP response (e.g. 400 "list of parts
|
||||||
@@ -291,16 +376,28 @@ export class PartsCatalogsService {
|
|||||||
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|other side closed|terminated|UND_ERR/i.test(
|
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|other side closed|terminated|UND_ERR/i.test(
|
||||||
`${e.message} ${String(e.cause ?? "")}`,
|
`${e.message} ${String(e.cause ?? "")}`,
|
||||||
);
|
);
|
||||||
if (transient && attempt < maxRetries) {
|
if (transient) {
|
||||||
this.logger.warn(
|
// A token that keeps failing transport across rotating IPs is dead at
|
||||||
`Transient transport error on ${endpoint} (attempt ${attempt + 1}/${maxRetries + 1}): ${e.message}${
|
// the source — evict it so it stops being round-robin-served (the old
|
||||||
e.cause ? ` [cause: ${String(e.cause)}]` : ""
|
// 401/403-only eviction never caught this). Only when a proxy is in use;
|
||||||
}`,
|
// direct-mode transport errors are upstream, not token/IP problems.
|
||||||
);
|
if (session.proxyUrl) {
|
||||||
// Brief backoff so a momentarily-flaky proxy port can recover; the next
|
session._slot.failCount = (session._slot.failCount ?? 0) + 1;
|
||||||
// loop iteration re-acquires a session (round-robin across the pool).
|
if (session._slot.failCount >= TRANSPORT_FAIL_EVICT_THRESHOLD) {
|
||||||
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
|
await this.authService.invalidateSession(session);
|
||||||
continue;
|
}
|
||||||
|
}
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Transient transport error on ${endpoint} (attempt ${attempt + 1}/${maxRetries + 1}): ${e.message}${
|
||||||
|
e.cause ? ` [cause: ${String(e.cause)}]` : ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
// Brief backoff so a momentarily-flaky exit IP can recover; the next
|
||||||
|
// loop iteration re-acquires a session (a fresh rotating IP).
|
||||||
|
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Captured from a parts-catalogs.com v3 widget request.
|
* Captured from a parts-catalogs.com v3 widget request.
|
||||||
* Token + the supporting X-* headers the widget sends with every API call.
|
* Token + the supporting X-* headers the widget sends with every API call.
|
||||||
* IP-bound (must be reused with the same proxy port that captured it).
|
*
|
||||||
|
* NOT request-time IP-bound. Verified on prod 2026-06-10: 0 auth 401/403 across
|
||||||
|
* thousands of proxied calls, and a token captured on one exit IP replays with a
|
||||||
|
* 200 through arbitrary other IPs (incl. fresh residential ones). Upstream issues
|
||||||
|
* a stable shared token per origin. So the call leg rotates exit IPs freely; only
|
||||||
|
* the capture (Playwright) leg holds a sticky IP for the duration of one capture.
|
||||||
*/
|
*/
|
||||||
export interface PcatJwtToken {
|
export interface PcatJwtToken {
|
||||||
raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..."
|
raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..."
|
||||||
@@ -15,10 +20,14 @@ export interface PcatJwtToken {
|
|||||||
|
|
||||||
export interface JwtSlot {
|
export interface JwtSlot {
|
||||||
jwt: PcatJwtToken;
|
jwt: PcatJwtToken;
|
||||||
proxyPort: number;
|
|
||||||
siteUsed: string;
|
siteUsed: string;
|
||||||
capturedAt: number;
|
capturedAt: number;
|
||||||
refreshTimer: ReturnType<typeof setTimeout> | null;
|
refreshTimer: ReturnType<typeof setTimeout> | null;
|
||||||
|
// Consecutive transport failures across requests (reset on any 2xx). The slot
|
||||||
|
// is evicted once this crosses a threshold — a token that keeps failing on
|
||||||
|
// fresh rotating IPs is dead at the source, not a flaky exit IP. Optional so
|
||||||
|
// slots persisted to Redis by older builds still hydrate.
|
||||||
|
failCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PcatSession {
|
export interface PcatSession {
|
||||||
|
|||||||
17
apps/api/src/integrations/pl24/__fixtures__/p4_ford.html
Normal file
17
apps/api/src/integrations/pl24/__fixtures__/p4_ford.html
Normal 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&openVinDialog=false&startup=false&vin=WF0RXXGCDRAP80417&mode=A0LW0DEDE&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&openVinDialog=false&sharedCatCode=ZE&startup=false&vin=WF0RXXGCDRAP80417&mode=A0LW0DEDE&upds=2026.09.08+15%3A14%3A06+CEST" caption="ZE - Accessories (ZE)<div class="restrictionBlockWithMargin"></div>"><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&openVinDialog=false&sharedCatCode=ZF&startup=false&vin=WF0RXXGCDRAP80417&mode=A0LW0DEDE&upds=2026.09.08+15%3A14%3A06+CEST" caption="ZF - Fluids & Maintenance Products (ZF)<div class="restrictionBlockWithMargin"></div>"><td class="caption tc-lcell tc-rcell dynaheight"><div class="dynaheightWrapper"><div class="dblWrap">ZF - Fluids & 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&openVinDialog=false&sharedCatCode=C20DD0X&startup=false&vin=WF0RXXGCDRAP80417&mode=A0LW0DEDE&upds=2026.09.08+15%3A14%3A06+CEST" caption="C20DD0X - 2.0 DI Diesel<div class="restrictionBlockWithMargin"><span class="vinHit" title="Attribute matches">Engine Type = All 2.0L Duratorq Engines&nbsp;</span></div>"><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 </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&openVinDialog=false&sharedCatCode=CMPS6&startup=false&vin=WF0RXXGCDRAP80417&mode=A0LW0DEDE&upds=2026.09.08+15%3A14%3A06+CEST" caption="CMPS6 - 6 Speed Trans Powershift<div class="restrictionBlockWithMargin"><span class="vinHit" title="Attribute matches">Transmission = 6 Speed Powershift 6DCT450 - MPS6&nbsp;</span></div>"><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 </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&openVinDialog=false&sharedCatCode=CVPTO&startup=false&vin=WF0RXXGCDRAP80417&mode=A0LW0DEDE&upds=2026.09.08+15%3A14%3A06+CEST" caption="CVPTO - Transfer Case<div class="restrictionBlockWithMargin"><span class="vinHit" title="Attribute matches">Transmission = 6 Speed Powershift 6DCT450 - MPS6&nbsp;</span></div>"><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 </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>
|
||||||
13
apps/api/src/integrations/pl24/__fixtures__/p4_hyundai.html
Normal file
13
apps/api/src/integrations/pl24/__fixtures__/p4_hyundai.html
Normal 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&mainGroup=BO&openVinDialog=false&startup=false&vin=KMHCT41BAGU283791&mode=A0LW0DEDE&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&mainGroup=CH&openVinDialog=false&startup=false&vin=KMHCT41BAGU283791&mode=A0LW0DEDE&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&mainGroup=EL&openVinDialog=false&startup=false&vin=KMHCT41BAGU283791&mode=A0LW0DEDE&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&mainGroup=EN&openVinDialog=false&startup=false&vin=KMHCT41BAGU283791&mode=A0LW0DEDE&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&mainGroup=MI&openVinDialog=false&startup=false&vin=KMHCT41BAGU283791&mode=A0LW0DEDE&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&mainGroup=TR&openVinDialog=false&startup=false&vin=KMHCT41BAGU283791&mode=A0LW0DEDE&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>
|
||||||
109
apps/api/src/integrations/pl24/__fixtures__/p4_nissan.html
Normal file
109
apps/api/src/integrations/pl24/__fixtures__/p4_nissan.html
Normal 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 & 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 & 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&localMarketOnly=true&model=S14_097_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=S14_097_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=Z33_007_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Z33_007_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=Z34_042_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Z34_042_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=B10RS_016_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=B10RS_016_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=G15RA_110_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=G15RA_110_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=N15_088_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=N15_088_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=N16_298_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=N16_298_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=V10M_302_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=V10M_302_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=N16E_307_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=N16E_307_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=L33_138_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=L33_138_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=L34_155_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=L34_155_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=FE0_158_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=FE0_158_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=TK3_059_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=TK3_059_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=F24M_021_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=F24M_021_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=Z12_047_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Z12_047_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=HBD0R_149_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=HBD0R_149_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=HBD0_129_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=HBD0_129_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=BD0R_148_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=BD0R_148_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=BD0_121_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=BD0_121_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=ME0M_122_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=ME0M_122_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=R35_040_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=R35_040_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=D22S_014_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=D22S_014_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=X70_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=X70_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=XDD_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=XDD_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=XDE_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=XDE_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=F16E_157_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=F16E_157_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=F15_052_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=F15_052_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=F15E_056_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=F15E_056_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=D22_073_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=D22_073_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=LCD22_030_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=LCD22_030_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=X76_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=X76_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=ZE0_054_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=ZE0_054_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=ZE0E_111_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=ZE0E_111_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=ZE1E_152_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=ZE1E_152_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=ZE2E_166_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=ZE2E_166_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=A32_065_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=A32_065_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=A36_131_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=A36_131_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=CA33_305_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=CA33_305_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=K11E_081_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=K11E_081_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=K12E_005_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=K12E_005_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=K15_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=K15_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=CK12E_012_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=CK12E_012_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=K14FR_144_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=K14FR_144_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=K13KK_136_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=K13KK_136_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=K13K_051_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=K13K_051_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=Z50_008_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Z50_008_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=Z51_036_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Z51_036_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=Z51R_057_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Z51R_057_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=Z52R_137_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Z52R_137_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=D40M_010_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=D40M_010_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=D23M_135_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=D23M_135_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=E11E_015_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=E11E_015_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=E12E_113_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=E12E_113_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=D22SS_055_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=D22SS_055_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=M20_046_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=M20_046_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=M20M_050_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=M20M_050_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=X61_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=X61_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=X82_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=X82_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=X62_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=X62_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=X62B_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=X62B_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=R51M_009_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=R51M_009_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=R52R_123_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=R52R_123_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=R53_159_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=R53_159_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=R52RR_132_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=R52RR_132_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=Y62_048_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Y62_048_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=Y61_107_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Y61_107_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=UA0_043_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=UA0_043_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=X83_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=X83_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=P11E_090_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=P11E_090_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=P12E_001_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=P12E_001_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=W10_102_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=W10_102_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=WP11E_104_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=WP11E_104_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=C13M_124_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=C13M_124_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=J10E_023_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=J10E_023_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=J12E_160_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=J12E_160_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=J11R_134_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=J11R_134_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=J11E_120_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=J11E_120_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=JJ10E_038_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=JJ10E_038_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=B17RR_126_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=B17RR_126_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=B17R_125_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=B17R_125_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=C23M_070_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=C23M_070_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=Y10_105_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Y10_105_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=J31_017_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=J31_017_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=J32_031_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=J32_031_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=J32R_041_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=J32R_041_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=L33R_118_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=L33R_118_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=D10_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=D10_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=R50_092_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=R50_092_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=R20_091_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=R20_091_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=C11X_028_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=C11X_028_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=C13R_128_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=C13R_128_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=SC11X_024_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=SC11X_024_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=XFK_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=XFK_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=AGT32_151_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=AGT32_151_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=T30_002_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=T30_002_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=T32_117_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=T32_117_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=T33_163_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=T33_163_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=T31_026_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=T31_026_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=T31R_045_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=T31R_045_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=T32RR_140_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=T32RR_140_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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&localMarketOnly=true&model=T32R_127_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=T32R_127_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&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>
|
||||||
38
apps/api/src/integrations/pl24/__fixtures__/p4_opel.html
Normal file
38
apps/api/src/integrations/pl24/__fixtures__/p4_opel.html
Normal 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&lang=en&mainGroupId=394&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=395&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=396&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=397&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=398&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=399&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=400&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=401&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=402&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=403&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=404&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=405&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=406&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=407&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=408&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=409&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=410&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=741&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=742&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=743&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=744&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=745&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=746&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=747&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=748&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=749&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=750&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=751&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=752&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=753&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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&lang=en&mainGroupId=754&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&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>
|
||||||
198
apps/api/src/integrations/pl24/__fixtures__/p5_volvo_en.json
Normal file
198
apps/api/src/integrations/pl24/__fixtures__/p5_volvo_en.json
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
{
|
||||||
|
"link": {
|
||||||
|
"wid": "mainGroupTable",
|
||||||
|
"path": "/p5volvo/extern/groups/vin/mainGroup?lang=en&model=308&modelYear=1620&partnerGroup=46&serviceName=volvo_parts&upds=2026-08-24--11-02&vin=YV1AS84ABD1168166"
|
||||||
|
},
|
||||||
|
"segments": {
|
||||||
|
"vinfoBasic": {
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Vehicle Identification No.",
|
||||||
|
"value": "YV1AS84ABD1168166"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Year",
|
||||||
|
"value": "2013"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Model",
|
||||||
|
"value": "S80 (07\\-)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Km/h or mph",
|
||||||
|
"value": "K"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Factory code",
|
||||||
|
"value": "21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Steering gear prod no",
|
||||||
|
"value": "31360538"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Structure week",
|
||||||
|
"value": "201236"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Type",
|
||||||
|
"value": "S80"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Chassis",
|
||||||
|
"value": "168166"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Partner group",
|
||||||
|
"value": "Europe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Upholstery/interior code",
|
||||||
|
"value": "210100"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Upholstery/interior",
|
||||||
|
"value": "LEATHER/ANTHR/QRTZCEIL/NV"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Exterior color",
|
||||||
|
"value": "61400"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Exterior color",
|
||||||
|
"value": "WHITE SOLID ICE WHITE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Body style code",
|
||||||
|
"value": "0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Body style",
|
||||||
|
"value": "Sedan"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Special vehicle code",
|
||||||
|
"value": " "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Special vehicles",
|
||||||
|
"value": " "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Sales type",
|
||||||
|
"value": "42"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Sales type",
|
||||||
|
"value": "SALES VERSION 42"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Market code",
|
||||||
|
"value": "49"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Market",
|
||||||
|
"value": "TR"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Engine Code",
|
||||||
|
"value": "84"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Engine",
|
||||||
|
"value": "D4162T"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Engine part no",
|
||||||
|
"value": "6906309"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Engine serial no",
|
||||||
|
"value": "00000000000004138845 / 0ELD61 2208122233587"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Transmission Code",
|
||||||
|
"value": "B"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Transmission",
|
||||||
|
"value": "6\\-PSHIFT 2WD / MPS6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Transmission part no",
|
||||||
|
"value": "1285041"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Transmission serial no",
|
||||||
|
"value": "00AWBB1 170812170654"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Chassis code",
|
||||||
|
"value": "35659B7A6276"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
198
apps/api/src/integrations/pl24/__fixtures__/p5_volvo_tr.json
Normal file
198
apps/api/src/integrations/pl24/__fixtures__/p5_volvo_tr.json
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
{
|
||||||
|
"link": {
|
||||||
|
"wid": "mainGroupTable",
|
||||||
|
"path": "/p5volvo/extern/groups/vin/mainGroup?lang=tr&model=308&modelYear=1620&partnerGroup=46&serviceName=volvo_parts&upds=2026-08-24--11-02&vin=YV1AS84ABD1168166"
|
||||||
|
},
|
||||||
|
"segments": {
|
||||||
|
"vinfoBasic": {
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Sasi numarasi",
|
||||||
|
"value": "YV1AS84ABD1168166"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Model yili",
|
||||||
|
"value": "2013"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Model",
|
||||||
|
"value": "S80 (07\\-)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Hız Birimi",
|
||||||
|
"value": "K"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Fabrika Kodu",
|
||||||
|
"value": "21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Direksiyon Kutusu Üretim No",
|
||||||
|
"value": "31360538"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Üretim Haftası",
|
||||||
|
"value": "201236"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Türü",
|
||||||
|
"value": "S80"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Şasi",
|
||||||
|
"value": "168166"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Ortak grubu",
|
||||||
|
"value": "Europe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Döşeme/iç mekan kodu",
|
||||||
|
"value": "210100"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Döşeme",
|
||||||
|
"value": "LEATHER/ANTHR/QRTZCEIL/NV"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Dis rengi",
|
||||||
|
"value": "61400"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Dis rengi",
|
||||||
|
"value": "WHITE SOLID ICE WHITE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Karoseri Tipi Kodu",
|
||||||
|
"value": "0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Kaporta Stili",
|
||||||
|
"value": "Sedan"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Özel Araç Kodu",
|
||||||
|
"value": " "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Özel araçlar",
|
||||||
|
"value": " "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Satis tipi",
|
||||||
|
"value": "42"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Satis tipi",
|
||||||
|
"value": "SALES VERSION 42"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Piyasa Kodu",
|
||||||
|
"value": "49"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Market",
|
||||||
|
"value": "TR"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Motor kodu",
|
||||||
|
"value": "84"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Motor",
|
||||||
|
"value": "D4162T"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Motor Parça No",
|
||||||
|
"value": "6906309"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Motor Seri Numarası",
|
||||||
|
"value": "00000000000004138845 / 0ELD61 2208122233587"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Şanzıman kodu",
|
||||||
|
"value": "B"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Şanzıman",
|
||||||
|
"value": "6\\-PSHIFT 2WD / MPS6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Şanzıman Parça No",
|
||||||
|
"value": "1285041"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Şanzıman Seri No",
|
||||||
|
"value": "00AWBB1 170812170654"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"description": "Şasi Kodu",
|
||||||
|
"value": "35659B7A6276"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
492
apps/api/src/integrations/pl24/pl24-auth.service.spec.ts
Normal file
492
apps/api/src/integrations/pl24/pl24-auth.service.spec.ts
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PL24AuthService } from "./pl24-auth.service";
|
||||||
|
import { PL24BudgetExceededError } from "./pl24-budget.service";
|
||||||
|
|
||||||
|
// Oturum modeli (1 login → PL24TOKEN çerezi → yalnız authorize ile yenileme),
|
||||||
|
// PL24_TR_DISABLED köprüsü ve login devre kesici. Ağ yok: global fetch stub'ı;
|
||||||
|
// private durum repo genelindeki `as unknown as` kalıbıyla okunur.
|
||||||
|
|
||||||
|
type RedisStub = {
|
||||||
|
store: Map<string, unknown>;
|
||||||
|
get: (k: string) => Promise<string | null>;
|
||||||
|
set: (k: string, v: string, ttl?: number) => Promise<void>;
|
||||||
|
exists: (k: string) => Promise<boolean>;
|
||||||
|
getJson: (k: string) => Promise<unknown>;
|
||||||
|
setJson: (k: string, v: unknown) => Promise<void>;
|
||||||
|
del: (k: string) => Promise<void>;
|
||||||
|
setNx: (k: string, v: string, ttl: number) => Promise<boolean>;
|
||||||
|
incr: (k: string) => Promise<number>;
|
||||||
|
expire: (k: string, ttl: number) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeRedis = (opts: { lockTaken?: boolean } = {}): RedisStub => {
|
||||||
|
const store = new Map<string, unknown>();
|
||||||
|
const counters = new Map<string, number>();
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
get: async (k: string) => (store.has(k) ? String(store.get(k)) : null),
|
||||||
|
set: async (k: string, v: string) => {
|
||||||
|
store.set(k, v);
|
||||||
|
},
|
||||||
|
exists: async (k: string) => store.has(k),
|
||||||
|
getJson: async (k: string) => store.get(k) ?? null,
|
||||||
|
setJson: async (k: string, v: unknown) => {
|
||||||
|
store.set(k, v);
|
||||||
|
},
|
||||||
|
del: async (k: string) => {
|
||||||
|
store.delete(k);
|
||||||
|
},
|
||||||
|
// Gerçek SET NX semantiği: var olan anahtarı ikinci kez yazmaz. Alarm
|
||||||
|
// tekrarının bastırılması buna dayanıyor.
|
||||||
|
setNx: async (k: string, v: string) => {
|
||||||
|
if (opts.lockTaken && k.includes("login-lock")) return false;
|
||||||
|
if (store.has(k)) return false;
|
||||||
|
store.set(k, v);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
incr: async (k: string) => {
|
||||||
|
const n = (counters.get(k) ?? 0) + 1;
|
||||||
|
counters.set(k, n);
|
||||||
|
return n;
|
||||||
|
},
|
||||||
|
expire: async () => {},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Bütçe servisi stub'ı: sayar ama engellemez, telemetriyi yutar. */
|
||||||
|
const makeBudget = () => ({
|
||||||
|
consumed: [] as string[],
|
||||||
|
recorded: [] as unknown[],
|
||||||
|
consume: async function (kind: string) {
|
||||||
|
this.consumed.push(kind);
|
||||||
|
},
|
||||||
|
record: function (e: unknown) {
|
||||||
|
this.recorded.push(e);
|
||||||
|
},
|
||||||
|
spentToday: async () => 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeService = (cfgOverrides: Record<string, string> = {}, redis = makeRedis()) => {
|
||||||
|
const cfg: Record<string, string> = {
|
||||||
|
"pl24.companyCode": "tr-000000",
|
||||||
|
"pl24.username": "admin",
|
||||||
|
"pl24.password": "pw-tr",
|
||||||
|
"pl24.companyCode2": "de-000000",
|
||||||
|
"pl24.username2": "admin",
|
||||||
|
"pl24.password2": "pw-de",
|
||||||
|
"pl24.proxyDe": "http://u:p@127.0.0.1:9",
|
||||||
|
...cfgOverrides,
|
||||||
|
};
|
||||||
|
const configService = { get: (k: string, d?: unknown) => cfg[k] ?? d } as never;
|
||||||
|
const budget = makeBudget();
|
||||||
|
const telegram = {
|
||||||
|
sent: [] as string[],
|
||||||
|
isConfigured: () => true,
|
||||||
|
send: async function (t: string) {
|
||||||
|
this.sent.push(t);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
svc: new PL24AuthService(configService, redis as never, budget as never, telegram as never),
|
||||||
|
redis,
|
||||||
|
budget,
|
||||||
|
telegram,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type Exposed = {
|
||||||
|
effectiveAccount(account: "tr" | "de"): "tr" | "de";
|
||||||
|
login(account: "tr" | "de"): Promise<unknown>;
|
||||||
|
ensureSession(account: "tr" | "de"): Promise<{ sessionToken: string }>;
|
||||||
|
sessions: Record<string, { sessionToken: string } | undefined>;
|
||||||
|
serviceTokens: Record<"tr" | "de", Map<string, { token: string; expiresAt: number }>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Portal login yanıtı: {loginStatus, sessionToken} + Set-Cookie PL24TOKEN. */
|
||||||
|
const loginOk = (token = "sess-token-1") => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
headers: {
|
||||||
|
get: (h: string) => (h.toLowerCase() === "set-cookie" ? `PL24TOKEN=${token}; Path=/` : null),
|
||||||
|
getSetCookie: () => [`PL24TOKEN=${token}; Path=/; Secure`],
|
||||||
|
},
|
||||||
|
json: async () => ({ loginStatus: "OK", sessionToken: token }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const jwt = (payload: Record<string, unknown>) =>
|
||||||
|
`h.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.s`;
|
||||||
|
|
||||||
|
const authorizeOk = (scope = "vw_parts pl24-usage", sessionStatus = "alive") => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
headers: { get: () => null, getSetCookie: () => [] },
|
||||||
|
json: async () => ({
|
||||||
|
access_token: jwt({ exp: Math.floor(Date.now() / 1000) + 600, sid: "sess-token-1" }),
|
||||||
|
expires_in: 600,
|
||||||
|
scope,
|
||||||
|
session_status: sessionStatus,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const problem = (status: number, type: string) => ({
|
||||||
|
ok: false,
|
||||||
|
status,
|
||||||
|
statusText: "Precondition Failed",
|
||||||
|
headers: { get: () => null, getSetCookie: () => [] },
|
||||||
|
json: async () => ({ type, title: type, detail: type }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedFlag = process.env.PL24_TR_DISABLED;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// "" ≠ "true" → bayrak kapalı; delete yerine atama (biome noDelete).
|
||||||
|
process.env.PL24_TR_DISABLED = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.PL24_TR_DISABLED = savedFlag ?? "";
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PL24AuthService — PL24_TR_DISABLED tr→de köprüsü", () => {
|
||||||
|
it("bayrak kapalıyken hesaplar olduğu gibi kalır", () => {
|
||||||
|
const { svc } = makeService();
|
||||||
|
const p = svc as unknown as Exposed;
|
||||||
|
expect(p.effectiveAccount("tr")).toBe("tr");
|
||||||
|
expect(p.effectiveAccount("de")).toBe("de");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bayrak açıkken tr → de'ye maplenir, de değişmez", () => {
|
||||||
|
process.env.PL24_TR_DISABLED = "true";
|
||||||
|
const { svc } = makeService();
|
||||||
|
const p = svc as unknown as Exposed;
|
||||||
|
expect(p.effectiveAccount("tr")).toBe("de");
|
||||||
|
expect(p.effectiveAccount("de")).toBe("de");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("de hesabı tanımlı değilse mapleme yapılmaz (duvara yönlendirme yok)", () => {
|
||||||
|
process.env.PL24_TR_DISABLED = "true";
|
||||||
|
const { svc } = makeService({ "pl24.companyCode2": "" });
|
||||||
|
expect((svc as unknown as Exposed).effectiveAccount("tr")).toBe("tr");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bayrak açıkken tr login'i ağa çıkmadan reddedilir", async () => {
|
||||||
|
process.env.PL24_TR_DISABLED = "true";
|
||||||
|
const fetchSpy = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const { svc } = makeService();
|
||||||
|
await expect((svc as unknown as Exposed).login("tr")).rejects.toThrow(/PL24_TR_DISABLED/);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legacy clearTokens (tr) bayrak altında de servis token'larını temizler", () => {
|
||||||
|
process.env.PL24_TR_DISABLED = "true";
|
||||||
|
const { svc } = makeService();
|
||||||
|
const p = svc as unknown as Exposed;
|
||||||
|
p.serviceTokens.de.set("vw_parts", { token: "x", expiresAt: Date.now() + 60_000 });
|
||||||
|
svc.clearTokens();
|
||||||
|
expect(p.serviceTokens.de.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PL24AuthService — oturum modeli", () => {
|
||||||
|
it("bir kez login eder, sonraki çağrılar aynı oturumu kullanır (yeniden login yok)", async () => {
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(loginOk());
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const { svc } = makeService();
|
||||||
|
|
||||||
|
const c1 = await svc.getSessionCookieForAccount("de");
|
||||||
|
const c2 = await svc.getSessionCookieForAccount("de");
|
||||||
|
|
||||||
|
expect(c1).toBe("PL24TOKEN=sess-token-1");
|
||||||
|
expect(c2).toBe(c1);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("eşzamanlı çağrılar tek login paylaşır (single-flight)", async () => {
|
||||||
|
const fetchSpy = vi.fn().mockImplementation(async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
return loginOk();
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const { svc } = makeService();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
svc.getSessionCookieForAccount("de"),
|
||||||
|
svc.getSessionCookieForAccount("de"),
|
||||||
|
svc.getSessionCookieForAccount("de"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("başka bir süreç oturumu tutuyorsa Redis'ten devralır, login etmez", async () => {
|
||||||
|
const redis = makeRedis();
|
||||||
|
redis.store.set("pl24:auth:session:de", {
|
||||||
|
sessionToken: "shared-token",
|
||||||
|
loginAt: Date.now(),
|
||||||
|
lastOkAt: Date.now(),
|
||||||
|
});
|
||||||
|
const fetchSpy = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const { svc } = makeService({}, redis);
|
||||||
|
|
||||||
|
expect(await svc.getSessionCookieForAccount("de")).toBe("PL24TOKEN=shared-token");
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("login sadece çerez mint eder; servis token'ı authorize'dan gelir (Bearer'sız)", async () => {
|
||||||
|
const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
|
||||||
|
if (String(url).includes("/login")) return loginOk();
|
||||||
|
return authorizeOk();
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const { svc } = makeService();
|
||||||
|
|
||||||
|
await svc.authorizeServiceForAccount("vw_parts", "de");
|
||||||
|
|
||||||
|
const authorizeCall = fetchSpy.mock.calls.find((c) => String(c[0]).includes("/authorize"));
|
||||||
|
expect(authorizeCall).toBeTruthy();
|
||||||
|
const headers = (authorizeCall?.[1] as { headers: Record<string, string> }).headers;
|
||||||
|
expect(headers.Cookie).toBe("PL24TOKEN=sess-token-1");
|
||||||
|
expect(headers.Authorization).toBeUndefined();
|
||||||
|
expect(headers["User-Agent"]).toMatch(/Chrome\/\d/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("servis token'ı süresi dolmadan yeniden authorize edilmez", async () => {
|
||||||
|
const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
|
||||||
|
if (String(url).includes("/login")) return loginOk();
|
||||||
|
return authorizeOk();
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const { svc } = makeService();
|
||||||
|
|
||||||
|
await svc.authorizeServiceForAccount("vw_parts", "de");
|
||||||
|
await svc.authorizeServiceForAccount("vw_parts", "de");
|
||||||
|
|
||||||
|
const authorizeCalls = fetchSpy.mock.calls.filter((c) => String(c[0]).includes("/authorize"));
|
||||||
|
expect(authorizeCalls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("session_status=gone → oturumu düşürür, bir kez yeniden login edip devam eder", async () => {
|
||||||
|
let authorizeCalls = 0;
|
||||||
|
const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
|
||||||
|
if (String(url).includes("/login")) return loginOk(`sess-${authorizeCalls}`);
|
||||||
|
authorizeCalls += 1;
|
||||||
|
return authorizeCalls === 1 ? authorizeOk("vw_parts", "gone") : authorizeOk();
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const { svc } = makeService();
|
||||||
|
|
||||||
|
const token = await svc.authorizeServiceForAccount("vw_parts", "de");
|
||||||
|
|
||||||
|
expect(token).toBeTruthy();
|
||||||
|
expect(authorizeCalls).toBe(2);
|
||||||
|
expect(fetchSpy.mock.calls.filter((c) => String(c[0]).includes("/login"))).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("authorize 401 → oturumu düşürür ve tek sefer yeniden dener", async () => {
|
||||||
|
let authorizeCalls = 0;
|
||||||
|
const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
|
||||||
|
if (String(url).includes("/login")) return loginOk();
|
||||||
|
authorizeCalls += 1;
|
||||||
|
if (authorizeCalls === 1) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
statusText: "Unauthorized",
|
||||||
|
headers: { get: () => null, getSetCookie: () => [] },
|
||||||
|
json: async () => ({}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return authorizeOk();
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const { svc } = makeService();
|
||||||
|
|
||||||
|
await expect(svc.authorizeServiceForAccount("vw_parts", "de")).resolves.toBeTruthy();
|
||||||
|
expect(authorizeCalls).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("oturum sınırı aşıldı (412) → tek sefer squeezeOut ile tekrar dener", async () => {
|
||||||
|
const bodies: string[] = [];
|
||||||
|
let loginCalls = 0;
|
||||||
|
const fetchSpy = vi.fn().mockImplementation(async (url: string, opts: { body: string }) => {
|
||||||
|
if (!String(url).includes("/login")) return authorizeOk();
|
||||||
|
bodies.push(opts.body);
|
||||||
|
loginCalls += 1;
|
||||||
|
return loginCalls === 1
|
||||||
|
? problem(412, "urn:login:session-limit-exceeded")
|
||||||
|
: loginOk("after-squeeze");
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const { svc } = makeService();
|
||||||
|
|
||||||
|
expect(await svc.getSessionCookieForAccount("de")).toBe("PL24TOKEN=after-squeeze");
|
||||||
|
expect(JSON.parse(bodies[0]).squeezeOut).toBe(false);
|
||||||
|
expect(JSON.parse(bodies[1]).squeezeOut).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("P4 header'ı yalnız çerez taşır (authorize isteği atmaz)", async () => {
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(loginOk());
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const { svc } = makeService();
|
||||||
|
|
||||||
|
const headers = await svc.buildFordLegacyHeadersForAccount("opel_parts", "de");
|
||||||
|
|
||||||
|
expect(headers.Cookie).toBe("PL24TOKEN=sess-token-1");
|
||||||
|
expect(headers.Authorization).toBeUndefined();
|
||||||
|
expect(fetchSpy.mock.calls.filter((c) => String(c[0]).includes("/authorize"))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ford hintstoken değeri oturum token'ıdır", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginOk("hint-1")));
|
||||||
|
const { svc } = makeService();
|
||||||
|
await svc.getSessionCookieForAccount("de");
|
||||||
|
expect(svc.getPL24TokenValueForAccount("de")).toBe("hint-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PL24AuthService — login devre kesici", () => {
|
||||||
|
it("3 ardışık başarısız login'den sonra açılır ve yeni ağ denemesini keser", async () => {
|
||||||
|
const fetchSpy = vi.fn().mockRejectedValue(new Error("network down"));
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const { svc } = makeService();
|
||||||
|
const p = svc as unknown as Exposed;
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await expect(p.login("de")).rejects.toThrow(/giris hatasi/);
|
||||||
|
}
|
||||||
|
// Ağ hatasında squeezeOut denemesi yapılmaz → login başına tek fetch.
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
await expect(p.login("de")).rejects.toThrow(/breaker open/);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hesap kapalı (account-not-active) → ilk hatada uzun süreli kesici", async () => {
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(problem(400, "urn:login:account-not-active"));
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const { svc } = makeService();
|
||||||
|
const p = svc as unknown as Exposed;
|
||||||
|
|
||||||
|
await expect(p.login("de")).rejects.toThrow(/giris hatasi/);
|
||||||
|
await expect(p.login("de")).rejects.toThrow(/breaker open/);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("başarılı login kesici sayacını sıfırlar", async () => {
|
||||||
|
let failFirst = 2;
|
||||||
|
const fetchSpy = vi.fn().mockImplementation(async () => {
|
||||||
|
if (failFirst > 0) {
|
||||||
|
failFirst -= 1;
|
||||||
|
throw new Error("network down");
|
||||||
|
}
|
||||||
|
return loginOk();
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const { svc } = makeService();
|
||||||
|
const p = svc as unknown as Exposed;
|
||||||
|
|
||||||
|
await expect(p.login("de")).rejects.toThrow();
|
||||||
|
await expect(p.login("de")).rejects.toThrow();
|
||||||
|
await expect(p.login("de")).resolves.toBeTruthy();
|
||||||
|
await expect(p.login("de")).resolves.toBeTruthy();
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saatlik login tavanı aşılırsa yeni login denenmez", async () => {
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(loginOk());
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const { svc } = makeService();
|
||||||
|
const p = svc as unknown as Exposed;
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i++) await p.login("de");
|
||||||
|
await expect(p.login("de")).rejects.toThrow(/login rate limit/);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PL24AuthService — 1 saatlik kesinti alarmı (Telegram)", () => {
|
||||||
|
const loginFails = () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
statusText: "Bad Request",
|
||||||
|
headers: { get: () => null, getSetCookie: () => [] },
|
||||||
|
json: async () => ({ type: "urn:login:account-not-active", detail: "account not active" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ilk saat içinde alarm göndermez", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginFails()));
|
||||||
|
const { svc, telegram } = makeService();
|
||||||
|
await expect((svc as unknown as Exposed).login("de")).rejects.toThrow();
|
||||||
|
expect(telegram.sent).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kesinti 1 saati geçince tek sefer alarm gönderir", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginFails()));
|
||||||
|
const redis = makeRedis();
|
||||||
|
// Kesinti 70 dakika önce başlamış gibi davran (paylaşılan Redis saati).
|
||||||
|
redis.store.set("pl24:auth:fail-since:de", String(Date.now() - 70 * 60_000));
|
||||||
|
const { svc, telegram } = makeService({}, redis);
|
||||||
|
|
||||||
|
await expect((svc as unknown as Exposed).login("de")).rejects.toThrow();
|
||||||
|
await new Promise((r) => setTimeout(r, 10)); // void alarm promise'i
|
||||||
|
|
||||||
|
expect(telegram.sent).toHaveLength(1);
|
||||||
|
expect(telegram.sent[0]).toContain("PL24 giriş yapılamıyor");
|
||||||
|
expect(telegram.sent[0]).toContain("70 dakika");
|
||||||
|
|
||||||
|
// Kesici açık olsa bile ikinci kez sızlanmaz (alerted anahtarı).
|
||||||
|
await expect((svc as unknown as Exposed).login("de")).rejects.toThrow();
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
expect(telegram.sent).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("giriş düzelince kurtarma mesajı gönderir ve saati sıfırlar", async () => {
|
||||||
|
const redis = makeRedis();
|
||||||
|
redis.store.set("pl24:auth:fail-since:de", String(Date.now() - 90 * 60_000));
|
||||||
|
redis.store.set("pl24:auth:alerted:de", "1");
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginOk()));
|
||||||
|
const { svc, telegram } = makeService({}, redis);
|
||||||
|
|
||||||
|
await svc.getSessionCookieForAccount("de");
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
expect(telegram.sent.some((t) => t.includes("PL24 girişi düzeldi"))).toBe(true);
|
||||||
|
expect(redis.store.has("pl24:auth:fail-since:de")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kendi günlük tavanımız dolduğunda bu bir GİRİŞ HATASI değildir.
|
||||||
|
*
|
||||||
|
* Prod 2026-09-21: tavan dolunca `attemptLogin` içindeki `budget.consume()`
|
||||||
|
* fırlattı, dış `catch` bunu `{ error }` düzleştirdi, `loginWithLock` giriş
|
||||||
|
* hatası sanıp devre kesiciyi tetikledi ve kesinti sayacını başlattı — bir saat
|
||||||
|
* sonra Telegram "hesap banlanmış olabilir" dedi. Hesap sağlamdı (tarayıcıdan
|
||||||
|
* giriş çalışıyordu, aynı gün 143 başarılı auth ve 0 × 401).
|
||||||
|
*/
|
||||||
|
describe("bütçe reddi ban alarmı üretmemeli", () => {
|
||||||
|
it("bütçe hatası olduğu gibi yukarı çıkar, giriş hatasına çevrilmez", async () => {
|
||||||
|
const { svc, redis, telegram, budget } = makeService();
|
||||||
|
budget.consume = async () => {
|
||||||
|
throw new PL24BudgetExceededError("user", 1205, 1200);
|
||||||
|
};
|
||||||
|
const fetchSpy = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
|
||||||
|
await expect((svc as never as Exposed).login("de")).rejects.toBeInstanceOf(
|
||||||
|
PL24BudgetExceededError,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kesinti sayacı başlamamalı → Telegram susmalı.
|
||||||
|
expect(telegram.sent).toHaveLength(0);
|
||||||
|
const failKeys = Object.keys(redis.store ?? {}).filter((k) => k.includes("fail-since"));
|
||||||
|
expect(failKeys).toHaveLength(0);
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
166
apps/api/src/integrations/pl24/pl24-budget.service.spec.ts
Normal file
166
apps/api/src/integrations/pl24/pl24-budget.service.spec.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { backfillContext } from "../../jobs/prefetch-context";
|
||||||
|
import { PL24BudgetExceededError, PL24BudgetService } from "./pl24-budget.service";
|
||||||
|
|
||||||
|
// Günlük PL24 HTTP tavanı + kullanıcı rezervi + proxy_logs telemetrisi.
|
||||||
|
// Redis ve telemetri stub'lanır; ağ yok.
|
||||||
|
|
||||||
|
const makeRedis = (opts: { fail?: boolean } = {}) => {
|
||||||
|
const counters = new Map<string, number>();
|
||||||
|
return {
|
||||||
|
counters,
|
||||||
|
incr: async (k: string) => {
|
||||||
|
if (opts.fail) throw new Error("redis down");
|
||||||
|
const n = (counters.get(k) ?? 0) + 1;
|
||||||
|
counters.set(k, n);
|
||||||
|
return n;
|
||||||
|
},
|
||||||
|
expire: async () => {},
|
||||||
|
get: async (k: string) => (counters.has(k) ? String(counters.get(k)) : null),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeTelemetry = () => {
|
||||||
|
const events: Record<string, unknown>[] = [];
|
||||||
|
return { events, record: (e: Record<string, unknown>) => events.push(e) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const make = (env: Record<string, string> = {}, redis = makeRedis()) => {
|
||||||
|
for (const [k, v] of Object.entries(env)) vi.stubEnv(k, v);
|
||||||
|
const telemetry = makeTelemetry();
|
||||||
|
return { svc: new PL24BudgetService(redis as never, telemetry as never), redis, telemetry };
|
||||||
|
};
|
||||||
|
|
||||||
|
const inBackfill = <T>(fn: () => Promise<T>) => backfillContext.run(true, fn);
|
||||||
|
|
||||||
|
describe("PL24BudgetService — günlük tavan", () => {
|
||||||
|
it("tavan altında çağrılara izin verir", async () => {
|
||||||
|
const { svc } = make({ PL24_HTTP_DAILY_MAX: "5" });
|
||||||
|
for (let i = 0; i < 5; i++) await svc.consume("catalog");
|
||||||
|
expect(await svc.spentToday()).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tavan aşılınca kullanıcı çağrısını da reddeder", async () => {
|
||||||
|
const { svc } = make({ PL24_HTTP_DAILY_MAX: "3" });
|
||||||
|
for (let i = 0; i < 3; i++) await svc.consume("catalog");
|
||||||
|
await expect(svc.consume("catalog")).rejects.toBeInstanceOf(PL24BudgetExceededError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("backfill kullanıcı rezervine dokunamaz, kullanıcı trafiği devam eder", async () => {
|
||||||
|
// tavan 10, rezerv %40 → backfill 6'da durur, kullanıcı 10'a kadar sürer
|
||||||
|
const { svc } = make({ PL24_HTTP_DAILY_MAX: "10", PL24_HTTP_USER_RESERVE: "0.4" });
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i++) await inBackfill(() => svc.consume("catalog"));
|
||||||
|
await expect(inBackfill(() => svc.consume("catalog"))).rejects.toBeInstanceOf(
|
||||||
|
PL24BudgetExceededError,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aynı gün, aynı sayaç: kullanıcı hâlâ geçebilmeli (7., 8. … 10. istek)
|
||||||
|
await expect(svc.consume("catalog")).resolves.toBeUndefined();
|
||||||
|
await expect(svc.consume("catalog")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reddedilen çağrı yalnız reddedildiği yerde sayılır (ağ isteği yok)", async () => {
|
||||||
|
const { svc, telemetry } = make({ PL24_HTTP_DAILY_MAX: "1" });
|
||||||
|
await svc.consume("catalog");
|
||||||
|
await expect(svc.consume("catalog")).rejects.toBeInstanceOf(PL24BudgetExceededError);
|
||||||
|
expect(telemetry.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Redis düşükse PL24'ü bloklamaz (fail-open)", async () => {
|
||||||
|
const { svc } = make({ PL24_HTTP_DAILY_MAX: "1" }, makeRedis({ fail: true }));
|
||||||
|
await expect(svc.consume("catalog")).resolves.toBeUndefined();
|
||||||
|
await expect(svc.consume("catalog")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PL24BudgetService — telemetri", () => {
|
||||||
|
it("katalog çağrısını proxy_logs biçiminde kaydeder", () => {
|
||||||
|
const { svc, telemetry } = make();
|
||||||
|
svc.record({
|
||||||
|
kind: "catalog",
|
||||||
|
url: "https://www.partslink24.com/p5psa/extern/group/vin/scope?vin=X",
|
||||||
|
proxied: true,
|
||||||
|
account: "de",
|
||||||
|
statusCode: 200,
|
||||||
|
success: true,
|
||||||
|
startedAt: Date.now() - 120,
|
||||||
|
});
|
||||||
|
const e = telemetry.events[0];
|
||||||
|
expect(e.service).toBe("pl24_http");
|
||||||
|
expect(e.provider).toBe("dataimpulse");
|
||||||
|
expect(e.targetHost).toBe("www.partslink24.com");
|
||||||
|
expect(e.success).toBe(true);
|
||||||
|
expect(e.errorKind).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("login çağrısını pl24_auth olarak ayırır", () => {
|
||||||
|
const { svc, telemetry } = make();
|
||||||
|
svc.record({
|
||||||
|
kind: "auth",
|
||||||
|
url: "https://www.partslink24.com/auth/ext/api/1.1/login",
|
||||||
|
proxied: false,
|
||||||
|
account: "tr",
|
||||||
|
statusCode: 400,
|
||||||
|
success: false,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
});
|
||||||
|
expect(telemetry.events[0].service).toBe("pl24_auth");
|
||||||
|
expect(telemetry.events[0].provider).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("403/429'u ban sinyali olarak işaretler", () => {
|
||||||
|
const { svc, telemetry } = make();
|
||||||
|
svc.record({
|
||||||
|
kind: "catalog",
|
||||||
|
url: "https://www.partslink24.com/x",
|
||||||
|
proxied: true,
|
||||||
|
account: "de",
|
||||||
|
statusCode: 403,
|
||||||
|
success: false,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
});
|
||||||
|
expect(telemetry.events[0].errorKind).toBe("banned");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("taşıma hatasını sınıflandırır", () => {
|
||||||
|
const { svc, telemetry } = make();
|
||||||
|
const err = Object.assign(new Error("fetch failed"), { name: "TimeoutError" });
|
||||||
|
svc.record({
|
||||||
|
kind: "catalog",
|
||||||
|
url: "https://www.partslink24.com/x",
|
||||||
|
proxied: true,
|
||||||
|
account: "de",
|
||||||
|
success: false,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
expect(telemetry.events[0].errorKind).toBe("timeout");
|
||||||
|
expect(telemetry.events[0].statusCode).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bütçe reddi bir giriş hatası DEĞİLDİR (plv2.md, bulgu auth-16).
|
||||||
|
*
|
||||||
|
* Prod 2026-09-21: günlük tavan dolunca `attemptLogin` içindeki
|
||||||
|
* `budget.consume()` fırlattı, dış `catch` bunu `{ error }` düzleştirdi,
|
||||||
|
* `loginWithLock` bunu giriş hatası sanıp devre kesiciyi tetikledi ve kesinti
|
||||||
|
* sayacını başlattı → bir saat sonra Telegram "hesap banlanmış olabilir" dedi.
|
||||||
|
* Oysa hesap sağlamdı: tarayıcıdan giriş çalışıyordu, aynı gün 143 başarılı
|
||||||
|
* auth çağrısı ve 0 × 401 vardı.
|
||||||
|
*/
|
||||||
|
describe("PL24BudgetExceededError — kendi frenimiz, upstream hatası değil", () => {
|
||||||
|
it("kendi hata sınıfını taşır, düz Error değil", () => {
|
||||||
|
const err = new PL24BudgetExceededError("user", 1205, 1200);
|
||||||
|
expect(err).toBeInstanceOf(PL24BudgetExceededError);
|
||||||
|
expect(err).toBeInstanceOf(Error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mesajı hangi şeridin ve hangi sayının durduğunu söyler", () => {
|
||||||
|
const err = new PL24BudgetExceededError("user", 1205, 1200);
|
||||||
|
expect(err.message).toContain("1205");
|
||||||
|
expect(err.message).toContain("1200");
|
||||||
|
expect(err.message.toLowerCase()).toContain("user");
|
||||||
|
});
|
||||||
|
});
|
||||||
145
apps/api/src/integrations/pl24/pl24-budget.service.ts
Normal file
145
apps/api/src/integrations/pl24/pl24-budget.service.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { isBackfillContext } from "../../jobs/prefetch-context";
|
||||||
|
import { RedisService } from "../../redis/redis.service";
|
||||||
|
import {
|
||||||
|
ProxyTelemetryService,
|
||||||
|
classifyTransportError,
|
||||||
|
isBanStatus,
|
||||||
|
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PL24 HTTP budget + telemetry.
|
||||||
|
*
|
||||||
|
* WHY (see /home/s/ss/plv2.md §2.2, findings consumers_jobs-03/07):
|
||||||
|
* Both PL24 account bans (tr 2026-07-24, de 2026-09-04) followed days that wrote
|
||||||
|
* 5-7k new category rows — roughly 10k+ upstream calls/day — while real user
|
||||||
|
* decodes never exceeded 14/day. The existing guards are all job-level
|
||||||
|
* (jobs/min, jobs/day); nothing counted actual HTTP requests, and PL24 calls
|
||||||
|
* were invisible in `proxy_logs` (only pcat/emex were logged), so the volume was
|
||||||
|
* only reconstructable after the fact from DB row counts.
|
||||||
|
*
|
||||||
|
* This service is the one choke point every PL24 upstream call passes through:
|
||||||
|
* - counts requests per UTC day in Redis and refuses new ones past the cap,
|
||||||
|
* - reserves a share of that cap for real users so a runaway backfill can
|
||||||
|
* never starve a paying customer's decode,
|
||||||
|
* - records every attempt into `proxy_logs` (service `pl24_http`/`pl24_auth`).
|
||||||
|
*
|
||||||
|
* The cap is deliberately low to start (PL24_HTTP_DAILY_MAX, default 1200);
|
||||||
|
* raise it only with telemetry in hand.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PL24BudgetService {
|
||||||
|
private readonly logger = new Logger(PL24BudgetService.name);
|
||||||
|
/** Log "budget exhausted" once per day per lane instead of on every call. */
|
||||||
|
private warnedFor = new Set<string>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
private readonly telemetry: ProxyTelemetryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get dailyMax(): number {
|
||||||
|
const raw = Number(process.env.PL24_HTTP_DAILY_MAX);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 1200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fraction of the daily cap that only user-facing calls may spend. */
|
||||||
|
private get userReserve(): number {
|
||||||
|
const raw = Number(process.env.PL24_HTTP_USER_RESERVE);
|
||||||
|
return Number.isFinite(raw) && raw > 0 && raw < 1 ? raw : 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
private dayKey(): string {
|
||||||
|
return `pl24:http:${new Date().toISOString().slice(0, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count one upstream call and decide whether it may proceed.
|
||||||
|
* Backfill stops at (1 - userReserve) of the cap; user traffic gets the rest.
|
||||||
|
*/
|
||||||
|
async consume(kind: "auth" | "catalog"): Promise<void> {
|
||||||
|
const backfill = isBackfillContext();
|
||||||
|
let spent: number;
|
||||||
|
try {
|
||||||
|
const key = this.dayKey();
|
||||||
|
spent = await this.redis.incr(key);
|
||||||
|
if (spent === 1) await this.redis.expire(key, 8 * 86_400);
|
||||||
|
// Per-lane counters. The shared total tells us WHEN the cap was hit but not
|
||||||
|
// WHO spent it, and sizing the cap needs that split: on 2026-09-21 the
|
||||||
|
// total hit 1200 and every user decode after 16:10 UTC was refused, with
|
||||||
|
// no way to tell from `proxy_logs` how much of it was warm-up prefetch
|
||||||
|
// versus somebody waiting on a screen.
|
||||||
|
const laneKey = `${key}:${backfill ? "worker" : "user"}`;
|
||||||
|
const laneSpent = await this.redis.incr(laneKey);
|
||||||
|
if (laneSpent === 1) await this.redis.expire(laneKey, 8 * 86_400);
|
||||||
|
} catch {
|
||||||
|
return; // Redis down → never block PL24 on telemetry
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = this.dailyMax;
|
||||||
|
const limit = backfill ? Math.floor(max * (1 - this.userReserve)) : max;
|
||||||
|
if (spent > limit) {
|
||||||
|
const lane = backfill ? "backfill" : "user";
|
||||||
|
if (!this.warnedFor.has(`${lane}:${this.dayKey()}`)) {
|
||||||
|
this.warnedFor.add(`${lane}:${this.dayKey()}`);
|
||||||
|
this.logger.warn(
|
||||||
|
`PL24 daily HTTP budget exhausted for the ${lane} lane (${spent}/${limit}, cap ${max}, kind=${kind}) — refusing further calls today`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new PL24BudgetExceededError(lane, spent, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current spend (for /health and the Süper Panel). */
|
||||||
|
async spentToday(): Promise<number> {
|
||||||
|
try {
|
||||||
|
return Number((await this.redis.get(this.dayKey())) ?? 0);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record(opts: {
|
||||||
|
kind: "auth" | "catalog";
|
||||||
|
url: string;
|
||||||
|
proxied: boolean;
|
||||||
|
account: string;
|
||||||
|
statusCode?: number | null;
|
||||||
|
success: boolean;
|
||||||
|
startedAt: number;
|
||||||
|
error?: unknown;
|
||||||
|
}): void {
|
||||||
|
let targetHost: string | null = null;
|
||||||
|
try {
|
||||||
|
targetHost = new URL(opts.url).hostname;
|
||||||
|
} catch {
|
||||||
|
/* keep null */
|
||||||
|
}
|
||||||
|
this.telemetry.record({
|
||||||
|
service: opts.kind === "auth" ? "pl24_auth" : "pl24_http",
|
||||||
|
provider: opts.proxied ? "dataimpulse" : "none",
|
||||||
|
sessionKey: opts.account,
|
||||||
|
targetHost,
|
||||||
|
statusCode: opts.statusCode ?? null,
|
||||||
|
errorKind: opts.error
|
||||||
|
? classifyTransportError(opts.error)
|
||||||
|
: isBanStatus(opts.statusCode)
|
||||||
|
? "banned"
|
||||||
|
: null,
|
||||||
|
success: opts.success,
|
||||||
|
durationMs: Date.now() - opts.startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown when the daily PL24 HTTP budget is used up for this lane. */
|
||||||
|
export class PL24BudgetExceededError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly lane: "user" | "backfill",
|
||||||
|
readonly spent: number,
|
||||||
|
readonly limit: number,
|
||||||
|
) {
|
||||||
|
super(`PL24 daily HTTP budget exhausted (${lane} lane: ${spent}/${limit})`);
|
||||||
|
this.name = "PL24BudgetExceededError";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,6 +91,20 @@ describe("PL24FordLegacyService.parseFordGroupsFromHtml — nav-crumb junk filte
|
|||||||
expect(groups.some((g) => g.linkPath.includes("eu.nissan.biz"))).toBe(false);
|
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", () => {
|
it("keeps Volvo vin-group.action?...group1=... (real category) but drops the bare breadcrumb", () => {
|
||||||
const html = [
|
const html = [
|
||||||
'<tr class="tc-data-row" url="vin-group.action?group1=2&lang=tr" caption="Motor"><td>Motor</td></tr>',
|
'<tr class="tc-data-row" url="vin-group.action?group1=2&lang=tr" caption="Motor"><td>Motor</td></tr>',
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export class PL24FordLegacyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ford / Hyundai-Kia / Nissan / Opel / Volvo devam eder...
|
// 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);
|
const html = await this.fetchVinGroupPage(vin, serviceName, account);
|
||||||
if (!html) return null;
|
if (!html) return null;
|
||||||
@@ -124,8 +124,8 @@ export class PL24FordLegacyService {
|
|||||||
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
||||||
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
||||||
this.logger.warn(`P4 legacy: demo mode for ${serviceName}, retrying with fresh auth`);
|
this.logger.warn(`P4 legacy: demo mode for ${serviceName}, retrying with fresh auth`);
|
||||||
this.authService.clearTokensForAccount(account);
|
await this.authService.dropSessionForAccount(account, "P4 page 401");
|
||||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
await this.authService.ensureSession(account);
|
||||||
|
|
||||||
const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account);
|
const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account);
|
||||||
if (!retryHtml) return null;
|
if (!retryHtml) return null;
|
||||||
@@ -1110,7 +1110,7 @@ export class PL24FordLegacyService {
|
|||||||
this.logger.log(`Ford: fetching vehicle list for ${serviceName}`);
|
this.logger.log(`Ford: fetching vehicle list for ${serviceName}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.authService.authorizeService(serviceName);
|
await this.authService.ensureSession("tr");
|
||||||
|
|
||||||
const config = getServiceConfig(serviceName);
|
const config = getServiceConfig(serviceName);
|
||||||
const basePath = config ? `${config.basePath}/${serviceName}` : `/ford/${serviceName}`;
|
const basePath = config ? `${config.basePath}/${serviceName}` : `/ford/${serviceName}`;
|
||||||
@@ -1126,8 +1126,8 @@ export class PL24FordLegacyService {
|
|||||||
let support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
let support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
||||||
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
||||||
this.logger.warn(`Ford: demo mode for ${serviceName}, retrying with fresh auth`);
|
this.logger.warn(`Ford: demo mode for ${serviceName}, retrying with fresh auth`);
|
||||||
this.authService.clearTokens();
|
await this.authService.dropSessionForAccount("tr", "P4 page 401");
|
||||||
await this.authService.authorizeService(serviceName);
|
await this.authService.ensureSession("tr");
|
||||||
const retryHtml = await this.fetchP4Page(vinGroupUrl, serviceName, true);
|
const retryHtml = await this.fetchP4Page(vinGroupUrl, serviceName, true);
|
||||||
if (!retryHtml) return [];
|
if (!retryHtml) return [];
|
||||||
const retrySupport = this.extractScriptVariable<FordPL24Support>(retryHtml, "PL24_SUPPORT");
|
const retrySupport = this.extractScriptVariable<FordPL24Support>(retryHtml, "PL24_SUPPORT");
|
||||||
@@ -1221,7 +1221,7 @@ export class PL24FordLegacyService {
|
|||||||
this.logger.log(`HyundaiKia: fetching vehicle list for ${serviceName}`);
|
this.logger.log(`HyundaiKia: fetching vehicle list for ${serviceName}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.authService.authorizeService(serviceName);
|
await this.authService.ensureSession("tr");
|
||||||
|
|
||||||
const config = getServiceConfig(serviceName);
|
const config = getServiceConfig(serviceName);
|
||||||
const basePath = config
|
const basePath = config
|
||||||
@@ -1306,7 +1306,7 @@ export class PL24FordLegacyService {
|
|||||||
this.logger.log(`Nissan: fetching vehicle list for ${serviceName}`);
|
this.logger.log(`Nissan: fetching vehicle list for ${serviceName}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.authService.authorizeService(serviceName);
|
await this.authService.ensureSession("tr");
|
||||||
|
|
||||||
const config = getServiceConfig(serviceName);
|
const config = getServiceConfig(serviceName);
|
||||||
const basePath = config ? `${config.basePath}/${serviceName}` : `/nissan/${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}`);
|
this.logger.log(`Opel: fetching vehicle list for ${serviceName}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.authService.authorizeService(serviceName);
|
await this.authService.ensureSession("tr");
|
||||||
|
|
||||||
const config = getServiceConfig(serviceName);
|
const config = getServiceConfig(serviceName);
|
||||||
const basePath = config ? `${config.basePath}/${serviceName}` : `/opel/${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}`);
|
this.logger.log(`Volvo: fetching vehicle list for ${serviceName}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.authService.authorizeService(serviceName);
|
await this.authService.ensureSession("tr");
|
||||||
|
|
||||||
const config = getServiceConfig(serviceName);
|
const config = getServiceConfig(serviceName);
|
||||||
const basePath = config ? `${config.basePath}/${serviceName}` : `/volvo/${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}`);
|
this.logger.log(`Volvo: fetching model config for ${serviceName} mdl=${mdlId}`);
|
||||||
|
|
||||||
await this.authService.authorizeService(serviceName);
|
await this.authService.ensureSession("tr");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = getServiceConfig(serviceName);
|
const config = getServiceConfig(serviceName);
|
||||||
@@ -1626,7 +1626,7 @@ export class PL24FordLegacyService {
|
|||||||
|
|
||||||
this.logger.log(`Ford: fetching model config for ${serviceName} family=${familyId}`);
|
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 config = getServiceConfig(serviceName);
|
||||||
const arch = config?.architecture;
|
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}`,
|
: `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 {
|
try {
|
||||||
const basePath = config ? `${config.basePath}/${serviceName}` : `/ford/${serviceName}`;
|
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.
|
* Fiat services always use 'de'; others use round-robin via Redis.
|
||||||
*/
|
*/
|
||||||
private async resolveAccount(userId?: string, serviceName?: string): Promise<"tr" | "de"> {
|
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.
|
// Fiat always needs de account; Hyundai/Kia/Nissan parts are licensed only on de.
|
||||||
if (serviceName && ["fiatp_parts", "fiatt_parts"].includes(serviceName)) {
|
if (serviceName && ["fiatp_parts", "fiatt_parts"].includes(serviceName)) {
|
||||||
return "de";
|
return "de";
|
||||||
@@ -1953,7 +1957,7 @@ export class PL24FordLegacyService {
|
|||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
account: "tr" | "de",
|
account: "tr" | "de",
|
||||||
): Promise<PL24DecodedVehicle | null> {
|
): Promise<PL24DecodedVehicle | null> {
|
||||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
await this.authService.ensureSession(account);
|
||||||
|
|
||||||
const html = await this.fetchVinGroupPage(vin, serviceName, account);
|
const html = await this.fetchVinGroupPage(vin, serviceName, account);
|
||||||
if (!html) return null;
|
if (!html) return null;
|
||||||
@@ -1961,8 +1965,8 @@ export class PL24FordLegacyService {
|
|||||||
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
||||||
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
||||||
this.logger.warn(`Fiat: demo mode for ${serviceName}, retrying with fresh auth`);
|
this.logger.warn(`Fiat: demo mode for ${serviceName}, retrying with fresh auth`);
|
||||||
this.authService.clearTokensForAccount(account);
|
await this.authService.dropSessionForAccount(account, "P4 demo page (session not accepted)");
|
||||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
await this.authService.ensureSession(account);
|
||||||
|
|
||||||
const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account);
|
const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account);
|
||||||
if (!retryHtml) return null;
|
if (!retryHtml) return null;
|
||||||
@@ -2889,7 +2893,7 @@ export class PL24FordLegacyService {
|
|||||||
private async initPsaSession(
|
private async initPsaSession(
|
||||||
serviceName: string,
|
serviceName: string,
|
||||||
): Promise<{ jsessionId: string; mode: string; upds: string } | null> {
|
): 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);
|
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -3324,7 +3328,7 @@ export class PL24FordLegacyService {
|
|||||||
const isDeOnly = LEGACY_DE_SERVICES.has(serviceName);
|
const isDeOnly = LEGACY_DE_SERVICES.has(serviceName);
|
||||||
const account = isDeOnly ? "de" : accountParam;
|
const account = isDeOnly ? "de" : accountParam;
|
||||||
if (isDeOnly) {
|
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
|
// Some catalogs (Volvo vin-group.action) store hrefs relative to the catalog
|
||||||
// directory (e.g. "vin-group.action?group1=…"). Prefix the service basePath
|
// directory (e.g. "vin-group.action?group1=…"). Prefix the service basePath
|
||||||
@@ -3354,8 +3358,8 @@ export class PL24FordLegacyService {
|
|||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
||||||
this.authService.clearTokensForAccount(account);
|
await this.authService.dropSessionForAccount(account, "P4 page 401");
|
||||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
await this.authService.ensureSession(account);
|
||||||
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(
|
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(
|
||||||
serviceName,
|
serviceName,
|
||||||
account,
|
account,
|
||||||
@@ -3388,8 +3392,11 @@ export class PL24FordLegacyService {
|
|||||||
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
||||||
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
||||||
this.logger.warn(`Ford legacy: demo page for ${serviceName}, re-authing + retry`);
|
this.logger.warn(`Ford legacy: demo page for ${serviceName}, re-authing + retry`);
|
||||||
this.authService.clearTokensForAccount(account);
|
await this.authService.dropSessionForAccount(
|
||||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
account,
|
||||||
|
"P4 demo page (session not accepted)",
|
||||||
|
);
|
||||||
|
await this.authService.ensureSession(account);
|
||||||
return this.fetchP4Page(url, serviceName, isFullUrl, account, true);
|
return this.fetchP4Page(url, serviceName, isFullUrl, account, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3434,8 +3441,8 @@ export class PL24FordLegacyService {
|
|||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
||||||
this.authService.clearTokensForAccount(account);
|
await this.authService.dropSessionForAccount(account, "P4 page 401");
|
||||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
await this.authService.ensureSession(account);
|
||||||
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(
|
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(
|
||||||
serviceName,
|
serviceName,
|
||||||
account,
|
account,
|
||||||
@@ -3648,7 +3655,10 @@ export class PL24FordLegacyService {
|
|||||||
segment.match(/\bjsonurl="([^"]+)"/);
|
segment.match(/\bjsonurl="([^"]+)"/);
|
||||||
if (!urlMatch) continue;
|
if (!urlMatch) continue;
|
||||||
|
|
||||||
const url = urlMatch[1];
|
// Row attributes are HTML-escaped (`&`), 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(/&/g, "&").replace(/&/g, "&");
|
||||||
// Skip vehicle.action rows — those are sub-model selectors, not part group links
|
// Skip vehicle.action rows — those are sub-model selectors, not part group links
|
||||||
if (url.includes("vehicle.action")) continue;
|
if (url.includes("vehicle.action")) continue;
|
||||||
// Skip header/breadcrumb navigation links that leak into the group table as
|
// 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,
|
// 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.
|
// none of which match these. Volvo's vin-group.action?...group1=... is kept.
|
||||||
if (/(portal|logout)\.action/i.test(url)) continue;
|
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 (/^https?:\/\//i.test(url) && !/\.action(\?|$)/i.test(url)) continue;
|
||||||
if (seen.has(url)) continue;
|
if (seen.has(url)) continue;
|
||||||
seen.add(url);
|
seen.add(url);
|
||||||
|
|
||||||
const captionMatch = segment.match(/\bcaption="([^"]+)"/);
|
const captionMatch = segment.match(/\bcaption="([^"]+)"/);
|
||||||
// Try to extract any text from <td> elements, including those with nested tags
|
// Collect all <td> texts (nested tags stripped). The first non-empty one
|
||||||
let tdName = "";
|
// is the primary name for most brands.
|
||||||
const tdPattern = /<td[^>]*>([\s\S]*?)<\/td>/g;
|
const tdPattern = /<td[^>]*>([\s\S]*?)<\/td>/g;
|
||||||
|
const tdTexts: string[] = [];
|
||||||
for (const tdMatch2 of segment.matchAll(tdPattern)) {
|
for (const tdMatch2 of segment.matchAll(tdPattern)) {
|
||||||
const text = tdMatch2[1]
|
const text = tdMatch2[1]
|
||||||
.replace(/<[^>]+>/g, " ")
|
.replace(/<[^>]+>/g, " ")
|
||||||
@@ -3674,10 +3694,22 @@ export class PL24FordLegacyService {
|
|||||||
.replace(/\xa0/g, " ")
|
.replace(/\xa0/g, " ")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
if (text.length >= 3) {
|
if (text.length >= 2) tdTexts.push(text);
|
||||||
tdName = text;
|
}
|
||||||
break;
|
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. <div class="restrictionBlock"...>)
|
// Caption may contain HTML-encoded content (e.g. <div class="restrictionBlock"...>)
|
||||||
@@ -4038,6 +4070,15 @@ export class PL24FordLegacyService {
|
|||||||
return [];
|
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) {
|
if (categories.length === 0) {
|
||||||
categories = this.parseP4NavigationCategories(html);
|
categories = this.parseP4NavigationCategories(html);
|
||||||
}
|
}
|
||||||
|
|||||||
127
apps/api/src/integrations/pl24/pl24-model-path.spec.ts
Normal file
127
apps/api/src/integrations/pl24/pl24-model-path.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PL24Service } from "./pl24.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model-list entry point resolution (plv2.md, bulgu p5core-08).
|
||||||
|
*
|
||||||
|
* `BACKEND_MODEL_PATH` eksik kaldığında eski davranış yedi bilinen yolu sırayla
|
||||||
|
* denemekti: paylaşılan günlük bütçeden çağrı başına yedi isteğe kadar, ve
|
||||||
|
* listede olmayan bir yol için sessiz sıfır sonuç. Volvo tam olarak buydu —
|
||||||
|
* `/extern/vehicles/models` (çoğul "vehicles") listede yok, dolayısıyla
|
||||||
|
* Volvo/Polestar browse sıfır model tohumlayıp emekli P4 listesini sunmaya
|
||||||
|
* devam ediyordu.
|
||||||
|
*
|
||||||
|
* Otoritatif kaynak her P5 backend'inin kendi `/extern/catmeta` yanıtındaki
|
||||||
|
* `data.catalogEntryPoint.path`. Canlı doğrulama (2026-09-20):
|
||||||
|
* p5volvo → /p5volvo/extern/vehicles/models (60 model)
|
||||||
|
* p5psa → /p5psa/extern/vehicle/catalogs
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Resolver = {
|
||||||
|
resolveModelPathFromCatmeta: (
|
||||||
|
base: string,
|
||||||
|
svc: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
) => Promise<string | null>;
|
||||||
|
baseUrl: string;
|
||||||
|
language: string;
|
||||||
|
redis: {
|
||||||
|
get: (k: string) => Promise<string | null>;
|
||||||
|
set: (k: string, v: string, ttl?: number) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
logger: { log: (m: string) => void; warn: (m: string) => void };
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeService(opts: {
|
||||||
|
cached?: string | null;
|
||||||
|
catmeta?: unknown;
|
||||||
|
status?: number;
|
||||||
|
throws?: boolean;
|
||||||
|
}) {
|
||||||
|
const sets: Array<[string, string]> = [];
|
||||||
|
const svc = Object.create(PL24Service.prototype) as unknown as Resolver;
|
||||||
|
svc.baseUrl = "https://pl24.test";
|
||||||
|
svc.language = "tr";
|
||||||
|
svc.redis = {
|
||||||
|
get: vi.fn(async () => opts.cached ?? null),
|
||||||
|
set: vi.fn(async (k: string, v: string) => {
|
||||||
|
sets.push([k, v]);
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
svc.logger = { log: vi.fn(), warn: vi.fn() };
|
||||||
|
const fetchMock = vi.fn(async () => {
|
||||||
|
if (opts.throws) throw new Error("network down");
|
||||||
|
return {
|
||||||
|
ok: (opts.status ?? 200) < 400,
|
||||||
|
status: opts.status ?? 200,
|
||||||
|
json: async () => opts.catmeta ?? {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
return { svc, sets, fetchMock };
|
||||||
|
}
|
||||||
|
|
||||||
|
const volvoMeta = {
|
||||||
|
data: {
|
||||||
|
catalogEntryPoint: {
|
||||||
|
wid: "modelsTable",
|
||||||
|
path: "/p5volvo/extern/vehicles/models?lang=en&serviceName=volvo_parts",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("resolveModelPathFromCatmeta", () => {
|
||||||
|
beforeEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it("catmeta'daki giriş noktasını backend'e göreli yola indirger", async () => {
|
||||||
|
const { svc } = makeService({ catmeta: volvoMeta });
|
||||||
|
const out = await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {});
|
||||||
|
expect(out).toBe("/extern/vehicles/models");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("çözülen yolu 30 gün cache'ler", async () => {
|
||||||
|
const { svc, sets } = makeService({ catmeta: volvoMeta });
|
||||||
|
await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {});
|
||||||
|
expect(sets[0][0]).toBe("pl24:modelpath:p5volvo");
|
||||||
|
expect(sets[0][1]).toBe("/extern/vehicles/models");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cache'lenmiş yol için upstream'e hiç gitmez", async () => {
|
||||||
|
const { svc, fetchMock } = makeService({ cached: "/extern/vehicles/models" });
|
||||||
|
const out = await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {});
|
||||||
|
expect(out).toBe("/extern/vehicles/models");
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("olumsuz sonuç da cache'lenir — her browse'da yeniden denenmez", async () => {
|
||||||
|
const { svc, fetchMock } = makeService({ cached: "none" });
|
||||||
|
expect(await svc.resolveModelPathFromCatmeta("/p5x", "x_parts", {})).toBeNull();
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catmeta yoksa veya şekil beklenmedikse null döner", async () => {
|
||||||
|
const { svc, sets } = makeService({ catmeta: { data: {} } });
|
||||||
|
expect(await svc.resolveModelPathFromCatmeta("/p5x", "x_parts", {})).toBeNull();
|
||||||
|
expect(sets[0][1]).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HTTP hatasında null döner", async () => {
|
||||||
|
const { svc } = makeService({ status: 403, catmeta: volvoMeta });
|
||||||
|
expect(await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ağ hatası fırlatmaz", async () => {
|
||||||
|
const { svc } = makeService({ throws: true });
|
||||||
|
await expect(
|
||||||
|
svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {}),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/extern/ ile başlamayan yolu kabul etmez", async () => {
|
||||||
|
const { svc } = makeService({
|
||||||
|
catmeta: { data: { catalogEntryPoint: { path: "https://baska.site/kotu" } } },
|
||||||
|
});
|
||||||
|
expect(await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
70
apps/api/src/integrations/pl24/pl24-p4-groups.spec.ts
Normal file
70
apps/api/src/integrations/pl24/pl24-p4-groups.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -442,31 +442,50 @@ export class PL24PsaService {
|
|||||||
|
|
||||||
// ==================== PRIVATE: session + HTTP ====================
|
// ==================== 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.
|
* entry.action → startup=true (302) → 302 with mode+upds. Returns JSESSIONID/mode/upds.
|
||||||
*/
|
*/
|
||||||
private async initPsaSession(serviceName: string): Promise<PsaSession | null> {
|
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 headers = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||||
|
const dispatcher = await this.psaDispatcher();
|
||||||
|
const withDispatcher = (o: RequestInit): RequestInit & { dispatcher?: any } =>
|
||||||
|
dispatcher ? { ...o, dispatcher } : o;
|
||||||
try {
|
try {
|
||||||
const entryRes = await fetch(`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`, {
|
const entryRes = await fetch(
|
||||||
method: "GET",
|
`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`,
|
||||||
headers,
|
withDispatcher({
|
||||||
redirect: "manual",
|
method: "GET",
|
||||||
signal: AbortSignal.timeout(this.timeout),
|
headers,
|
||||||
});
|
redirect: "manual",
|
||||||
|
signal: AbortSignal.timeout(this.timeout),
|
||||||
|
}),
|
||||||
|
);
|
||||||
const jsessionId = entryRes.headers.get("set-cookie")?.match(/JSESSIONID=([^;]+)/)?.[1] || "";
|
const jsessionId = entryRes.headers.get("set-cookie")?.match(/JSESSIONID=([^;]+)/)?.[1] || "";
|
||||||
const loc1 = entryRes.headers.get("location");
|
const loc1 = entryRes.headers.get("location");
|
||||||
if (!loc1) return null;
|
if (!loc1) return null;
|
||||||
const hdrs2 = jsessionId
|
const hdrs2 = jsessionId
|
||||||
? { ...headers, Cookie: `${headers.Cookie}; JSESSIONID=${jsessionId}` }
|
? { ...headers, Cookie: `${headers.Cookie}; JSESSIONID=${jsessionId}` }
|
||||||
: headers;
|
: headers;
|
||||||
const startupRes = await fetch(loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`, {
|
const startupRes = await fetch(
|
||||||
method: "GET",
|
loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`,
|
||||||
headers: hdrs2,
|
withDispatcher({
|
||||||
redirect: "manual",
|
method: "GET",
|
||||||
signal: AbortSignal.timeout(this.timeout),
|
headers: hdrs2,
|
||||||
});
|
redirect: "manual",
|
||||||
|
signal: AbortSignal.timeout(this.timeout),
|
||||||
|
}),
|
||||||
|
);
|
||||||
const loc2 = startupRes.headers.get("location") || loc1;
|
const loc2 = startupRes.headers.get("location") || loc1;
|
||||||
const mode = loc2.match(/[?&]mode=([^&]+)/)?.[1] || "";
|
const mode = loc2.match(/[?&]mode=([^&]+)/)?.[1] || "";
|
||||||
// Keep upds URL-encoded exactly as returned (e.g. "2024.02.13+09%3A27%3A21+CET");
|
// 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",
|
Accept: opts.json ? "application/json,*/*" : "text/html,application/xhtml+xml,*/*;q=0.9",
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const dispatcher = await this.psaDispatcher();
|
||||||
|
const fetchOpts: RequestInit & { dispatcher?: any } = {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers,
|
headers,
|
||||||
redirect: opts.manual ? "manual" : "follow",
|
redirect: opts.manual ? "manual" : "follow",
|
||||||
signal: AbortSignal.timeout(this.timeout),
|
signal: AbortSignal.timeout(this.timeout),
|
||||||
});
|
};
|
||||||
|
if (dispatcher) fetchOpts.dispatcher = dispatcher;
|
||||||
|
const res = await fetch(url, fetchOpts);
|
||||||
const location = res.headers.get("location");
|
const location = res.headers.get("location");
|
||||||
if (opts.manual && res.status >= 300 && res.status < 400) {
|
if (opts.manual && res.status >= 300 && res.status < 400) {
|
||||||
return { status: res.status, text: null, location };
|
return { status: res.status, text: null, location };
|
||||||
@@ -543,13 +565,19 @@ export class PL24PsaService {
|
|||||||
Accept: "application/json,image/*,*/*;q=0.9",
|
Accept: "application/json,image/*,*/*;q=0.9",
|
||||||
Referer: `${this.baseUrl}/psa/${serviceName}/vin-image-board.action`,
|
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 {
|
try {
|
||||||
const infoRes = await fetch(`${imageUrl}&request=GetImageInfo&cv=1`, {
|
const infoRes = await fetch(
|
||||||
method: "GET",
|
`${imageUrl}&request=GetImageInfo&cv=1`,
|
||||||
headers,
|
withDispatcher({
|
||||||
redirect: "follow",
|
method: "GET",
|
||||||
signal: AbortSignal.timeout(this.timeout),
|
headers,
|
||||||
});
|
redirect: "follow",
|
||||||
|
signal: AbortSignal.timeout(this.timeout),
|
||||||
|
}),
|
||||||
|
);
|
||||||
if (!infoRes.ok) return null;
|
if (!infoRes.ok) return null;
|
||||||
const info = (await infoRes.json()) as {
|
const info = (await infoRes.json()) as {
|
||||||
imageWidth: number;
|
imageWidth: number;
|
||||||
@@ -570,12 +598,15 @@ export class PL24PsaService {
|
|||||||
const getImgUrl =
|
const getImgUrl =
|
||||||
`${imageUrl}&request=GetImage&format=image%2Fpng` +
|
`${imageUrl}&request=GetImage&format=image%2Fpng` +
|
||||||
`&bbox=${encodeURIComponent(`0,0,${w},${h}`)}&width=${w}&height=${h}&scalefac=1.0&cv=1&rnd=${rnd}`;
|
`&bbox=${encodeURIComponent(`0,0,${w},${h}`)}&width=${w}&height=${h}&scalefac=1.0&cv=1&rnd=${rnd}`;
|
||||||
const imgRes = await fetch(getImgUrl, {
|
const imgRes = await fetch(
|
||||||
method: "GET",
|
getImgUrl,
|
||||||
headers: { ...headers, Accept: "image/png,image/*,*/*;q=0.9" },
|
withDispatcher({
|
||||||
redirect: "follow",
|
method: "GET",
|
||||||
signal: AbortSignal.timeout(this.timeout),
|
headers: { ...headers, Accept: "image/png,image/*,*/*;q=0.9" },
|
||||||
});
|
redirect: "follow",
|
||||||
|
signal: AbortSignal.timeout(this.timeout),
|
||||||
|
}),
|
||||||
|
);
|
||||||
if (!imgRes.ok) return null;
|
if (!imgRes.ok) return null;
|
||||||
const contentType = imgRes.headers.get("content-type") || "image/png";
|
const contentType = imgRes.headers.get("content-type") || "image/png";
|
||||||
const buffer = Buffer.from(await imgRes.arrayBuffer());
|
const buffer = Buffer.from(await imgRes.arrayBuffer());
|
||||||
|
|||||||
186
apps/api/src/integrations/pl24/pl24-tree.spec.ts
Normal file
186
apps/api/src/integrations/pl24/pl24-tree.spec.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isPl24GroupNode,
|
||||||
|
isPl24LeafNode,
|
||||||
|
isPl24PartDetailNode,
|
||||||
|
isStalePl24Architecture,
|
||||||
|
} from "./pl24-tree";
|
||||||
|
|
||||||
|
// Canlı P5 yanıtlarından (2026-09-16 keşfi, plv2-artefakt/) alınan gerçek
|
||||||
|
// wid + path çiftleri. Bu dosya "0 parça" sınıfı hatanın regresyon kilidi.
|
||||||
|
|
||||||
|
describe("isPl24LeafNode — canlı P5 şekilleri", () => {
|
||||||
|
it("VW (p5vwag): maingroups/subgroups grup, bom/vin yaprak", () => {
|
||||||
|
expect(
|
||||||
|
isPl24GroupNode({
|
||||||
|
linkWid: "mainGroupsTable",
|
||||||
|
linkPath: "/p5vwag/extern/groups/vin_maingroups?vin=X",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isPl24GroupNode({
|
||||||
|
linkWid: "subGroupsIllusTable",
|
||||||
|
linkPath: "/p5vwag/extern/groups/vin_subgroups_illus?maingroup=4",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isPl24LeafNode({
|
||||||
|
linkWid: "bomlist",
|
||||||
|
linkPath: "/p5vwag/extern/bom/vin?illustration=407-000",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PSA (p5psa): scope + mainGroups + illusTable grup, bomDetails yaprak", () => {
|
||||||
|
expect(
|
||||||
|
isPl24GroupNode({
|
||||||
|
linkWid: "scopeTable",
|
||||||
|
linkPath: "/p5psa/extern/group/vin/scope?modelCode=1PD2",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isPl24GroupNode({
|
||||||
|
linkWid: "mainGroupTable",
|
||||||
|
linkPath: "/p5psa/extern/group/vin/mainGroups?scope=_FCT0001",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
// Eski `includes("group")` kuralının kaçırdığı seviye — sessiz boş panelin kaynağı.
|
||||||
|
expect(
|
||||||
|
isPl24GroupNode({
|
||||||
|
linkWid: "illusTable",
|
||||||
|
linkPath: "/p5psa/extern/group/vin/illus?mainGroup=_FCT0001_FCT0512",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
// camelCase bomDetails — eski `includes("/bomdetails")` kaçırıyordu.
|
||||||
|
expect(
|
||||||
|
isPl24LeafNode({
|
||||||
|
linkWid: "bomlist",
|
||||||
|
linkPath: "/p5psa/extern/details/vin/bomDetails?illustration=D2F001A48A",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Volvo (p5volvo): illustrationsTable grup, bom yaprak", () => {
|
||||||
|
expect(
|
||||||
|
isPl24GroupNode({
|
||||||
|
linkWid: "illustrationsTable",
|
||||||
|
linkPath: "/p5volvo/extern/groups/vin/illustration?group=0b00c8af80205942",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(isPl24LeafNode({ linkWid: "bomlist", linkPath: "/p5volvo/extern/bom/vin?..." })).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isPl24LeafNode({
|
||||||
|
linkWid: "partinfo",
|
||||||
|
linkPath: "/p5volvo/extern/partinfo/vin?partno=36050493",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Subaru (p5subaru): subgroups/illustrations grup, bom/vin?figNum yaprak", () => {
|
||||||
|
expect(
|
||||||
|
isPl24GroupNode({
|
||||||
|
linkWid: "illustrationsTable",
|
||||||
|
linkPath: "/p5subaru/extern/groups/vin/illustrations?subGroup=001",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isPl24LeafNode({ linkWid: "bomlist", linkPath: "/p5subaru/extern/bom/vin?figNum=01" }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wid yoksa yol kalıbına düşer (eski DB satırları)", () => {
|
||||||
|
expect(isPl24LeafNode({ linkPath: "/p5vwag/extern/bom/vin?illustration=1" })).toBe(true);
|
||||||
|
expect(
|
||||||
|
isPl24LeafNode({ linkPath: "/psa/peugeot_parts/vin-image-board.action?illCode=1" }),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isPl24GroupNode({
|
||||||
|
linkPath: "/psa/peugeot_parts/json-vin-main-groups.action?scope=_FCT0001",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasSubgroups=true yol tahminini ezer", () => {
|
||||||
|
expect(isPl24LeafNode({ linkPath: "/p5x/extern/unknown", hasSubgroups: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("servicepart öğe listesi yapraktır", () => {
|
||||||
|
expect(
|
||||||
|
isPl24LeafNode({
|
||||||
|
linkWid: "servicePartsItemsTable",
|
||||||
|
linkPath: "/p5vwag/extern/servicepart/vin_items?x=1",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isStalePl24Architecture — P4→P5 göç tespiti", () => {
|
||||||
|
it("eski PSA/Volvo yolu + artık P5 olan servis → bayat", () => {
|
||||||
|
expect(
|
||||||
|
isStalePl24Architecture({ catalogPath: "/psa/peugeot_parts", currentApiPath: "/p5psa" }),
|
||||||
|
).toBe(true);
|
||||||
|
expect(isStalePl24Architecture({ catalogPath: "/volvo", currentApiPath: "/p5volvo" })).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zaten P5 kaydı bayat değil", () => {
|
||||||
|
expect(isStalePl24Architecture({ catalogPath: "/p5psa", currentApiPath: "/p5psa" })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hâlâ P4 olan markalar (Ford/Opel/Hyundai) dokunulmaz", () => {
|
||||||
|
expect(isStalePl24Architecture({ catalogPath: "/ford", currentApiPath: "/ford" })).toBe(false);
|
||||||
|
expect(isStalePl24Architecture({ catalogPath: "/opel", currentApiPath: "/opel" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("eksik bilgi → bayat sayma (güvenli taraf)", () => {
|
||||||
|
expect(isStalePl24Architecture({ catalogPath: null, currentApiPath: "/p5psa" })).toBe(false);
|
||||||
|
expect(isStalePl24Architecture({ catalogPath: "/psa/x", currentApiPath: null })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mitsubishi: parça listesi grup sanılıyordu (plv2.md, bulgu consumers-13).
|
||||||
|
*
|
||||||
|
* `/p5mitsubishi/extern/details/vinDetails` yanıtı `partno`/`qty` taşıyan bir
|
||||||
|
* PARÇA listesi (canlı doğrulama 2026-09-20: 16 kayıt), ama her kaydın linki
|
||||||
|
* `partInfoTable`. Ne wid ne yol sınıflandırıcıda karşılık bulmuyordu → 2.193
|
||||||
|
* parça listesi gruba, içlerindeki 19.576 tekil parça kategoriye dönüşmüştü;
|
||||||
|
* hepsinde toplam 2 parça vardı ve her prefetch turunda yeniden çekiliyorlardı.
|
||||||
|
*/
|
||||||
|
describe("Mitsubishi detailsTable / partInfoTable", () => {
|
||||||
|
const detailsPath =
|
||||||
|
"/p5mitsubishi/extern/details/vinDetails?bomDetails=133_110D00125Y&mainGroup=33";
|
||||||
|
const partInfoPath = "/p5mitsubishi/extern/details/vinpartinfo?bomDetails=142_7103K22Y5T";
|
||||||
|
|
||||||
|
it("detailsTable bir parça listesi — yaprak", () => {
|
||||||
|
expect(isPl24LeafNode({ linkWid: "detailsTable", linkPath: detailsPath })).toBe(true);
|
||||||
|
expect(isPl24GroupNode({ linkWid: "detailsTable", linkPath: detailsPath })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("partInfoTable tekil parça detayı — ne grup ne liste", () => {
|
||||||
|
expect(isPl24PartDetailNode({ linkWid: "partInfoTable", linkPath: partInfoPath })).toBe(true);
|
||||||
|
expect(isPl24GroupNode({ linkWid: "partInfoTable", linkPath: partInfoPath })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wid yoksa yol kalıbı parça-detayını yine yakalar", () => {
|
||||||
|
expect(isPl24PartDetailNode({ linkPath: partInfoPath })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gerçek parça uçlarını parça-detayı sanmaz", () => {
|
||||||
|
// Volvo'nun partinfo yaprağı ve VW'nin bom listesi etkilenmemeli.
|
||||||
|
expect(
|
||||||
|
isPl24PartDetailNode({
|
||||||
|
linkWid: "partinfo",
|
||||||
|
linkPath: "/p5volvo/extern/partinfo/vin?partno=1",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isPl24PartDetailNode({ linkWid: "bomlist", linkPath: "/p5vwag/extern/bom/vin?x=1" }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
151
apps/api/src/integrations/pl24/pl24-tree.ts
Normal file
151
apps/api/src/integrations/pl24/pl24-tree.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* One source of truth for "is this PL24 node a parts leaf or a group to drill?".
|
||||||
|
*
|
||||||
|
* WHY THIS FILE EXISTS (plv2.md, findings p5core-02 / psa-05 / consumers_jobs-01):
|
||||||
|
* the same question was answered by four independent heuristics —
|
||||||
|
* `categories.service` (twice), `catalog.service` and the prefetch worker — and
|
||||||
|
* they disagreed. Two failure modes shipped repeatedly:
|
||||||
|
*
|
||||||
|
* 1. `linkWid.includes("group")` as the "is a group" test. Live P5 PSA's second
|
||||||
|
* level has wid `illusTable`, Volvo's and Subaru's `illustrationsTable` —
|
||||||
|
* none contain "group", so those nodes fell through to the parts fetcher,
|
||||||
|
* which asked an *illustration list* for parts, got records with no partno,
|
||||||
|
* and rendered a silent empty panel (`parts: []`, no error). This is the same
|
||||||
|
* class of bug as the 2026-06 `isPsaParent` incident.
|
||||||
|
* 2. Case-sensitive `includes("/bomdetails")` while p5psa and p5volvo spell the
|
||||||
|
* endpoint `/details/vin/bomDetails` — so those leaves were queued as groups,
|
||||||
|
* `getChildren` returned [] and their parts were never prefetched (live today
|
||||||
|
* for Mitsubishi 99.9% / Fiat 80% / Renault 70% of bomDetails leaves).
|
||||||
|
*
|
||||||
|
* The reliable cross-brand marker is the response's own `link.wid`: `bomlist`
|
||||||
|
* (and the service-parts/partinfo variants) means parts, anything else means
|
||||||
|
* drill. Path matching stays as a fallback for stored rows without a wid.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `link.wid` values that identify a parts (BOM) node across every P5 backend.
|
||||||
|
*
|
||||||
|
* `detailstable` is Mitsubishi's: `/p5mitsubishi/extern/details/vinDetails`
|
||||||
|
* answers with 16-ish records carrying `partno`/`qty`, i.e. it IS the parts
|
||||||
|
* list — but each record's own link is a `partInfoTable` per-part detail, and
|
||||||
|
* neither the wid nor the path matched anything here, so the whole list was
|
||||||
|
* drilled as a group. Prod on 2026-09-20: 2,193 Mitsubishi parts lists turned
|
||||||
|
* into group nodes and their 19,576 individual parts ("SCREW,LOCK CYLINDER",
|
||||||
|
* "BOLT,STEERING COLUMN WASHER") became categories — 19,576 fake tree nodes
|
||||||
|
* with 2 parts between them, each re-fetched on every prefetch pass.
|
||||||
|
*/
|
||||||
|
const LEAF_WIDS = new Set([
|
||||||
|
"bomlist",
|
||||||
|
"bomoverviewlist",
|
||||||
|
"servicepartsitemstable",
|
||||||
|
"partinfo",
|
||||||
|
"detailstable",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nodes that describe ONE part rather than a list of them. They are neither a
|
||||||
|
* group to drill nor a list to fetch: the parent's own response already carried
|
||||||
|
* the part. Queueing them buys nothing and costs one upstream request each —
|
||||||
|
* 19,576 of them on prod before this was recognised.
|
||||||
|
*/
|
||||||
|
const PART_DETAIL_WIDS = new Set(["partinfotable"]);
|
||||||
|
const PART_DETAIL_PATH = /\/details\/vinpartinfo\b/i;
|
||||||
|
|
||||||
|
/** True when this node is a single part's detail view, not a listing. */
|
||||||
|
export function isPl24PartDetailNode(opts: {
|
||||||
|
linkPath?: string | null;
|
||||||
|
linkWid?: string | null;
|
||||||
|
}): boolean {
|
||||||
|
const wid = opts.linkWid?.toLowerCase().trim();
|
||||||
|
if (wid && PART_DETAIL_WIDS.has(wid)) return true;
|
||||||
|
return PART_DETAIL_PATH.test(opts.linkPath ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `link.wid` values that identify a drillable group node. */
|
||||||
|
const GROUP_WID_PATTERN =
|
||||||
|
/(group|scope|illus|illustration|catalog|model|vpages|msppages|chemicals|category|categories)/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path fragments that only ever appear on a parts endpoint. `image-board` has no
|
||||||
|
* leading slash on purpose: the legacy PSA leaf is `vin-image-board.action`.
|
||||||
|
*/
|
||||||
|
const LEAF_PATH_PATTERN =
|
||||||
|
/\/(bom|bomdetails|partinfo|vin_items|mdl_items|vin_bomdetails)\b|\/bom\/|\/details\/vin\/bomdetails|\/servicepart\/vin_items|image-board/i;
|
||||||
|
|
||||||
|
/** True when this node yields parts (never children). */
|
||||||
|
export function isPl24LeafNode(opts: {
|
||||||
|
linkPath?: string | null;
|
||||||
|
linkWid?: string | null;
|
||||||
|
hasSubgroups?: boolean | null;
|
||||||
|
}): boolean {
|
||||||
|
const wid = opts.linkWid?.toLowerCase().trim();
|
||||||
|
if (wid) {
|
||||||
|
if (LEAF_WIDS.has(wid)) return true;
|
||||||
|
if (GROUP_WID_PATTERN.test(wid)) return false;
|
||||||
|
}
|
||||||
|
// Explicit DB hint wins over path guessing when there is no usable wid.
|
||||||
|
if (opts.hasSubgroups === true) return false;
|
||||||
|
const lp = opts.linkPath?.toLowerCase() ?? "";
|
||||||
|
if (!lp) return false;
|
||||||
|
return LEAF_PATH_PATTERN.test(lp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when this node should be drilled for children. */
|
||||||
|
export function isPl24GroupNode(opts: {
|
||||||
|
linkPath?: string | null;
|
||||||
|
linkWid?: string | null;
|
||||||
|
hasSubgroups?: boolean | null;
|
||||||
|
}): boolean {
|
||||||
|
if (!opts.linkPath && !opts.linkWid) return false;
|
||||||
|
// A per-part detail node is not a group; drilling it returns nothing.
|
||||||
|
if (isPl24PartDetailNode(opts)) return false;
|
||||||
|
return !isPl24LeafNode(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a stored vehicle's catalogInfo points at an architecture the service
|
||||||
|
* no longer uses — i.e. the row was decoded before PSA/Volvo moved to P5.
|
||||||
|
*
|
||||||
|
* These vehicles keep serving a tree built from the frozen P4 PSA snapshot
|
||||||
|
* (upds 2024-02-13) or the dead P4 Volvo endpoint (HTTP 503), and `decodeVin`'s
|
||||||
|
* db_hit short-circuit means a code fix never reaches them. Detecting the
|
||||||
|
* mismatch at read time lets each vehicle heal itself on first view instead of
|
||||||
|
* needing a bulk re-decode storm against the one surviving account.
|
||||||
|
*/
|
||||||
|
export function isStalePl24Architecture(opts: {
|
||||||
|
catalogPath?: string | null;
|
||||||
|
currentApiPath?: string | null;
|
||||||
|
}): boolean {
|
||||||
|
const stored = opts.catalogPath?.toLowerCase() ?? "";
|
||||||
|
const current = opts.currentApiPath?.toLowerCase() ?? "";
|
||||||
|
if (!stored || !current) return false;
|
||||||
|
// Only the two migrated legacy backends; unknown/other paths are left alone.
|
||||||
|
const storedIsLegacyPsaOrVolvo = stored.startsWith("/psa") || stored.startsWith("/volvo");
|
||||||
|
if (!storedIsLegacyPsaOrVolvo) return false;
|
||||||
|
return current.startsWith("/p5");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a stored `catalog_vehicles` browse row was listed under an
|
||||||
|
* architecture the service table no longer uses.
|
||||||
|
*
|
||||||
|
* Browse rows are a PERMANENT cache: `CatalogService.getModels` returns early
|
||||||
|
* whenever the brand already has rows, so `fetchVehicleList` is never called
|
||||||
|
* again for that brand. The 188 rows listed while PSA and Volvo were still P4
|
||||||
|
* (127 LEGACY_PSA + 61 LEGACY_VOLVO on prod, 2026-09-20) are therefore pinned
|
||||||
|
* forever: Peugeot/Citroën browse serves the frozen 2024-02-13 snapshot and
|
||||||
|
* Volvo/Polestar browse serves an endpoint that answers HTTP 503. Detecting the
|
||||||
|
* mismatch at list time lets the brand re-list itself once, the same way
|
||||||
|
* `isStalePl24Architecture` heals a VIN-decoded vehicle.
|
||||||
|
*/
|
||||||
|
export function isStaleBrowseArchitecture(opts: {
|
||||||
|
storedArchitecture?: string | null;
|
||||||
|
currentArchitecture?: string | null;
|
||||||
|
}): boolean {
|
||||||
|
const stored = opts.storedArchitecture?.trim();
|
||||||
|
const current = opts.currentArchitecture?.trim();
|
||||||
|
// An unknown service (no config) or an unlabelled row is left alone: without a
|
||||||
|
// current architecture to compare against there is nothing to migrate TO.
|
||||||
|
if (!stored || !current) return false;
|
||||||
|
return stored !== current;
|
||||||
|
}
|
||||||
68
apps/api/src/integrations/pl24/pl24-volvo-vinfo.spec.ts
Normal file
68
apps/api/src/integrations/pl24/pl24-volvo-vinfo.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { PL24Service } from "./pl24.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Volvo P5 vinfoBasic regression lock (plv2.md, finding p5core-09).
|
||||||
|
*
|
||||||
|
* Volvo emits BOTH a bare internal code and a readable description for the same
|
||||||
|
* attribute — "Şanzıman kodu" = "B" next to "Şanzıman" = "6-PSHIFT 2WD / MPS6",
|
||||||
|
* and "Satis tipi" = "42" next to "Türü" = "S80". The key order the parser needs
|
||||||
|
* for VAG (whose "Şanzıman kodu" IS the useful value, e.g. "MQ200") therefore
|
||||||
|
* rendered a single letter as the gearbox and a bare number as the series.
|
||||||
|
*
|
||||||
|
* Fixtures are the real 2026-09-16 discovery captures for VIN
|
||||||
|
* YV1AS84ABD1168166 (S80), trimmed to the segments the parser reads, in both
|
||||||
|
* the Turkish and the English label locale.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fixture = (name: string) =>
|
||||||
|
JSON.parse(readFileSync(join(__dirname, "__fixtures__", `${name}.json`), "utf-8"));
|
||||||
|
|
||||||
|
// parseVehicleResponse only touches the response and the service name.
|
||||||
|
const parse = (data: unknown, serviceName = "volvo_parts") => {
|
||||||
|
const svc = Object.create(PL24Service.prototype) as unknown as {
|
||||||
|
parseVehicleResponse: (v: string, d: unknown, s: string) => Record<string, unknown>;
|
||||||
|
isDaimlerService: (s: string) => boolean;
|
||||||
|
};
|
||||||
|
svc.isDaimlerService = () => false;
|
||||||
|
return svc.parseVehicleResponse("YV1AS84ABD1168166", data, serviceName);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Volvo P5 vinfoBasic — kod yerine açıklama", () => {
|
||||||
|
it("Türkçe etiketlerde şanzımanı okunur değerinden alır", () => {
|
||||||
|
const out = parse(fixture("p5_volvo_tr"));
|
||||||
|
expect(out.transmission).toBe("6-PSHIFT 2WD / MPS6");
|
||||||
|
// Tek harflik "Şanzıman kodu" artık kazanmıyor.
|
||||||
|
expect(out.transmission).not.toBe("B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("İngilizce etiketlerde de aynı sonucu verir", () => {
|
||||||
|
const out = parse(fixture("p5_volvo_en"));
|
||||||
|
expect(out.transmission).toBe("6-PSHIFT 2WD / MPS6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seri, çıplak satış kodu yerine gerçek tipi döner", () => {
|
||||||
|
expect(parse(fixture("p5_volvo_tr")).series).toBe("S80");
|
||||||
|
expect(parse(fixture("p5_volvo_en")).series).toBe("S80");
|
||||||
|
expect(parse(fixture("p5_volvo_tr")).series).not.toBe("42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kaporta stili iki dilde de çözülür ve '0' kodu sızmaz", () => {
|
||||||
|
expect(parse(fixture("p5_volvo_tr")).bodyType).toBe("Sedan");
|
||||||
|
expect(parse(fixture("p5_volvo_en")).bodyType).toBe("Sedan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("motor alanları bozulmadan kalır", () => {
|
||||||
|
const out = parse(fixture("p5_volvo_tr"));
|
||||||
|
expect(out.engineCode).toBe("84");
|
||||||
|
expect(out.engineType).toBe("D4162T");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("model ve yıl korunur", () => {
|
||||||
|
const out = parse(fixture("p5_volvo_tr"));
|
||||||
|
expect(out.model).toBe("S80 (07-)");
|
||||||
|
expect(out.year).toBe(2013);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,29 @@
|
|||||||
export const PL24_DEFAULTS = {
|
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:",
|
CACHE_PREFIX: "pl24:",
|
||||||
REQUEST_TIMEOUT: 30000,
|
REQUEST_TIMEOUT: 30000,
|
||||||
MAX_RETRIES: 3,
|
MAX_RETRIES: 3,
|
||||||
} as const;
|
} 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 = {
|
export const PL24_ENDPOINTS = {
|
||||||
// Auth
|
// Auth — LOGIN is the portal/landing endpoint (returns {loginStatus, sessionToken}
|
||||||
LOGIN: "/pl24-appgtw/ext/api/1.0/login",
|
// + 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",
|
AUTHORIZE: "/auth/ext/api/1.1/authorize",
|
||||||
|
|
||||||
// Catalog
|
// Catalog
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ProxyTelemetryModule } from "../proxy-telemetry/proxy-telemetry.module";
|
||||||
import { PL24AuthService } from "./pl24-auth.service";
|
import { PL24AuthService } from "./pl24-auth.service";
|
||||||
|
import { PL24BudgetService } from "./pl24-budget.service";
|
||||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||||
import { PL24FordService } from "./pl24-ford.service";
|
import { PL24FordService } from "./pl24-ford.service";
|
||||||
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
|
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
|
||||||
@@ -11,6 +13,7 @@ import { PL24Service } from "./pl24.service";
|
|||||||
const PL24_PROVIDERS = [
|
const PL24_PROVIDERS = [
|
||||||
PL24Service,
|
PL24Service,
|
||||||
PL24AuthService,
|
PL24AuthService,
|
||||||
|
PL24BudgetService,
|
||||||
PL24FordLegacyService,
|
PL24FordLegacyService,
|
||||||
PL24PsaService,
|
PL24PsaService,
|
||||||
PL24VolvoService,
|
PL24VolvoService,
|
||||||
@@ -20,6 +23,7 @@ const PL24_PROVIDERS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [ProxyTelemetryModule],
|
||||||
providers: PL24_PROVIDERS,
|
providers: PL24_PROVIDERS,
|
||||||
exports: PL24_PROVIDERS,
|
exports: PL24_PROVIDERS,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const svc = new PL24Service(
|
|||||||
{} as never, // redis
|
{} as never, // redis
|
||||||
{} as never, // storage
|
{} as never, // storage
|
||||||
{} as never, // posthog
|
{} as never, // posthog
|
||||||
|
{} as never, // budget
|
||||||
);
|
);
|
||||||
const p = svc as unknown as {
|
const p = svc as unknown as {
|
||||||
parseVehicleResponse(
|
parseVehicleResponse(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { PostHogService } from "../../posthog/posthog.service";
|
|||||||
import { RedisService } from "../../redis/redis.service";
|
import { RedisService } from "../../redis/redis.service";
|
||||||
import { StorageService } from "../../storage/storage.service";
|
import { StorageService } from "../../storage/storage.service";
|
||||||
import { PL24AuthService } from "./pl24-auth.service";
|
import { PL24AuthService } from "./pl24-auth.service";
|
||||||
|
import { PL24BudgetService } from "./pl24-budget.service";
|
||||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||||
import { PL24FordService } from "./pl24-ford.service";
|
import { PL24FordService } from "./pl24-ford.service";
|
||||||
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
|
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
|
||||||
@@ -55,6 +56,65 @@ const LEGACY_ARCH_SOURCE_TAG: Record<string, string> = {
|
|||||||
LEGACY_HYUNDAI_KIA: "hyundai-kia",
|
LEGACY_HYUNDAI_KIA: "hyundai-kia",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a vinfoBasic label into a lookup key.
|
||||||
|
*
|
||||||
|
* JS `toLowerCase()` maps Turkish "İ" to "i" + U+0307 (combining dot), so PSA
|
||||||
|
* labels like "AKTARMA SİSTEMLERİ" / "GÖVDE TİPİ" produced keys no lookup could
|
||||||
|
* ever match and the transmission/body fields silently stayed null. Lower-case
|
||||||
|
* with the Turkish locale, then strip combining marks and fold "ı" → "i" so a
|
||||||
|
* single spelling matches both "Model yılı" and "MODEL YILI".
|
||||||
|
*/
|
||||||
|
export function normalizeLabel(label: string): string {
|
||||||
|
return (
|
||||||
|
label
|
||||||
|
.toLocaleLowerCase("tr")
|
||||||
|
.normalize("NFD")
|
||||||
|
// \p{M} (all combining marks) rather than the U+0300–U+036F range: the range
|
||||||
|
// is a character class that can also match a base character followed by a
|
||||||
|
// combining one, which biome flags as misleading. NFD has already split every
|
||||||
|
// accent into its own mark, so matching marks directly is both correct and
|
||||||
|
// broader (Turkish, Latin-Extended, anything PL24 sends).
|
||||||
|
.replace(/\p{M}/gu, "")
|
||||||
|
.replace(/ı/g, "i")
|
||||||
|
.replace(/[\s/]+/g, "_")
|
||||||
|
.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PSA model year: "AM 2005" → 2005. Index labels ("01 MAJÖR ENDEKS",
|
||||||
|
* 'MAJÖR ENDEKS "0C"') are NOT years — returning one there is how a Citroën
|
||||||
|
* ended up as a 2001 model. PSA VINs also don't encode the model year in
|
||||||
|
* position 10, so the caller must fall back to DAM or leave it null.
|
||||||
|
*/
|
||||||
|
export function parsePsaModelYear(value: string | null | undefined): number | null {
|
||||||
|
if (!value) return null;
|
||||||
|
if (/endeks/i.test(value)) return null;
|
||||||
|
const m = value.match(/\b(?:AM\s*)?((?:19|20)\d{2})\b/i);
|
||||||
|
if (m) return Number(m[1]);
|
||||||
|
const short = value.match(/^\s*AM\s*(\d{2})\s*$/i);
|
||||||
|
if (short) {
|
||||||
|
const n = Number(short[1]);
|
||||||
|
return n >= 70 ? 1900 + n : 2000 + n;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PSA "DAM" (e.g. "10479CJ") = days since 1976-01-01 + plant code → build date.
|
||||||
|
* PSA model years roll in July, so a build after June belongs to the next one.
|
||||||
|
*/
|
||||||
|
export function damToModelYear(dam: string | null | undefined): number | null {
|
||||||
|
if (!dam) return null;
|
||||||
|
const m = dam.match(/^(\d{4,5})/);
|
||||||
|
if (!m) return null;
|
||||||
|
const date = new Date(Date.UTC(1976, 0, 1) + Number(m[1]) * 86_400_000);
|
||||||
|
const year = date.getUTCFullYear();
|
||||||
|
if (year < 1980 || year > 2100) return null;
|
||||||
|
return date.getUTCMonth() >= 6 ? year + 1 : year;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PL24Service {
|
export class PL24Service {
|
||||||
private readonly logger = new Logger(PL24Service.name);
|
private readonly logger = new Logger(PL24Service.name);
|
||||||
@@ -74,6 +134,7 @@ export class PL24Service {
|
|||||||
private redis: RedisService,
|
private redis: RedisService,
|
||||||
private storage: StorageService,
|
private storage: StorageService,
|
||||||
private posthog: PostHogService,
|
private posthog: PostHogService,
|
||||||
|
private readonly budget: PL24BudgetService,
|
||||||
) {
|
) {
|
||||||
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
|
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
|
||||||
this.timeout = 30000;
|
this.timeout = 30000;
|
||||||
@@ -944,15 +1005,28 @@ export class PL24Service {
|
|||||||
return opts;
|
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) {
|
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);
|
this.authService.clearTokensForAccount(account);
|
||||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||||
const newHeaders = await this.authService.buildAuthHeadersForAccount(account, serviceName);
|
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) {
|
if (!retry.ok) {
|
||||||
throw new Error(`HTTP ${retry.status}: ${retry.statusText}`);
|
throw new Error(`HTTP ${retry.status}: ${retry.statusText}`);
|
||||||
@@ -971,6 +1045,50 @@ export class PL24Service {
|
|||||||
return response;
|
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 ====================
|
// ==================== PRIVATE: Account routing ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1026,6 +1144,78 @@ export class PL24Service {
|
|||||||
|
|
||||||
// ==================== PRIVATE: Response parsers ====================
|
// ==================== PRIVATE: Response parsers ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask a P5 backend where its own model list lives, instead of guessing.
|
||||||
|
*
|
||||||
|
* Every P5 backend serves `/extern/catmeta`, whose `data.catalogEntryPoint.path`
|
||||||
|
* is the authoritative browse entry point — e.g. p5psa answers
|
||||||
|
* `/p5psa/extern/vehicle/catalogs`, p5volvo `/p5volvo/extern/vehicles/models`.
|
||||||
|
* The old behaviour, when `BACKEND_MODEL_PATH` had no entry, was to fire the
|
||||||
|
* seven known paths in turn and keep whichever returned rows. That costs up to
|
||||||
|
* seven upstream requests per call on a shared daily budget, and it silently
|
||||||
|
* fails for any backend whose path is not already on the list — which is how
|
||||||
|
* Volvo/Polestar browse ended up seeding zero models while still serving the
|
||||||
|
* retired P4 listing.
|
||||||
|
*
|
||||||
|
* The resolved path is cached per backend (30 days) so this costs one request
|
||||||
|
* for a backend's whole lifetime, and it self-heals if PL24 moves an endpoint.
|
||||||
|
* Returns null on any failure; the caller then falls back as before.
|
||||||
|
*/
|
||||||
|
private async resolveModelPathFromCatmeta(
|
||||||
|
catalogBase: string,
|
||||||
|
serviceName: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const cacheKey = `pl24:modelpath:${catalogBase.replace(/^\//, "")}`;
|
||||||
|
try {
|
||||||
|
const cached = await this.redis.get(cacheKey);
|
||||||
|
if (cached) return cached === "none" ? null : cached;
|
||||||
|
} catch {
|
||||||
|
// Redis down — resolve live rather than failing the listing.
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved: string | null = null;
|
||||||
|
try {
|
||||||
|
const url = `${this.baseUrl}${catalogBase}/extern/catmeta?serviceName=${serviceName}&country=DE&lang=${this.language}`;
|
||||||
|
const res = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(15000) });
|
||||||
|
if (res.ok) {
|
||||||
|
const meta = (await res.json()) as {
|
||||||
|
data?: { catalogEntryPoint?: { path?: string } };
|
||||||
|
};
|
||||||
|
const full = meta.data?.catalogEntryPoint?.path;
|
||||||
|
if (full) {
|
||||||
|
// catmeta returns the absolute path with query string
|
||||||
|
// ("/p5volvo/extern/vehicles/models?lang=en&serviceName=…"); the caller
|
||||||
|
// appends its own lang/serviceName, so keep only the backend-relative
|
||||||
|
// path segment.
|
||||||
|
const withoutQuery = full.split("?")[0];
|
||||||
|
const relative = withoutQuery.startsWith(catalogBase)
|
||||||
|
? withoutQuery.slice(catalogBase.length)
|
||||||
|
: withoutQuery;
|
||||||
|
if (relative.startsWith("/extern/")) resolved = relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`fetchVehicleList: catmeta lookup failed for ${serviceName}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved) {
|
||||||
|
this.logger.log(
|
||||||
|
`fetchVehicleList: ${serviceName} model path resolved from catmeta → ${resolved}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Cache the negative too, so a backend without a usable catmeta does not
|
||||||
|
// re-probe on every browse.
|
||||||
|
await this.redis.set(cacheKey, resolved ?? "none", 30 * 86_400);
|
||||||
|
} catch {
|
||||||
|
// best effort
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse vehicle data from directAccess response.
|
* Parse vehicle data from directAccess response.
|
||||||
*/
|
*/
|
||||||
@@ -1051,7 +1241,7 @@ export class PL24Service {
|
|||||||
const label = (v.key !== undefined ? v.key : v.description) || "";
|
const label = (v.key !== undefined ? v.key : v.description) || "";
|
||||||
const value = (v.key !== undefined ? v.description : v.value) || "";
|
const value = (v.key !== undefined ? v.description : v.value) || "";
|
||||||
if (!label) continue;
|
if (!label) continue;
|
||||||
const key = label.toLowerCase().replace(/[\s\/]+/g, "_");
|
const key = normalizeLabel(label);
|
||||||
if (!(key in vehicleData)) {
|
if (!(key in vehicleData)) {
|
||||||
// Normalize like prNr col3: newlines→space, unescape the literal "\-" some P5
|
// 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"),
|
// backends emit (JLR "XJ 2010 \- 2019", Toyota/Suzuki dates "2023\-11\-29"/"2005\-07"),
|
||||||
@@ -1073,6 +1263,31 @@ export class PL24Service {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like `lookup`, but prefers a human-readable value over a bare internal
|
||||||
|
* code when the record carries both.
|
||||||
|
*
|
||||||
|
* Volvo's vinfoBasic has BOTH "Şanzıman kodu" ("B") and "Şanzıman"
|
||||||
|
* ("6-PSHIFT 2WD / MPS6"), and BOTH "Satis tipi" ("42") and a second
|
||||||
|
* "Satis tipi" ("SALES VERSION 42"). The code-first key order that VAG needs
|
||||||
|
* (its "Şanzıman kodu" IS the useful value, e.g. "MQ200") therefore rendered
|
||||||
|
* a single letter as the Volvo gearbox and a bare number as its series.
|
||||||
|
*
|
||||||
|
* Falls back to the first present value, so a backend that only ever emits
|
||||||
|
* codes behaves exactly as before.
|
||||||
|
*/
|
||||||
|
const lookupDescriptive = (...keys: string[]): string | null => {
|
||||||
|
let firstPresent: string | null = null;
|
||||||
|
for (const k of keys) {
|
||||||
|
const v = vehicleData[k]?.trim();
|
||||||
|
if (!v) continue;
|
||||||
|
if (firstPresent === null) firstPresent = v;
|
||||||
|
// Two characters or fewer, or digits only → an index, not a description.
|
||||||
|
if (v.length > 2 && !/^\d+$/.test(v)) return v;
|
||||||
|
}
|
||||||
|
return firstPresent;
|
||||||
|
};
|
||||||
|
|
||||||
// Extract prNr records for richer vehicle attributes
|
// Extract prNr records for richer vehicle attributes
|
||||||
const prNrRecords = segments.prNr?.records || [];
|
const prNrRecords = segments.prNr?.records || [];
|
||||||
const prNrByCode: Record<string, string> = {};
|
const prNrByCode: Record<string, string> = {};
|
||||||
@@ -1098,26 +1313,47 @@ export class PL24Service {
|
|||||||
const engineCode = lookup("motor_kodu", "engine_code");
|
const engineCode = lookup("motor_kodu", "engine_code");
|
||||||
// Non-VAG P5 OEMs label the engine differently and give a description (sometimes with a
|
// 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.".
|
// 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",
|
// 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",
|
// Toyota "ATM,MTM" (key "atm,mtm" — only spaces/slashes are underscored), MAN "Şanzıman",
|
||||||
// Suzuki "Şanzıman numarası".
|
// Suzuki "Şanzıman numarası".
|
||||||
const transmissionCode = lookup(
|
// normalizeLabel folds ı→i and strips diacritics, so "Şanzıman kodu" and
|
||||||
"şanzıman_kodu",
|
// "ŞANZIMAN KODU" both arrive as "sanziman_kodu". PSA uses "AKTARMA
|
||||||
"sanzıman_kodu",
|
// SİSTEMLERİ" ("5 MEKANİK VİTES KUTUSU"), Subaru "Mission".
|
||||||
|
const transmissionCode = lookupDescriptive(
|
||||||
|
"sanziman_kodu",
|
||||||
"transmission_code",
|
"transmission_code",
|
||||||
"vites_kutusu",
|
"vites_kutusu",
|
||||||
"atm,mtm",
|
"atm,mtm",
|
||||||
"şanzıman",
|
"aktarma_sistemleri",
|
||||||
"şanzıman_numarası",
|
"sanziman",
|
||||||
|
// Volvo in the English locale: "Transmission code" ("B") plus the readable
|
||||||
|
// "Transmission" ("6-PSHIFT 2WD / MPS6"). Without the plain key the
|
||||||
|
// English response falls back to the single-letter code.
|
||||||
|
"transmission",
|
||||||
|
"sanziman_numarasi",
|
||||||
|
"mission",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build body type from prNr K8* (Kaporta formları); brands without a prNr segment
|
// Build body type from prNr K8* (Kaporta formları); brands without a prNr segment
|
||||||
// (e.g. BMW) carry it in vinfoBasic "Karoseri" ("Limousine").
|
// (e.g. BMW) carry it in vinfoBasic "Karoseri" ("Limousine").
|
||||||
const bodyType =
|
const bodyType =
|
||||||
Object.entries(prNrByCode).find(([code]) => code.startsWith("K8"))?.[1] ||
|
Object.entries(prNrByCode).find(([code]) => code.startsWith("K8"))?.[1] ||
|
||||||
lookup("karoseri", "body", "body_type") ||
|
// PSA "GÖVDE TİPİ" ("4 KAPILI SEDAN"), Volvo "Kaporta Stili" ("Sedan").
|
||||||
|
// Volvo: TR "Kaporta Stili" / EN "Body style" ("Sedan"). Its
|
||||||
|
// "Karoseri Tipi Kodu" / "Body style code" is a bare "0" — never matched
|
||||||
|
// here because the lookup is exact-key, and that is deliberate.
|
||||||
|
lookup("karoseri", "body", "body_type", "govde_tipi", "kaporta_stili", "body_style") ||
|
||||||
null;
|
null;
|
||||||
|
|
||||||
// Engine description from prNr D3* (Motor nitelikleri)
|
// Engine description from prNr D3* (Motor nitelikleri)
|
||||||
@@ -1140,7 +1376,7 @@ export class PL24Service {
|
|||||||
// the friendly name is in "Araç" / description ("L200(EUR/MMTH)") — strip the region suffix.
|
// the friendly name is in "Araç" / description ("L200(EUR/MMTH)") — strip the region suffix.
|
||||||
const isMitsubishi = getServiceApiPath(serviceName) === "/p5mitsubishi";
|
const isMitsubishi = getServiceApiPath(serviceName) === "/p5mitsubishi";
|
||||||
const baseModel = isMitsubishi
|
const baseModel = isMitsubishi
|
||||||
? (lookup("araç") || (data.description as string) || lookup("model") || "")
|
? (lookup("arac") || (data.description as string) || lookup("model") || "")
|
||||||
.replace(/\s*\([^)]*\)\s*$/, "")
|
.replace(/\s*\([^)]*\)\s*$/, "")
|
||||||
.trim()
|
.trim()
|
||||||
: lookup("model_bilgisi", "model")?.trim() ||
|
: lookup("model_bilgisi", "model")?.trim() ||
|
||||||
@@ -1163,17 +1399,23 @@ export class PL24Service {
|
|||||||
// production date ("05/09/2014"); p5daimler → only "Teslimat tarihi" (delivery, "04.05.2009").
|
// 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
|
// 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.
|
// 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:
|
year:
|
||||||
|
parsePsaModelYear(lookup("model_yili", "year")) ||
|
||||||
Number.parseInt(lookup("model_yili", "year") || "", 10) ||
|
Number.parseInt(lookup("model_yili", "year") || "", 10) ||
|
||||||
Number.parseInt(
|
Number.parseInt(
|
||||||
(lookup("my", "üretim_tarihi", "uretim_tarihi", "teslimat_tarihi") || "").match(
|
(lookup("my", "uretim_tarihi", "teslimat_tarihi") || "").match(/(19|20)\d{2}/)?.[0] || "",
|
||||||
/(19|20)\d{2}/,
|
|
||||||
)?.[0] || "",
|
|
||||||
10,
|
10,
|
||||||
) ||
|
) ||
|
||||||
|
damToModelYear(lookup("dam")) ||
|
||||||
extractModelYear(vin) ||
|
extractModelYear(vin) ||
|
||||||
0,
|
0,
|
||||||
series: lookup("seri", "satis_tipi", "sales_type"),
|
// Volvo's "Satis tipi" is the bare sales code ("42") with the readable
|
||||||
|
// form ("SALES VERSION 42") in a duplicate row, and its "Türü" is the real
|
||||||
|
// series ("S80") — so prefer whichever of these is actually descriptive.
|
||||||
|
series: lookupDescriptive("seri", "satis_tipi", "sales_type", "turu", "type"),
|
||||||
bodyType,
|
bodyType,
|
||||||
engineCode:
|
engineCode:
|
||||||
engineCode ||
|
engineCode ||
|
||||||
@@ -1187,7 +1429,7 @@ export class PL24Service {
|
|||||||
colorCode:
|
colorCode:
|
||||||
lookup("dis_rengi_boya_numarasi", "exterior_color___paint_code") ||
|
lookup("dis_rengi_boya_numarasi", "exterior_color___paint_code") ||
|
||||||
lookup("tavan_rengi", "roof_color"),
|
lookup("tavan_rengi", "roof_color"),
|
||||||
productionDate: lookup("üretim_tarihi", "date_of_production"),
|
productionDate: lookup("uretim_tarihi", "date_of_production", "teslimat_tarihi"),
|
||||||
raw: data,
|
raw: data,
|
||||||
catalogInfo: {
|
catalogInfo: {
|
||||||
serviceName,
|
serviceName,
|
||||||
@@ -1352,9 +1594,14 @@ export class PL24Service {
|
|||||||
const values = (record.values as Record<string, string>) || {};
|
const values = (record.values as Record<string, string>) || {};
|
||||||
const link = (record.link as Record<string, unknown>) || {};
|
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 =
|
const code =
|
||||||
values.subgroup ||
|
values.subgroup ||
|
||||||
values.illustrationNumber ||
|
values.illustrationNumber ||
|
||||||
|
values.illustration?.replace(/\s+/g, "") ||
|
||||||
values.id ||
|
values.id ||
|
||||||
values.code ||
|
values.code ||
|
||||||
String(record.id || "");
|
String(record.id || "");
|
||||||
@@ -1432,11 +1679,14 @@ export class PL24Service {
|
|||||||
records = responseData.parts as Array<Record<string, unknown>>;
|
records = responseData.parts as Array<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const partRecords = records.filter(
|
const partRecords = records.filter((record) => {
|
||||||
(record) =>
|
if (record.characteristic === "sectionrow") return false;
|
||||||
record.characteristic !== "sectionrow" &&
|
if (!(record.partno || (record.values as Record<string, unknown>)?.partno)) return false;
|
||||||
(record.partno || (record.values as Record<string, unknown>)?.partno),
|
// 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) => {
|
return partRecords.map((part) => {
|
||||||
const values = (part.values as Record<string, string>) || {};
|
const values = (part.values as Record<string, string>) || {};
|
||||||
@@ -1444,11 +1694,14 @@ export class PL24Service {
|
|||||||
const formattedPartNo = ((part.partno as string) || values.partno || "").trim();
|
const formattedPartNo = ((part.partno as string) || values.partno || "").trim();
|
||||||
const cleanPartNo = formattedPartNo.replace(/\s+/g, "");
|
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 quantity = Number.parseInt(qtyStr.trim(), 10) || undefined;
|
||||||
|
|
||||||
const remark = values.remark?.trim() || 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;
|
let superseded: { oldCode: string; newCode: string } | undefined;
|
||||||
const supersededByValue = (part.supersededBy as string) || values.supersededBy || "";
|
const supersededByValue = (part.supersededBy as string) || values.supersededBy || "";
|
||||||
@@ -2052,11 +2305,22 @@ export class PL24Service {
|
|||||||
p5mitsubishi: "/extern/vehicles/vehiclesOverview", // Mitsubishi
|
p5mitsubishi: "/extern/vehicles/vehiclesOverview", // Mitsubishi
|
||||||
p5suzuki: "/extern/vehicle/modelFamilies", // Suzuki
|
p5suzuki: "/extern/vehicle/modelFamilies", // Suzuki
|
||||||
p5man: "/extern/model/categories", // MAN trucks
|
p5man: "/extern/model/categories", // MAN trucks
|
||||||
|
// Pinned 2026-09-20 from each backend's own catmeta `catalogEntryPoint`
|
||||||
|
// (see resolveModelPathFromCatmeta). Before this, p5psa needed five wasted
|
||||||
|
// probe requests to rediscover its path on every call, and p5volvo found
|
||||||
|
// nothing at all — none of the seven guessed paths matches its plural
|
||||||
|
// `/extern/vehicles/models`, so Volvo/Polestar browse silently seeded zero
|
||||||
|
// models and kept serving the dead P4 listing.
|
||||||
|
p5psa: "/extern/vehicle/catalogs", // Peugeot, Citroën, DS, psa_opel, psa_vauxhall
|
||||||
|
p5volvo: "/extern/vehicles/models", // Volvo, Polestar — NOTE: plural "vehicles"
|
||||||
};
|
};
|
||||||
|
|
||||||
// catalogBase is like "/p5vwag" — strip leading slash for map lookup
|
// catalogBase is like "/p5vwag" — strip leading slash for map lookup
|
||||||
const backendKey = catalogBase.replace(/^\//, "");
|
const backendKey = catalogBase.replace(/^\//, "");
|
||||||
const modelPath = BACKEND_MODEL_PATH[backendKey] ?? "/extern/vehicle/modelfamilies";
|
const modelPath =
|
||||||
|
BACKEND_MODEL_PATH[backendKey] ??
|
||||||
|
(await this.resolveModelPathFromCatmeta(catalogBase, serviceName, headers)) ??
|
||||||
|
"/extern/vehicle/modelfamilies";
|
||||||
|
|
||||||
const url = `${this.baseUrl}${catalogBase}${modelPath}?lang=${this.language}&serviceName=${serviceName}`;
|
const url = `${this.baseUrl}${catalogBase}${modelPath}?lang=${this.language}&serviceName=${serviceName}`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { PL24_WMI_SERVICE_MAP, SERVICE_TO_BRAND } from "./pl24.types";
|
import { PL24_SERVICE_CATALOGS, PL24_WMI_SERVICE_MAP, SERVICE_TO_BRAND } from "./pl24.types";
|
||||||
|
|
||||||
// Q6 routing additions (undecoded-vin-rca.md). Both target services are already
|
// Q6 routing additions (undecoded-vin-rca.md). Both target services are already
|
||||||
// live in prod (nissan_parts: JN1 success; mercedesvans_parts: WDF decodes today),
|
// live in prod (nissan_parts: JN1 success; mercedesvans_parts: WDF decodes today),
|
||||||
@@ -27,3 +27,71 @@ describe("PL24_WMI_SERVICE_MAP — Q6 routing additions", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PL24'te bulunmayan WMI'ler: haritada yer almamalı, yoksa her sorguda boşuna
|
||||||
|
// upstream isteği üretir ve gerçek kaynağa (pcat/emex/vinpin) geçişi geciktirir.
|
||||||
|
describe("PL24_WMI_SERVICE_MAP — kapsam dışı WMI'ler", () => {
|
||||||
|
it("VR7 haritada değil (canlı: HTTP 410 'no brands found for WMI = VR7')", () => {
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.VR7).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("NM4 (Tofaş) haritada değil (canlı: HTTP 410)", () => {
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.NM4).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("canlı doğrulanmış WMI'ler doğru katalogda", () => {
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.JF1).toBe("subaru_parts");
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.ZAR).toBe("alfa_parts");
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.VXK).toBe("psa_opel_parts");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2026-09-20 canlı WMI servisi taraması (plv2.md, bulgu types_wmi-06/09).
|
||||||
|
* Prod'daki 1.406 haritasız araçtan hangilerinin PL24'te gerçekten karşılığı
|
||||||
|
* olduğu `/pl24-wmi/ext/api/2.0/decode` ile tek tek soruldu; bu test o cevapları
|
||||||
|
* kilitliyor — özellikle "yok" cevaplarını, çünkü onları haritaya eklemek
|
||||||
|
* boşuna istek üretir.
|
||||||
|
*/
|
||||||
|
describe("WMI haritası — canlı WMI servisi taraması (2026-09-20)", () => {
|
||||||
|
it("Renault yeniden açıldı (VIN tanımlama askısı kalktı)", () => {
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.VF1).toBe("renault_parts");
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.VF6).toBe("renault_parts");
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.VNE).toBe("renault_parts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("canlı servisin çözdüğü yeni WMI'ler haritada", () => {
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.NLH).toBe("hyundai_parts");
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.TMA).toBe("hyundai_parts");
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.NLJ).toBe("hyundai_parts");
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.KMF).toBe("hyundai_parts");
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.KNE).toBe("kia_parts");
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.KNC).toBe("kia_parts");
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.MMC).toBe("mmc_parts");
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.XMC).toBe("mmc_parts");
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.JSA).toBe("suzuki_parts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("NMB binek Mercedes değil, kamyon kataloğuna gider", () => {
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.NMB).toBe("mercedestrucks_parts");
|
||||||
|
// Binek WMI'leri bozulmadı.
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.WDD).toBe("mercedes_parts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PL24'te olmayan WMI'ler haritaya EKLENMEDİ (HTTP 410)", () => {
|
||||||
|
// Honda ve Chevrolet'nin PL24'te kataloğu yok; eklemek boşuna istek olurdu.
|
||||||
|
for (const wmi of ["JHM", "SHH", "SHS", "MAK", "NLA", "KL1", "NM4", "VR7"]) {
|
||||||
|
expect(PL24_WMI_SERVICE_MAP[wmi]).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("JMZ (Mazda) eklenmedi — servis Ford kataloğu öneriyor, marka tutarsız", () => {
|
||||||
|
expect(PL24_WMI_SERVICE_MAP.JMZ).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("eşlenen her servisin katalog tanımı var", () => {
|
||||||
|
for (const [wmi, svc] of Object.entries(PL24_WMI_SERVICE_MAP)) {
|
||||||
|
expect(PL24_SERVICE_CATALOGS[svc], `${wmi} → ${svc}`).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -53,6 +53,58 @@ export interface PL24JWTPayload {
|
|||||||
alo: string;
|
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 {
|
export interface PL24TokenData {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
@@ -287,19 +339,61 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
|
|||||||
|
|
||||||
// PSA Group (Citroën, Peugeot)
|
// PSA Group (Citroën, Peugeot)
|
||||||
citroen_parts: {
|
citroen_parts: {
|
||||||
basePath: "/psa",
|
basePath: "/pl24-app/citroen_parts",
|
||||||
apiPath: "/psa",
|
apiPath: "/p5psa",
|
||||||
architecture: "LEGACY_PSA",
|
architecture: "P5_MODERN",
|
||||||
},
|
},
|
||||||
citroenDs_parts: {
|
citroenDs_parts: {
|
||||||
basePath: "/psa",
|
basePath: "/pl24-app/citroenDs_parts",
|
||||||
apiPath: "/psa",
|
apiPath: "/p5psa",
|
||||||
architecture: "LEGACY_PSA",
|
architecture: "P5_MODERN",
|
||||||
},
|
},
|
||||||
peugeot_parts: {
|
peugeot_parts: {
|
||||||
basePath: "/psa",
|
basePath: "/pl24-app/peugeot_parts",
|
||||||
apiPath: "/psa",
|
apiPath: "/p5psa",
|
||||||
architecture: "LEGACY_PSA",
|
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
|
// Ford Group
|
||||||
@@ -352,14 +446,14 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
|
|||||||
|
|
||||||
// Volvo/Polestar
|
// Volvo/Polestar
|
||||||
volvo_parts: {
|
volvo_parts: {
|
||||||
basePath: "/volvo",
|
basePath: "/pl24-app/volvo_parts",
|
||||||
apiPath: "/volvo",
|
apiPath: "/p5volvo",
|
||||||
architecture: "LEGACY_VOLVO",
|
architecture: "P5_MODERN",
|
||||||
},
|
},
|
||||||
polestar_parts: {
|
polestar_parts: {
|
||||||
basePath: "/volvo",
|
basePath: "/pl24-app/polestar_parts",
|
||||||
apiPath: "/volvo",
|
apiPath: "/p5volvo",
|
||||||
architecture: "LEGACY_VOLVO",
|
architecture: "P5_MODERN",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Fiat Group (FCA) — P5 Modern catalog at /p5fiat, requires de-708171 account.
|
// Fiat Group (FCA) — P5 Modern catalog at /p5fiat, requires de-708171 account.
|
||||||
@@ -446,6 +540,10 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
|||||||
|
|
||||||
// Mercedes-Benz
|
// Mercedes-Benz
|
||||||
WDB: "mercedes_parts",
|
WDB: "mercedes_parts",
|
||||||
|
// NMB: canlı WMI servisi bunu mercedes_parts'a DEĞİL mercedestrucks_parts'a
|
||||||
|
// çözüyor (error:false) — 30 prod aracı "Mercedes-Benz" etiketliydi ama binek
|
||||||
|
// kataloğunda yok.
|
||||||
|
NMB: "mercedestrucks_parts",
|
||||||
WDD: "mercedes_parts",
|
WDD: "mercedes_parts",
|
||||||
WDC: "mercedes_parts",
|
WDC: "mercedes_parts",
|
||||||
W1K: "mercedes_parts",
|
W1K: "mercedes_parts",
|
||||||
@@ -484,15 +582,21 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
|||||||
JTJ: "lexus_parts",
|
JTJ: "lexus_parts",
|
||||||
"2T2": "lexus_parts",
|
"2T2": "lexus_parts",
|
||||||
|
|
||||||
// Renault — DISABLED: PL24 has suspended Renault VIN identification ("Bu marka
|
// Renault — RE-ENABLED 2026-09-20. It was disabled while PL24 had suspended
|
||||||
// için şasi numarası tanımlamasının belirsiz bir süre için mevcut olmayacağını
|
// Renault VIN identification ("Bu marka için şasi numarası tanımlamasının
|
||||||
// üzülerek bildiririz."). renault_parts authorizes fine but every decode throws
|
// belirsiz bir süre için mevcut olmayacağını üzülerek bildiririz."), which both
|
||||||
// that message → wasted ~1s call AND it trips the PL24 circuit breaker, which
|
// wasted a call per decode and, back then, tripped the global PL24 breaker.
|
||||||
// then skips PL24 for ALL brands. PCAT + EMEX cover Renault. Re-enable when PL24
|
// BOTH reasons are gone, each verified against prod rather than assumed:
|
||||||
// restores Renault VIN decode.
|
// 1. The suspension is over — live directAccess on /p5renault for a real
|
||||||
// VF1: "renault_parts",
|
// customer VIN (VF14SRCL458170337) returns resultStatus
|
||||||
// VF6: "renault_parts",
|
// VEHICLE_IDENTIFIED, "SYMBOL II/LOGAN II", and the WMI service answers
|
||||||
// VNE: "renault_parts",
|
// {service: renault_parts, valid: true, error: false}.
|
||||||
|
// 2. The breaker no longer counts definitive upstream negatives, only
|
||||||
|
// transient transport faults (see vehicles.service `isTransient`).
|
||||||
|
// 450 prod vehicles carry VF1 and had no PL24 catalog at all.
|
||||||
|
VF1: "renault_parts",
|
||||||
|
VF6: "renault_parts",
|
||||||
|
VNE: "renault_parts",
|
||||||
|
|
||||||
// Dacia
|
// Dacia
|
||||||
UU1: "dacia_parts",
|
UU1: "dacia_parts",
|
||||||
@@ -512,6 +616,10 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
|||||||
WMH: "man_parts",
|
WMH: "man_parts",
|
||||||
|
|
||||||
// Mitsubishi
|
// Mitsubishi
|
||||||
|
// XMC / MMC: canlı WMI servisi ikisini de mmc_parts'a çözüyor ve P5 doğruluyor
|
||||||
|
// (error:false) — 26 prod aracı haritasızdı.
|
||||||
|
MMC: "mmc_parts", // Mitsubishi Japan
|
||||||
|
XMC: "mmc_parts", // Mitsubishi (diğer pazarlar)
|
||||||
JMB: "mmc_parts",
|
JMB: "mmc_parts",
|
||||||
JMY: "mmc_parts",
|
JMY: "mmc_parts",
|
||||||
MMB: "mmc_parts",
|
MMB: "mmc_parts",
|
||||||
@@ -521,6 +629,7 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
|||||||
JS2: "suzuki_parts",
|
JS2: "suzuki_parts",
|
||||||
JS3: "suzuki_parts",
|
JS3: "suzuki_parts",
|
||||||
TSM: "suzuki_parts",
|
TSM: "suzuki_parts",
|
||||||
|
JSA: "suzuki_parts", // Suzuki (canlı WMI: suzuki_parts, error:false)
|
||||||
MA3: "suzuki_parts",
|
MA3: "suzuki_parts",
|
||||||
MBH: "suzuki_parts",
|
MBH: "suzuki_parts",
|
||||||
|
|
||||||
@@ -536,18 +645,27 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
|||||||
ZFA: "fiatp_parts", // Fiat SpA Italy (most common)
|
ZFA: "fiatp_parts", // Fiat SpA Italy (most common)
|
||||||
ZCF: "fiatp_parts", // Tofaş Turkey (Linea, Fiorino, etc.)
|
ZCF: "fiatp_parts", // Tofaş Turkey (Linea, Fiorino, etc.)
|
||||||
ZFF: "fiatp_parts", // Abarth / Fiat Sport
|
ZFF: "fiatp_parts", // Abarth / Fiat Sport
|
||||||
ZAR: "fiatp_parts", // Alfa Romeo
|
ZAR: "alfa_parts", // Alfa Romeo (kendi kataloğu, /p5fiat backend)
|
||||||
ZLA: "fiatp_parts", // Lancia
|
ZLA: "lancia_parts", // Lancia (kendi kataloğu, /p5fiat backend)
|
||||||
|
|
||||||
// Fiat Commercial (fiatt_parts)
|
// Fiat Commercial (fiatt_parts)
|
||||||
ZFC: "fiatt_parts", // Fiat Commercial
|
ZFC: "fiatt_parts", // Fiat Commercial
|
||||||
|
|
||||||
// Hyundai
|
// Hyundai
|
||||||
KMH: "hyundai_parts", // Hyundai Korea Motor House
|
KMH: "hyundai_parts", // Hyundai Korea Motor House
|
||||||
|
// Aşağıdakiler 2026-09-20'de canlı WMI servisinden alındı; hepsi hyundai_parts.
|
||||||
|
// TMA iki marka döndürüyor (hyundai_parts + kia_parts) — Hyundai Assan (Türkiye)
|
||||||
|
// üretimi olduğu için hyundai seçildi.
|
||||||
|
NLH: "hyundai_parts", // Hyundai Assan / Türkiye (106 prod aracı)
|
||||||
|
TMA: "hyundai_parts", // Hyundai Türkiye (49)
|
||||||
|
NLJ: "hyundai_parts", // Hyundai (8)
|
||||||
|
KMF: "hyundai_parts", // Hyundai (7)
|
||||||
TMK: "hyundai_parts", // Hyundai (Turkey/other markets)
|
TMK: "hyundai_parts", // Hyundai (Turkey/other markets)
|
||||||
|
|
||||||
// Kia
|
// Kia
|
||||||
KNA: "kia_parts", // Kia (worldwide production)
|
KNA: "kia_parts", // Kia (worldwide production)
|
||||||
|
KNE: "kia_parts", // Kia (canlı WMI, 31 prod aracı)
|
||||||
|
KNC: "kia_parts", // Kia (canlı WMI, 8)
|
||||||
U5Y: "kia_parts", // Kia Slovakia
|
U5Y: "kia_parts", // Kia Slovakia
|
||||||
|
|
||||||
// Nissan
|
// Nissan
|
||||||
@@ -563,8 +681,16 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
|||||||
JNK: "infiniti_parts", // Infiniti (Japan/Korea)
|
JNK: "infiniti_parts", // Infiniti (Japan/Korea)
|
||||||
|
|
||||||
// Opel / Vauxhall
|
// 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)
|
W0L: "opel_parts", // Opel AG (Germany)
|
||||||
W0V: "opel_parts", // Opel (newer Stellantis-era WMI)
|
W0V: "opel_parts", // Opel (newer Stellantis-era WMI)
|
||||||
|
VXK: "psa_opel_parts", // PSA-platform Opel (Corsa F, Mokka B) → Stellantis kataloğu /p5psa
|
||||||
|
|
||||||
// Citroën (PSA)
|
// Citroën (PSA)
|
||||||
VF7: "citroen_parts", // Citroën SA (France)
|
VF7: "citroen_parts", // Citroën SA (France)
|
||||||
@@ -573,7 +699,19 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
|||||||
// Peugeot (PSA)
|
// Peugeot (PSA)
|
||||||
VF3: "peugeot_parts", // Peugeot SA (France)
|
VF3: "peugeot_parts", // Peugeot SA (France)
|
||||||
VR3: "peugeot_parts", // Peugeot (newer WMI)
|
VR3: "peugeot_parts", // Peugeot (newer WMI)
|
||||||
VR7: "peugeot_parts", // Peugeot (newer WMI)
|
// VR7: PL24'ün WMI veritabanında HİÇ YOK — canlı doğrulama (2026-09-19,
|
||||||
|
// /pl24-wmi/ext/api/2.0/decode): HTTP 410 "no brands found for WMI = VR7",
|
||||||
|
// tıpkı Tofaş NM4 gibi. Önceden peugeot_parts'a yönlendiriliyordu; prod'daki
|
||||||
|
// 17 VR7 aracının hepsi model çözülmeden ("Peugeot Peugeot") ve 0 kategoriyle
|
||||||
|
// kaydedilmişti. Haritada tutmak yalnız boşuna PL24 isteği üretir ve
|
||||||
|
// pcat/emex/vinpin fallback'ini geciktirir.
|
||||||
|
|
||||||
|
// PL24'TE HİÇ OLMAYANLAR — canlı WMI servisi HTTP 410 "no brands found for WMI"
|
||||||
|
// dedi (2026-09-20), tıpkı NM4 ve VR7 gibi. Haritaya eklenmemeleri kasıtlı:
|
||||||
|
// eklemek yalnız boşuna istek üretir ve pcat/emex/vinpin fallback'ini geciktirir.
|
||||||
|
// JHM, SHH, SHS, MAK, NLA (Honda) · KL1 (Chevrolet)
|
||||||
|
// JMZ (Mazda) 410 DEĞİL ama fordp/fordt döndürüyor (Ford-Mazda platform ortaklığı
|
||||||
|
// dönemi); marka tutarsız olduğu için eklenmedi — bir Mazda 3 Ford kataloğunda yok.
|
||||||
|
|
||||||
// Volvo
|
// Volvo
|
||||||
YV1: "volvo_parts", // Volvo Cars (Sweden)
|
YV1: "volvo_parts", // Volvo Cars (Sweden)
|
||||||
@@ -793,6 +931,13 @@ export const SERVICE_TO_BRAND: Record<string, string> = {
|
|||||||
vauxhall_parts: "Opel",
|
vauxhall_parts: "Opel",
|
||||||
// Volvo/Polestar
|
// Volvo/Polestar
|
||||||
volvo_parts: "Volvo",
|
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",
|
polestar_parts: "Polestar",
|
||||||
// Fiat Group
|
// Fiat Group
|
||||||
fiatp_parts: "Fiat",
|
fiatp_parts: "Fiat",
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { ConfigService } from "@nestjs/config";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ProxyHealthService } from "./proxy-health.service";
|
||||||
|
import { isProxyConnectFailure } from "./proxy-telemetry.service";
|
||||||
|
|
||||||
|
function makeConfig(overrides: Record<string, unknown> = {}): ConfigService {
|
||||||
|
return {
|
||||||
|
get: vi.fn((key: string, def?: unknown) =>
|
||||||
|
Object.hasOwn(overrides, key) ? overrides[key] : def,
|
||||||
|
),
|
||||||
|
} as unknown as ConfigService;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ProxyHealthService — Floxy failover gate", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts healthy (Floxy not down)", () => {
|
||||||
|
const h = new ProxyHealthService(makeConfig());
|
||||||
|
expect(h.isFloxyDown()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trips for the cooldown window then self-heals when it lapses", () => {
|
||||||
|
const h = new ProxyHealthService(makeConfig({ FLOXY_FAILOVER_COOLDOWN_MS: 180_000 }));
|
||||||
|
h.reportFloxyFailure("test");
|
||||||
|
expect(h.isFloxyDown()).toBe(true);
|
||||||
|
expect(h.cooldownRemainingSec()).toBe(180);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(179_000);
|
||||||
|
expect(h.isFloxyDown()).toBe(true);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(2_000); // past the 180s window
|
||||||
|
expect(h.isFloxyDown()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a Floxy success clears the cooldown immediately", () => {
|
||||||
|
const h = new ProxyHealthService(makeConfig({ FLOXY_FAILOVER_COOLDOWN_MS: 180_000 }));
|
||||||
|
h.reportFloxyFailure();
|
||||||
|
expect(h.isFloxyDown()).toBe(true);
|
||||||
|
h.reportFloxySuccess();
|
||||||
|
expect(h.isFloxyDown()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reporting a failure extends the window", () => {
|
||||||
|
const h = new ProxyHealthService(makeConfig({ FLOXY_FAILOVER_COOLDOWN_MS: 100_000 }));
|
||||||
|
h.reportFloxyFailure();
|
||||||
|
vi.advanceTimersByTime(90_000);
|
||||||
|
h.reportFloxyFailure(); // extends from now
|
||||||
|
vi.advanceTimersByTime(20_000); // 110s since first failure, 20s since second
|
||||||
|
expect(h.isFloxyDown()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isProxyConnectFailure", () => {
|
||||||
|
it("matches proxy/tunnel connection failures (the Floxy-down signal)", () => {
|
||||||
|
for (const msg of [
|
||||||
|
"page.goto: net::ERR_TUNNEL_CONNECTION_FAILED at https://emexdwc.ae/",
|
||||||
|
"net::ERR_PROXY_CONNECTION_FAILED",
|
||||||
|
"TypeError: fetch failed",
|
||||||
|
"connect ECONNREFUSED 1.2.3.4:12321",
|
||||||
|
"Client network socket disconnected (ECONNRESET)",
|
||||||
|
]) {
|
||||||
|
expect(isProxyConnectFailure(new Error(msg)), msg).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT match a plain slow-site navigation timeout", () => {
|
||||||
|
const e = new Error("Timeout 30000ms exceeded.");
|
||||||
|
e.name = "TimeoutError";
|
||||||
|
expect(isProxyConnectFailure(e)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared Floxy-health gate. Floxy is the residential primary for EMEX + pcat
|
||||||
|
* egress (HTTP fetch, Playwright capture, browser scrape). When Floxy goes dark
|
||||||
|
* — balance exhausted (proxy returns 402) or the entry node refuses CONNECT
|
||||||
|
* (`ERR_TUNNEL_CONNECTION_FAILED` / `fetch failed`) — every consumer otherwise
|
||||||
|
* keeps hammering the dead proxy and the decode success rate collapses (this
|
||||||
|
* happened 2026-06-11/13: ~17k tunnel failures/24h, decode 78% → 45%).
|
||||||
|
*
|
||||||
|
* This singleton is the one place that answers "should we route through Floxy
|
||||||
|
* right now?". Any consumer that sees a Floxy transport failure trips the gate;
|
||||||
|
* for the cooldown window all consumers fail over to the DataImpulse datacenter
|
||||||
|
* pool instead. The gate self-heals: once the cooldown lapses, the next request
|
||||||
|
* probes Floxy again and `reportFloxySuccess()` clears it (or re-trips if still
|
||||||
|
* down). A live Floxy answer at any time clears the cooldown immediately.
|
||||||
|
*
|
||||||
|
* In-memory only and deliberately dependency-light (no DB, no Redis): a single
|
||||||
|
* api/worker process is the egress unit, and a stale cooldown is self-correcting
|
||||||
|
* within one window. Cooldown is tunable via FLOXY_FAILOVER_COOLDOWN_MS.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ProxyHealthService {
|
||||||
|
private readonly logger = new Logger(ProxyHealthService.name);
|
||||||
|
private readonly cooldownMs: number;
|
||||||
|
private floxyDownUntil = 0;
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {
|
||||||
|
const raw = Number(this.config.get("FLOXY_FAILOVER_COOLDOWN_MS", 180_000));
|
||||||
|
this.cooldownMs = Number.isFinite(raw) && raw >= 0 ? raw : 180_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while Floxy is in cooldown — consumers should route via DataImpulse. */
|
||||||
|
isFloxyDown(): boolean {
|
||||||
|
return Date.now() < this.floxyDownUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floxy transport failure observed — open (or extend) the cooldown so every
|
||||||
|
* consumer fails over to DataImpulse. Logged once per outage, not per failure,
|
||||||
|
* so a 6k-failure storm doesn't flood the logs.
|
||||||
|
*/
|
||||||
|
reportFloxyFailure(reason?: string): void {
|
||||||
|
const wasDown = this.isFloxyDown();
|
||||||
|
this.floxyDownUntil = Date.now() + this.cooldownMs;
|
||||||
|
if (!wasDown) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Floxy proxy unhealthy${reason ? ` (${reason})` : ""} — failing over to DataImpulse for ${Math.round(
|
||||||
|
this.cooldownMs / 1000,
|
||||||
|
)}s`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Floxy request just succeeded — Floxy is healthy again, clear the gate. */
|
||||||
|
reportFloxySuccess(): void {
|
||||||
|
if (this.isFloxyDown()) {
|
||||||
|
this.logger.log("Floxy proxy recovered — resuming Floxy-primary routing");
|
||||||
|
}
|
||||||
|
this.floxyDownUntil = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds remaining in the current cooldown (0 when healthy). */
|
||||||
|
cooldownRemainingSec(): number {
|
||||||
|
return Math.max(0, Math.ceil((this.floxyDownUntil - Date.now()) / 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ProxyHealthService } from "./proxy-health.service";
|
||||||
|
import { ProxyTelemetryService } from "./proxy-telemetry.service";
|
||||||
|
|
||||||
|
// Imported by PartsCatalogsModule and EmexModule; Nest module caching makes the
|
||||||
|
// services singletons (one shared flush buffer / one shared Floxy-health gate)
|
||||||
|
// across both.
|
||||||
|
@Module({
|
||||||
|
providers: [ProxyTelemetryService, ProxyHealthService],
|
||||||
|
exports: [ProxyTelemetryService, ProxyHealthService],
|
||||||
|
})
|
||||||
|
export class ProxyTelemetryModule {}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { Inject, Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
|
||||||
|
import { DATABASE, type Database } from "../../database/database.provider";
|
||||||
|
import { proxyLogs } from "../../database/schema/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy telemetry — fire-and-forget logging of every proxied upstream attempt
|
||||||
|
* (pcat call/capture/validate, emex http, pl24 catalog/auth) into `proxy_logs`, so the Süper Panel
|
||||||
|
* can grade proxy providers per service and track banned exit IPs / sessions.
|
||||||
|
*
|
||||||
|
* Design constraints:
|
||||||
|
* - MUST never affect the request path: record() only pushes to an in-memory
|
||||||
|
* buffer; a timer flushes batches, and every DB error is swallowed.
|
||||||
|
* - Bounded memory: the buffer is capped; when full, oldest rows are dropped.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ProxyServiceLeg =
|
||||||
|
| "pcat_call"
|
||||||
|
| "pcat_capture"
|
||||||
|
| "pcat_validate"
|
||||||
|
| "emex_http"
|
||||||
|
| "pl24_http"
|
||||||
|
| "pl24_auth";
|
||||||
|
export type ProxyProviderName = "floxy" | "dataimpulse" | "none";
|
||||||
|
|
||||||
|
export interface ProxyLogEvent {
|
||||||
|
service: ProxyServiceLeg;
|
||||||
|
provider: ProxyProviderName;
|
||||||
|
sessionKey?: string | null;
|
||||||
|
exitIp?: string | null;
|
||||||
|
targetHost?: string | null;
|
||||||
|
statusCode?: number | null;
|
||||||
|
errorKind?: string | null;
|
||||||
|
success: boolean;
|
||||||
|
durationMs?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLUSH_INTERVAL_MS = 5_000;
|
||||||
|
const FLUSH_BATCH_SIZE = 50;
|
||||||
|
const BUFFER_CAP = 500;
|
||||||
|
|
||||||
|
// Upstream statuses that signal an IP-level block (not an auth/data problem):
|
||||||
|
// 403 = forbidden (WAF/geo/IP ban), 429 = rate limited. 402 is the proxy
|
||||||
|
// provider itself rejecting (balance exhausted — Floxy did this 2026-06-10);
|
||||||
|
// counted as banned=false but worth surfacing via status_code.
|
||||||
|
export function isBanStatus(status: number | null | undefined): boolean {
|
||||||
|
return status === 403 || status === 429;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classify a transport error (no HTTP answer) into a coarse error_kind. */
|
||||||
|
export function classifyTransportError(err: unknown): string {
|
||||||
|
const e = err as Error & { cause?: unknown };
|
||||||
|
const text = `${e?.name ?? ""} ${e?.message ?? ""} ${String(e?.cause ?? "")}`;
|
||||||
|
if (/TimeoutError|UND_ERR_CONNECT_TIMEOUT|UND_ERR_HEADERS_TIMEOUT/i.test(text)) return "timeout";
|
||||||
|
if (/AbortError|aborted/i.test(text)) return "aborted";
|
||||||
|
if (/ECONNREFUSED|UND_ERR_SOCKET|connect/i.test(text)) return "connect";
|
||||||
|
if (/ECONNRESET|socket hang up|other side closed|terminated/i.test(text)) return "reset";
|
||||||
|
if (/EAI_AGAIN|ENOTFOUND/i.test(text)) return "dns";
|
||||||
|
return "transport";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when an error means we could not even reach the upstream THROUGH the
|
||||||
|
* proxy — the proxy entry node refused/closed/reset the CONNECT, or undici/
|
||||||
|
* Chromium reported a tunnel/proxy connection failure ("fetch failed",
|
||||||
|
* ERR_TUNNEL_CONNECTION_FAILED, ERR_PROXY_CONNECTION_FAILED, ECONNREFUSED…).
|
||||||
|
* This is the "the proxy itself is dead" signal that should trip Floxy failover.
|
||||||
|
*
|
||||||
|
* Deliberately EXCLUDES a plain navigation/request TimeoutError with no proxy
|
||||||
|
* marker: a slow-but-alive residential exit shouldn't trip failover — only a
|
||||||
|
* genuine connection failure should.
|
||||||
|
*/
|
||||||
|
export function isProxyConnectFailure(err: unknown): boolean {
|
||||||
|
const e = err as Error & { cause?: unknown };
|
||||||
|
const text = `${e?.name ?? ""} ${e?.message ?? ""} ${String(e?.cause ?? "")}`;
|
||||||
|
return /ERR_TUNNEL_CONNECTION_FAILED|ERR_PROXY_CONNECTION_FAILED|ERR_SOCKS_CONNECTION_FAILED|NS_ERROR_PROXY|ECONNREFUSED|ECONNRESET|UND_ERR_SOCKET|UND_ERR_CONNECT_TIMEOUT|socket hang up|other side closed|tunnel|proxy connection|fetch failed/i.test(
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive provider + session key from a proxy URL the way the integrations
|
||||||
|
* build them: Floxy passwords may embed `_session-<id>_lifetime-<n>` (sticky);
|
||||||
|
* DataImpulse rotates by port, so the port IS the session identity.
|
||||||
|
*/
|
||||||
|
export function describeProxyUrl(proxyUrl: string | null | undefined): {
|
||||||
|
provider: ProxyProviderName;
|
||||||
|
sessionKey: string | null;
|
||||||
|
} {
|
||||||
|
if (!proxyUrl) return { provider: "none", sessionKey: null };
|
||||||
|
try {
|
||||||
|
const u = new URL(proxyUrl);
|
||||||
|
if (/floxy/i.test(u.hostname)) {
|
||||||
|
const m = decodeURIComponent(u.password).match(/_session-([A-Za-z0-9]+)/);
|
||||||
|
return { provider: "floxy", sessionKey: m ? `s-${m[1]}` : null };
|
||||||
|
}
|
||||||
|
return { provider: "dataimpulse", sessionKey: `di:${u.port}` };
|
||||||
|
} catch {
|
||||||
|
return { provider: "none", sessionKey: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the residential exit IP of a (sticky) proxy session with one cheap
|
||||||
|
* probe through the same proxy. Only meaningful for sticky sessions — on a
|
||||||
|
* rotating leg the probe and the real request exit from different IPs.
|
||||||
|
*/
|
||||||
|
export async function resolveExitIp(proxyUrl: string, timeoutMs = 5_000): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { ProxyAgent } = await import("undici");
|
||||||
|
const res = await fetch("https://api.ipify.org?format=text", {
|
||||||
|
dispatcher: new ProxyAgent({ uri: proxyUrl, connect: { timeout: timeoutMs } }),
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
} as unknown as RequestInit);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const ip = (await res.text()).trim();
|
||||||
|
return /^[0-9a-fA-F.:]{3,45}$/.test(ip) ? ip : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProxyTelemetryService implements OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(ProxyTelemetryService.name);
|
||||||
|
private buffer: ProxyLogEvent[] = [];
|
||||||
|
private flushTimer: NodeJS.Timeout | null = null;
|
||||||
|
private flushing = false;
|
||||||
|
|
||||||
|
constructor(@Inject(DATABASE) private readonly db: Database) {
|
||||||
|
this.flushTimer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
|
||||||
|
// Never keep the process alive just to flush telemetry.
|
||||||
|
this.flushTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue one proxied-attempt record. Never throws, never blocks. */
|
||||||
|
record(event: ProxyLogEvent): void {
|
||||||
|
if (this.buffer.length >= BUFFER_CAP) this.buffer.shift();
|
||||||
|
this.buffer.push(event);
|
||||||
|
if (this.buffer.length >= FLUSH_BATCH_SIZE) void this.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flush(): Promise<void> {
|
||||||
|
if (this.flushing || this.buffer.length === 0) return;
|
||||||
|
this.flushing = true;
|
||||||
|
const batch = this.buffer.splice(0, FLUSH_BATCH_SIZE * 2);
|
||||||
|
try {
|
||||||
|
await this.db.insert(proxyLogs).values(
|
||||||
|
batch.map((e) => ({
|
||||||
|
service: e.service,
|
||||||
|
provider: e.provider,
|
||||||
|
sessionKey: e.sessionKey ?? null,
|
||||||
|
exitIp: e.exitIp ?? null,
|
||||||
|
targetHost: e.targetHost?.slice(0, 120) ?? null,
|
||||||
|
statusCode: e.statusCode ?? null,
|
||||||
|
errorKind: e.errorKind ?? null,
|
||||||
|
success: e.success,
|
||||||
|
banned: isBanStatus(e.statusCode),
|
||||||
|
durationMs: e.durationMs != null ? Math.round(e.durationMs) : null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Telemetry must never matter more than the product. Drop the batch.
|
||||||
|
this.logger.debug(
|
||||||
|
`proxy_logs flush failed (${batch.length} rows): ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.flushing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
if (this.flushTimer) clearInterval(this.flushTimer);
|
||||||
|
await this.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
148
apps/api/src/integrations/rpartstore/rpartstore.auth.spec.ts
Normal file
148
apps/api/src/integrations/rpartstore/rpartstore.auth.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RpartstoreAuthError, decodeJwtExpiry, loginRpartstore } from "./rpartstore.auth";
|
||||||
|
|
||||||
|
const b64url = (s: string): string => Buffer.from(s).toString("base64url");
|
||||||
|
const makeJwt = (claims: Record<string, unknown>): string =>
|
||||||
|
`${b64url('{"alg":"RS256"}')}.${b64url(JSON.stringify(claims))}.sig`;
|
||||||
|
|
||||||
|
function jsonResponse(
|
||||||
|
body: unknown,
|
||||||
|
init: { status?: number; headers?: Record<string, string> } = {},
|
||||||
|
) {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: init.status ?? 200,
|
||||||
|
headers: { "content-type": "application/json", ...(init.headers ?? {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("loginRpartstore (Okta IDX, browser-free)", () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it("walks authorize → introspect → identify → answer → redirect → token and returns the access token", async () => {
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||||
|
const jwt = makeJwt({ sub: "G123326", exp });
|
||||||
|
const calls: { url: string; body?: string; cookie?: string }[] = [];
|
||||||
|
let redirectState = "";
|
||||||
|
|
||||||
|
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||||
|
const u = String(url);
|
||||||
|
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||||
|
calls.push({
|
||||||
|
url: u,
|
||||||
|
body: init?.body ? String(init.body) : undefined,
|
||||||
|
cookie: headers.Cookie,
|
||||||
|
});
|
||||||
|
if (u.includes("/v1/authorize")) {
|
||||||
|
redirectState = new URL(u).searchParams.get("state") ?? "";
|
||||||
|
return new Response("<script>var stateToken = '02.id.abc\\x2Ddef';</script>", {
|
||||||
|
status: 200,
|
||||||
|
headers: { "set-cookie": "JSESSIONID=js1; Path=/; HttpOnly" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (u.endsWith("/idp/idx/introspect")) return jsonResponse({ stateHandle: "sh-1" });
|
||||||
|
if (u.endsWith("/idp/idx/identify")) return jsonResponse({ stateHandle: "sh-2" });
|
||||||
|
if (u.endsWith("/idp/idx/challenge/answer")) {
|
||||||
|
return jsonResponse({
|
||||||
|
success: { href: "https://sso.renault.com/login/token/redirect?stateToken=st" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (u.includes("/login/token/redirect")) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
location: `https://rpartstore.renault.com/idp-redirect?code=CODE1&state=${redirectState}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (u.endsWith("/v1/token")) {
|
||||||
|
return jsonResponse({
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: 3600,
|
||||||
|
access_token: jwt,
|
||||||
|
scope: "openid",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected url ${u}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = await loginRpartstore({
|
||||||
|
username: "G123326",
|
||||||
|
password: "pw",
|
||||||
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(token.accessToken).toBe(jwt);
|
||||||
|
expect(token.subject).toBe("G123326");
|
||||||
|
expect(token.expiresAt).toBe(exp * 1000);
|
||||||
|
|
||||||
|
// stateToken unescaped (\x2D → "-") and carried into introspect
|
||||||
|
expect(calls[1].body).toBe(JSON.stringify({ stateToken: "02.id.abc-def" }));
|
||||||
|
// identify uses the introspect handle, answer the identify handle
|
||||||
|
expect(JSON.parse(calls[2].body!)).toEqual({ identifier: "G123326", stateHandle: "sh-1" });
|
||||||
|
expect(JSON.parse(calls[3].body!)).toEqual({
|
||||||
|
credentials: { passcode: "pw" },
|
||||||
|
stateHandle: "sh-2",
|
||||||
|
});
|
||||||
|
// cookies from the authorize page are replayed on later hops
|
||||||
|
expect(calls[3].cookie).toContain("JSESSIONID=js1");
|
||||||
|
// PKCE: the token exchange sends the code and a verifier, never the password
|
||||||
|
const tokenBody = new URLSearchParams(calls.at(-1)!.body);
|
||||||
|
expect(tokenBody.get("grant_type")).toBe("authorization_code");
|
||||||
|
expect(tokenBody.get("code")).toBe("CODE1");
|
||||||
|
expect(tokenBody.get("code_verifier")).toBeTruthy();
|
||||||
|
expect(calls.at(-1)!.body).not.toContain("pw");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails with a challenge error when Okta returns no success redirect (bad password / MFA)", async () => {
|
||||||
|
const fetchImpl = vi.fn(async (url: string | URL) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (u.includes("/v1/authorize")) return new Response("stateToken = 'x'", { status: 200 });
|
||||||
|
if (u.endsWith("/introspect")) return jsonResponse({ stateHandle: "sh" });
|
||||||
|
if (u.endsWith("/identify")) return jsonResponse({ stateHandle: "sh" });
|
||||||
|
if (u.endsWith("/challenge/answer")) {
|
||||||
|
return jsonResponse({ messages: { value: [{ message: "Authentication failed" }] } });
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected url ${u}`);
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
loginRpartstore({
|
||||||
|
username: "u",
|
||||||
|
password: "bad",
|
||||||
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ name: "RpartstoreAuthError", step: "challenge" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an OAuth state mismatch on the callback", async () => {
|
||||||
|
const fetchImpl = vi.fn(async (url: string | URL) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (u.includes("/v1/authorize")) return new Response("stateToken = 'x'", { status: 200 });
|
||||||
|
if (u.endsWith("/introspect")) return jsonResponse({ stateHandle: "sh" });
|
||||||
|
if (u.endsWith("/identify")) return jsonResponse({ stateHandle: "sh" });
|
||||||
|
if (u.endsWith("/challenge/answer"))
|
||||||
|
return jsonResponse({ success: { href: "https://sso.renault.com/r" } });
|
||||||
|
if (u === "https://sso.renault.com/r") {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { location: "https://rpartstore.renault.com/idp-redirect?code=C&state=forged" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected url ${u}`);
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
loginRpartstore({
|
||||||
|
username: "u",
|
||||||
|
password: "p",
|
||||||
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(RpartstoreAuthError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes exp/sub from the access token", () => {
|
||||||
|
expect(decodeJwtExpiry(makeJwt({ sub: "G1", exp: 1790334740 }))).toEqual({
|
||||||
|
exp: 1790334740,
|
||||||
|
sub: "G1",
|
||||||
|
});
|
||||||
|
expect(() => decodeJwtExpiry(makeJwt({ sub: "G1" }))).toThrow(RpartstoreAuthError);
|
||||||
|
});
|
||||||
|
});
|
||||||
238
apps/api/src/integrations/rpartstore/rpartstore.auth.ts
Normal file
238
apps/api/src/integrations/rpartstore/rpartstore.auth.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* Browser-free Okta (OIE / IDX) login for rpartstore.renault.com.
|
||||||
|
*
|
||||||
|
* Flow (verified live 2026-09-25, ~1.8 s, no MFA / captcha):
|
||||||
|
* 1. GET {issuer}/v1/authorize?...PKCE... → hosted page HTML containing `stateToken`
|
||||||
|
* 2. POST /idp/idx/introspect {stateToken} → stateHandle
|
||||||
|
* 3. POST /idp/idx/identify {identifier, stateHandle}
|
||||||
|
* 4. POST /idp/idx/challenge/answer {credentials:{passcode}, stateHandle} → success.href
|
||||||
|
* 5. GET success.href (follow redirects manually) → …/idp-redirect?code=…&state=…
|
||||||
|
* 6. POST {issuer}/v1/token (authorization_code + code_verifier) → access_token (1 h, no refresh_token)
|
||||||
|
*/
|
||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
export interface RpartstoreAuthConfig {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
issuer?: string;
|
||||||
|
clientId?: string;
|
||||||
|
redirectUri?: string;
|
||||||
|
scope?: string;
|
||||||
|
/** Injectable for tests. */
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RpartstoreToken {
|
||||||
|
accessToken: string;
|
||||||
|
/** Epoch ms. */
|
||||||
|
expiresAt: number;
|
||||||
|
subject: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RpartstoreAuthError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly step: string,
|
||||||
|
readonly detail?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "RpartstoreAuthError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
issuer: "https://sso.renault.com/oauth2/aus133y6mks4ptDss417",
|
||||||
|
clientId: "irn-72795_ope_pkce_4hcafvxlbcil",
|
||||||
|
redirectUri: "https://rpartstore.renault.com/idp-redirect",
|
||||||
|
scope: "openid alliance_profile apis.default",
|
||||||
|
};
|
||||||
|
|
||||||
|
const USER_AGENT =
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36";
|
||||||
|
const ION = "application/ion+json; okta-version=1.0.0";
|
||||||
|
|
||||||
|
const b64url = (buf: Buffer): string => buf.toString("base64url");
|
||||||
|
|
||||||
|
/** Tiny cookie jar: Okta needs `idx`/`JSESSIONID` cookies across the IDX steps. */
|
||||||
|
class CookieJar {
|
||||||
|
private readonly jar = new Map<string, string>();
|
||||||
|
|
||||||
|
absorb(res: Response): void {
|
||||||
|
const setCookies: string[] =
|
||||||
|
typeof (res.headers as { getSetCookie?: () => string[] }).getSetCookie === "function"
|
||||||
|
? (res.headers as unknown as { getSetCookie: () => string[] }).getSetCookie()
|
||||||
|
: [];
|
||||||
|
for (const sc of setCookies) {
|
||||||
|
const kv = sc.split(";")[0];
|
||||||
|
const i = kv.indexOf("=");
|
||||||
|
if (i > 0) this.jar.set(kv.slice(0, i).trim(), kv.slice(i + 1).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header(): string {
|
||||||
|
return Array.from(this.jar.entries())
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join("; ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeJwtExpiry(accessToken: string): { exp: number; sub: string } {
|
||||||
|
const payload = JSON.parse(
|
||||||
|
Buffer.from(accessToken.split(".")[1], "base64url").toString("utf8"),
|
||||||
|
) as {
|
||||||
|
exp?: number;
|
||||||
|
sub?: string;
|
||||||
|
};
|
||||||
|
if (!payload.exp) throw new RpartstoreAuthError("access token has no exp claim", "token");
|
||||||
|
return { exp: payload.exp, sub: payload.sub ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginRpartstore(cfg: RpartstoreAuthConfig): Promise<RpartstoreToken> {
|
||||||
|
const issuer = cfg.issuer ?? DEFAULTS.issuer;
|
||||||
|
const clientId = cfg.clientId ?? DEFAULTS.clientId;
|
||||||
|
const redirectUri = cfg.redirectUri ?? DEFAULTS.redirectUri;
|
||||||
|
const scope = cfg.scope ?? DEFAULTS.scope;
|
||||||
|
const doFetch = cfg.fetchImpl ?? fetch;
|
||||||
|
const jar = new CookieJar();
|
||||||
|
|
||||||
|
const request = async (url: string, init: RequestInit = {}): Promise<Response> => {
|
||||||
|
const res = await doFetch(url, {
|
||||||
|
redirect: "manual",
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
Cookie: jar.header(),
|
||||||
|
...(init.headers as Record<string, string> | undefined),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
jar.absorb(res);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifier = b64url(randomBytes(48));
|
||||||
|
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
||||||
|
const state = randomBytes(16).toString("hex");
|
||||||
|
const nonce = randomBytes(16).toString("hex");
|
||||||
|
|
||||||
|
// 1. hosted authorize page → stateToken
|
||||||
|
const authorize = new URL(`${issuer}/v1/authorize`);
|
||||||
|
for (const [k, v] of Object.entries({
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: "code",
|
||||||
|
scope,
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
response_mode: "query",
|
||||||
|
})) {
|
||||||
|
authorize.searchParams.set(k, v);
|
||||||
|
}
|
||||||
|
const authorizeRes = await request(authorize.toString());
|
||||||
|
const html = await authorizeRes.text();
|
||||||
|
const stateTokenMatch =
|
||||||
|
html.match(/stateToken\s*=\s*'([^']+)'/) ?? html.match(/"stateToken":"([^"]+)"/);
|
||||||
|
if (authorizeRes.status !== 200 || !stateTokenMatch) {
|
||||||
|
throw new RpartstoreAuthError(
|
||||||
|
`authorize page did not expose a stateToken (HTTP ${authorizeRes.status})`,
|
||||||
|
"authorize",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const stateToken = stateTokenMatch[1].replace(/\\x([0-9A-Fa-f]{2})/g, (_, hex: string) =>
|
||||||
|
String.fromCharCode(Number.parseInt(hex, 16)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const idx = async (
|
||||||
|
path: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
step: string,
|
||||||
|
): Promise<Record<string, any>> => {
|
||||||
|
const res = await request(`https://sso.renault.com/idp/idx/${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": ION, Accept: ION, Origin: "https://sso.renault.com" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const json = (await res.json().catch(() => ({}))) as Record<string, any>;
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new RpartstoreAuthError(
|
||||||
|
`Okta ${step} failed (HTTP ${res.status})`,
|
||||||
|
step,
|
||||||
|
json.messages ?? json,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2-4. IDX remediation
|
||||||
|
const introspected = await idx("introspect", { stateToken }, "introspect");
|
||||||
|
const identified = await idx(
|
||||||
|
"identify",
|
||||||
|
{ identifier: cfg.username, stateHandle: introspected.stateHandle },
|
||||||
|
"identify",
|
||||||
|
);
|
||||||
|
const answered = await idx(
|
||||||
|
"challenge/answer",
|
||||||
|
{
|
||||||
|
credentials: { passcode: cfg.password },
|
||||||
|
stateHandle: identified.stateHandle ?? introspected.stateHandle,
|
||||||
|
},
|
||||||
|
"challenge",
|
||||||
|
);
|
||||||
|
const successHref: string | undefined = answered.success?.href;
|
||||||
|
if (!successHref) {
|
||||||
|
throw new RpartstoreAuthError(
|
||||||
|
"Okta did not return a success redirect (wrong password, locked account or MFA now required)",
|
||||||
|
"challenge",
|
||||||
|
answered.messages,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. follow the redirect chain until the app callback carries ?code=
|
||||||
|
let next = successHref;
|
||||||
|
let code: string | undefined;
|
||||||
|
for (let hop = 0; hop < 6 && !code; hop += 1) {
|
||||||
|
const res = await request(next);
|
||||||
|
const location = res.headers.get("location");
|
||||||
|
if (!location) break;
|
||||||
|
const target = new URL(location, next);
|
||||||
|
const gotCode = target.searchParams.get("code");
|
||||||
|
if (gotCode) {
|
||||||
|
if (target.searchParams.get("state") !== state)
|
||||||
|
throw new RpartstoreAuthError("OAuth state mismatch", "redirect");
|
||||||
|
code = gotCode;
|
||||||
|
}
|
||||||
|
next = target.toString();
|
||||||
|
}
|
||||||
|
if (!code)
|
||||||
|
throw new RpartstoreAuthError("redirect chain ended without an authorization code", "redirect");
|
||||||
|
|
||||||
|
// 6. PKCE token exchange
|
||||||
|
const tokenRes = await request(`${issuer}/v1/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Origin: "https://rpartstore.renault.com",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code,
|
||||||
|
code_verifier: verifier,
|
||||||
|
client_id: clientId,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
const token = (await tokenRes.json().catch(() => ({}))) as {
|
||||||
|
access_token?: string;
|
||||||
|
error?: string;
|
||||||
|
error_description?: string;
|
||||||
|
};
|
||||||
|
if (!tokenRes.ok || !token.access_token) {
|
||||||
|
throw new RpartstoreAuthError(
|
||||||
|
`token exchange failed (HTTP ${tokenRes.status}): ${token.error_description ?? token.error ?? ""}`,
|
||||||
|
"token",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { exp, sub } = decodeJwtExpiry(token.access_token);
|
||||||
|
return { accessToken: token.access_token, expiresAt: exp * 1000, subject: sub };
|
||||||
|
}
|
||||||
157
apps/api/src/integrations/rpartstore/rpartstore.client.ts
Normal file
157
apps/api/src/integrations/rpartstore/rpartstore.client.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { type RpartstoreToken, loginRpartstore } from "./rpartstore.auth";
|
||||||
|
import { canonicalRpartstoreBrand } from "./rpartstore.routing";
|
||||||
|
import {
|
||||||
|
RpartstoreBffError,
|
||||||
|
RpartstoreRateLimitError,
|
||||||
|
RpartstoreSession,
|
||||||
|
} from "./rpartstore.session";
|
||||||
|
import type {
|
||||||
|
RpartstoreDecoded,
|
||||||
|
RpartstoreVehicle,
|
||||||
|
SearchVehicleResponsePayload,
|
||||||
|
} from "./rpartstore.types";
|
||||||
|
|
||||||
|
export const RPARTSTORE_DEFAULTS = {
|
||||||
|
brokerUrl: "wss://1po-bff.renault-edh.com/ws",
|
||||||
|
appVersion: "1.34.0.6",
|
||||||
|
webLanguage: "tr",
|
||||||
|
country: "TR",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Where the (1 h, non-refreshable) Okta access token is cached between decodes. */
|
||||||
|
export interface TokenStore {
|
||||||
|
get(): Promise<RpartstoreToken | null>;
|
||||||
|
set(token: RpartstoreToken, ttlSeconds: number): Promise<void>;
|
||||||
|
clear(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RpartstoreClientOptions {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
tokenStore: TokenStore;
|
||||||
|
brokerUrl?: string;
|
||||||
|
appVersion?: string;
|
||||||
|
webLanguage?: string;
|
||||||
|
country?: string;
|
||||||
|
/** Injectable for tests. */
|
||||||
|
login?: typeof loginRpartstore;
|
||||||
|
sessionFactory?: (opts: ConstructorParameters<typeof RpartstoreSession>[0]) => RpartstoreSession;
|
||||||
|
logger?: { log: (msg: string) => void; warn: (msg: string) => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh the token this many ms before its `exp`. */
|
||||||
|
const TOKEN_SKEW_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
export class RpartstoreClient {
|
||||||
|
private readonly login: typeof loginRpartstore;
|
||||||
|
private readonly sessionFactory: NonNullable<RpartstoreClientOptions["sessionFactory"]>;
|
||||||
|
|
||||||
|
constructor(private readonly opts: RpartstoreClientOptions) {
|
||||||
|
this.login = opts.login ?? loginRpartstore;
|
||||||
|
this.sessionFactory = opts.sessionFactory ?? ((o) => new RpartstoreSession(o));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cached token when still valid (with skew), otherwise a fresh Okta login. */
|
||||||
|
async getToken(force = false): Promise<RpartstoreToken> {
|
||||||
|
if (!force) {
|
||||||
|
const cached = await this.opts.tokenStore.get();
|
||||||
|
if (cached && cached.expiresAt - Date.now() > TOKEN_SKEW_MS) return cached;
|
||||||
|
}
|
||||||
|
const token = await this.login({ username: this.opts.username, password: this.opts.password });
|
||||||
|
const ttl = Math.max(60, Math.floor((token.expiresAt - Date.now() - TOKEN_SKEW_MS) / 1000));
|
||||||
|
await this.opts.tokenStore.set(token, ttl);
|
||||||
|
this.opts.logger?.log(`[rpartstore] logged in as ${token.subject}, token ttl ${ttl}s`);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One VIN search on a fresh STOMP session (volume is capped at a handful per
|
||||||
|
* day, so a persistent socket buys nothing). Resolves to the decoded vehicle,
|
||||||
|
* `null` when RPartStore answers NOT_FOUND, and throws `RpartstoreRateLimitError`
|
||||||
|
* / other errors for the caller to handle. A CONNECT failure is retried once
|
||||||
|
* with a forced re-login (expired or revoked token).
|
||||||
|
*/
|
||||||
|
async searchVin(vin: string): Promise<RpartstoreDecoded | null> {
|
||||||
|
let token = await this.getToken();
|
||||||
|
const session = await this.openSession(token.accessToken).catch(async (err: Error) => {
|
||||||
|
this.opts.logger?.warn(`[rpartstore] CONNECT failed (${err.message}); re-login and retry`);
|
||||||
|
await this.opts.tokenStore.clear();
|
||||||
|
token = await this.getToken(true);
|
||||||
|
return this.openSession(token.accessToken);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const country = this.opts.country ?? RPARTSTORE_DEFAULTS.country;
|
||||||
|
const lang = this.opts.webLanguage ?? RPARTSTORE_DEFAULTS.webLanguage;
|
||||||
|
const msg = await session.request<SearchVehicleResponsePayload>(
|
||||||
|
"/vehicles/search/vin-or-vrn",
|
||||||
|
{
|
||||||
|
requestId: crypto.randomUUID(),
|
||||||
|
value: vin,
|
||||||
|
queryType: "VIN",
|
||||||
|
userContext: {
|
||||||
|
webLanguage: lang,
|
||||||
|
documentLanguage: lang,
|
||||||
|
documentCountryLanguage: country,
|
||||||
|
documentFallbackLanguage: lang,
|
||||||
|
documentFallbackCountryLanguage: country,
|
||||||
|
userCountry: country,
|
||||||
|
r1Country: country,
|
||||||
|
},
|
||||||
|
countryCode: country,
|
||||||
|
includeEstimate: false,
|
||||||
|
},
|
||||||
|
{ expect: ["1PO/CATALOG/SEARCH_VEHICLE_RESPONSE"], timeoutMs: 15_000 },
|
||||||
|
);
|
||||||
|
const vehicle = msg.payload?.vehicles?.[0];
|
||||||
|
if (!vehicle) return null;
|
||||||
|
return normalizeVehicle(vehicle);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RpartstoreBffError) {
|
||||||
|
const errorType = (err.payload as { errorType?: string } | undefined)?.errorType;
|
||||||
|
if (err.type === "1PO/CATALOG/SEARCH_VEHICLE_ERROR" && errorType === "NOT_FOUND")
|
||||||
|
return null;
|
||||||
|
if (err.type === "1PO/CATALOG/SEARCH_VEHICLE_NOT_COVERED_IN_COUNTRY") return null;
|
||||||
|
}
|
||||||
|
if (err instanceof RpartstoreRateLimitError) throw err;
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
session.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openSession(accessToken: string): Promise<RpartstoreSession> {
|
||||||
|
const session = this.sessionFactory({
|
||||||
|
brokerUrl: this.opts.brokerUrl ?? RPARTSTORE_DEFAULTS.brokerUrl,
|
||||||
|
accessToken,
|
||||||
|
profile: this.opts.username,
|
||||||
|
appVersion: this.opts.appVersion ?? RPARTSTORE_DEFAULTS.appVersion,
|
||||||
|
webLanguage: this.opts.webLanguage ?? RPARTSTORE_DEFAULTS.webLanguage,
|
||||||
|
logger: this.opts.logger
|
||||||
|
? { debug: () => undefined, warn: (m) => this.opts.logger?.warn(`[rpartstore] ${m}`) }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
await session.connect();
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeVehicle(v: RpartstoreVehicle): RpartstoreDecoded {
|
||||||
|
const dh = v.dataHubVehicle ?? {};
|
||||||
|
const brandName = canonicalRpartstoreBrand(v.vehicleBrand) ?? v.vehicleBrand;
|
||||||
|
const clean = (s: string | undefined): string | null => {
|
||||||
|
const t = (s ?? "").trim();
|
||||||
|
return t.length > 0 ? t : null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
brandName,
|
||||||
|
model: clean(v.model),
|
||||||
|
modelCode: clean(dh.modelTypeCode),
|
||||||
|
familyCode: clean(dh.familyCode),
|
||||||
|
modelYear: clean(dh.modelYear),
|
||||||
|
engine: clean(dh.engine),
|
||||||
|
gearbox: clean(dh.gearbox),
|
||||||
|
energyType: clean(dh.energyType),
|
||||||
|
manufacturingDate: clean(v.manufacturingDate),
|
||||||
|
raw: v,
|
||||||
|
};
|
||||||
|
}
|
||||||
140
apps/api/src/integrations/rpartstore/rpartstore.matcher.spec.ts
Normal file
140
apps/api/src/integrations/rpartstore/rpartstore.matcher.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
type RpartstoreCatalogCandidate,
|
||||||
|
pickRpartstoreCatalogMatch,
|
||||||
|
rpartstoreModelTokens,
|
||||||
|
} from "./rpartstore.matcher";
|
||||||
|
|
||||||
|
// Real PL24 `catalog_vehicles.model` values for Renault (prod, 2026-09-25).
|
||||||
|
const RENAULT = [
|
||||||
|
"ALASKAN",
|
||||||
|
"ARKANA EUROPE / XM3",
|
||||||
|
"ARKANA RUSYA",
|
||||||
|
"AUSTRAL/ESPACE VI/RAFALE",
|
||||||
|
"CAPTUR / QM3",
|
||||||
|
"CAPTUR II EUROPE/SYMBIOZ",
|
||||||
|
"CAPTUR II ÇİN",
|
||||||
|
"CAPTUR/KAPTUR",
|
||||||
|
"CLIO 4 / LUTECIA 4",
|
||||||
|
"CLIO 5/LUTECIA 5",
|
||||||
|
"DUSTER 1",
|
||||||
|
"DUSTER 2",
|
||||||
|
"DUSTER III / BIGSTER",
|
||||||
|
"EXPRESS",
|
||||||
|
"FLUENCE / FLUENCE Z.E.",
|
||||||
|
"KADJAR",
|
||||||
|
"KADJAR ÇİN",
|
||||||
|
"KANGOO 1",
|
||||||
|
"KANGOO 2 / KANGOO Z.E.",
|
||||||
|
"KANGOO 3",
|
||||||
|
"LATITUDE / SAFRANE 2",
|
||||||
|
"LOGAN\\-SANDERO 1/TONDAR 1",
|
||||||
|
"LOGAN\\-SANDERO 3 / TALIANT",
|
||||||
|
"MASTER 3",
|
||||||
|
"MASTER 4 VAN",
|
||||||
|
"MEGANE 1",
|
||||||
|
"MEGANE 2 / SCENIC 2",
|
||||||
|
"MEGANE 3 / SCENIC 3",
|
||||||
|
"MEGANE 4",
|
||||||
|
"MEGANE 4 SEDAN",
|
||||||
|
"RENAULT 5 EXPRESS / RAPID",
|
||||||
|
"RENAULT 9 / 11",
|
||||||
|
"TRAFIC 2",
|
||||||
|
"TRAFIC 3",
|
||||||
|
"X62 CHINE",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DACIA = [
|
||||||
|
"DOKKER",
|
||||||
|
"DUSTER 1",
|
||||||
|
"DUSTER 2",
|
||||||
|
"DUSTER 3/BIGSTER",
|
||||||
|
"LOGAN\\-SANDERO 1/TONDAR 1",
|
||||||
|
"LOGAN\\-SANDERO 2/SYMBOL 2",
|
||||||
|
"LOGAN\\-SANDERO 3 / TALIANT",
|
||||||
|
];
|
||||||
|
|
||||||
|
const cands = (models: string[]): RpartstoreCatalogCandidate[] =>
|
||||||
|
models.map((model) => ({ id: model, model }));
|
||||||
|
|
||||||
|
describe("rpartstoreModelTokens", () => {
|
||||||
|
it("drops the model code in parentheses and converts roman generations", () => {
|
||||||
|
expect(rpartstoreModelTokens("Clio IV / Lutecia IV (B98)")).toEqual([
|
||||||
|
"CLIO",
|
||||||
|
"4",
|
||||||
|
"LUTECIA",
|
||||||
|
"4",
|
||||||
|
]);
|
||||||
|
expect(rpartstoreModelTokens("Duster III (SUV)")).toEqual(["DUSTER", "3"]);
|
||||||
|
expect(rpartstoreModelTokens("Megane I Classic (L64)")).toEqual(["MEGANE", "1", "CLASSIC"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps single-digit generation tokens and folds diacritics", () => {
|
||||||
|
expect(rpartstoreModelTokens("MEGANE 4 SEDAN")).toEqual(["MEGANE", "4", "SEDAN"]);
|
||||||
|
expect(rpartstoreModelTokens("KADJAR ÇİN")).toEqual(["KADJAR", "CIN"]);
|
||||||
|
expect(rpartstoreModelTokens("LOGAN\\-SANDERO 3 / TALIANT")).toEqual([
|
||||||
|
"LOGAN",
|
||||||
|
"SANDERO",
|
||||||
|
"3",
|
||||||
|
"TALIANT",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pickRpartstoreCatalogMatch (prod benchmark models → PL24 Renault catalog)", () => {
|
||||||
|
const expectPick = (model: string, expected: string | null, pool = RENAULT) =>
|
||||||
|
expect(pickRpartstoreCatalogMatch(model, cands(pool))).toBe(expected);
|
||||||
|
|
||||||
|
it("matches roman-numeral generations to digit generations", () => {
|
||||||
|
expectPick("Clio IV / Lutecia IV (B98)", "CLIO 4 / LUTECIA 4");
|
||||||
|
expectPick("Trafic III (J82)", "TRAFIC 3");
|
||||||
|
expectPick("Master III (F62)", "MASTER 3");
|
||||||
|
expectPick("Kangoo II (K61)", "KANGOO 2 / KANGOO Z.E.");
|
||||||
|
expectPick("Logan III (LJF)", "LOGAN\\-SANDERO 3 / TALIANT");
|
||||||
|
expectPick("Duster III (SUV)", "DUSTER III / BIGSTER");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the body-qualified catalog when the decode carries the qualifier, and the plain one otherwise", () => {
|
||||||
|
expectPick("Megane IV Sedan (LFF)", "MEGANE 4 SEDAN");
|
||||||
|
expectPick("Megane IV (BFB)", "MEGANE 4");
|
||||||
|
expectPick("Megane I Classic (L64)", "MEGANE 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never picks a wrong-market catalog when a mainstream one exists", () => {
|
||||||
|
expectPick("Kadjar (HFE)", "KADJAR");
|
||||||
|
expectPick("Captur II (HJB)", "CAPTUR II EUROPE/SYMBIOZ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a newer generation steal a decode without a generation", () => {
|
||||||
|
expectPick("Captur (J87)", "CAPTUR / QM3");
|
||||||
|
expectPick("Express (KJK)", "EXPRESS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches multi-name catalogs and pre-2000 models", () => {
|
||||||
|
expectPick("Latitude / Safrane II (L43)", "LATITUDE / SAFRANE 2");
|
||||||
|
expectPick("Fluence (L38)", "FLUENCE / FLUENCE Z.E.");
|
||||||
|
expectPick("Renault 9 / 11 (L42)", "RENAULT 9 / 11");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches Dacia models within the Dacia catalog", () => {
|
||||||
|
expectPick("Duster I (H79)", "DUSTER 1", DACIA);
|
||||||
|
expectPick("Duster II (HJD)", "DUSTER 2", DACIA);
|
||||||
|
expectPick("Dokker (K67)", "DOKKER", DACIA);
|
||||||
|
expectPick("Logan I (L90)", "LOGAN\\-SANDERO 1/TONDAR 1", DACIA);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when nothing qualifies or the model is missing", () => {
|
||||||
|
expectPick("Arkana (LJL)", "ARKANA EUROPE / XM3");
|
||||||
|
expectPick("Twingo Z.E.", null);
|
||||||
|
expect(pickRpartstoreCatalogMatch(undefined, cands(RENAULT))).toBeNull();
|
||||||
|
expect(pickRpartstoreCatalogMatch("", cands(RENAULT))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("breaks exact ties by the fuller catalog", () => {
|
||||||
|
const pool: RpartstoreCatalogCandidate[] = [
|
||||||
|
{ id: "a", model: "KADJAR", categoryCount: 12 },
|
||||||
|
{ id: "b", model: "KADJAR", categoryCount: 44 },
|
||||||
|
];
|
||||||
|
expect(pickRpartstoreCatalogMatch("Kadjar (HFE)", pool)).toBe("b");
|
||||||
|
});
|
||||||
|
});
|
||||||
133
apps/api/src/integrations/rpartstore/rpartstore.matcher.ts
Normal file
133
apps/api/src/integrations/rpartstore/rpartstore.matcher.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { MARKET_QUALIFIERS } from "../vinpin/vinpin.matcher";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an RPartStore model label ("Megane IV Sedan (LFF)", "Clio IV / Lutecia IV
|
||||||
|
* (B98)", "Duster III (SUV)") onto an EXISTING PL24 `catalog_vehicles` row of the
|
||||||
|
* same brand ("MEGANE 4 SEDAN", "CLIO 4 / LUTECIA 4", "DUSTER III / BIGSTER").
|
||||||
|
*
|
||||||
|
* Differences from the Vinpin matcher that made a dedicated picker necessary:
|
||||||
|
* - RPartStore writes generations as roman numerals, PL24 mostly as digits
|
||||||
|
* ("IV" vs "4") — both sides are normalised to digits.
|
||||||
|
* - Generation digits are single characters; the Vinpin tokenizer drops tokens
|
||||||
|
* shorter than 2 chars, which would make "MEGANE 4" ≡ "MEGANE".
|
||||||
|
* - Body qualifiers (SEDAN, CLASSIC, …) are optional on the catalog side:
|
||||||
|
* "Megane I Classic" must still match "MEGANE 1".
|
||||||
|
* - PL24 Renault/Dacia rows carry no year, so there is no year scoring.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RpartstoreCatalogCandidate {
|
||||||
|
id: string;
|
||||||
|
model: string | null;
|
||||||
|
/** Tiebreak only: fuller catalog wins. */
|
||||||
|
categoryCount?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROMAN: Record<string, string> = {
|
||||||
|
I: "1",
|
||||||
|
II: "2",
|
||||||
|
III: "3",
|
||||||
|
IV: "4",
|
||||||
|
V: "5",
|
||||||
|
VI: "6",
|
||||||
|
VII: "7",
|
||||||
|
VIII: "8",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Body / trim words that PL24 may omit; they only add a small bonus when both sides have them. */
|
||||||
|
const BODY_QUALIFIERS = new Set<string>([
|
||||||
|
"SEDAN",
|
||||||
|
"CLASSIC",
|
||||||
|
"HATCHBACK",
|
||||||
|
"HB",
|
||||||
|
"ESTATE",
|
||||||
|
"GRANDTOUR",
|
||||||
|
"SPORTTOURER",
|
||||||
|
"SW",
|
||||||
|
"BREAK",
|
||||||
|
"VAN",
|
||||||
|
"COMBI",
|
||||||
|
"KOMBI",
|
||||||
|
"CABRIO",
|
||||||
|
"COUPE",
|
||||||
|
"SASI",
|
||||||
|
"CHASSIS",
|
||||||
|
"PICKUP",
|
||||||
|
"PHASE",
|
||||||
|
"PH",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Tokenize a model label: fold diacritics, drop parenthetical codes/years, split on
|
||||||
|
* non-alphanumerics, convert roman generation numerals to digits. Keeps 1-char
|
||||||
|
* numeric tokens (generations) and drops 1-char alpha noise ("Z.E." → "ZE" kept
|
||||||
|
* as-is is not needed for matching). */
|
||||||
|
export function rpartstoreModelTokens(s: string | null | undefined): string[] {
|
||||||
|
if (!s) return [];
|
||||||
|
return s
|
||||||
|
.toUpperCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/\p{M}/gu, "")
|
||||||
|
.replace(/\([^)]*\)/g, " ")
|
||||||
|
.replace(/[^A-Z0-9]+/g, " ")
|
||||||
|
.split(" ")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => t.length > 0)
|
||||||
|
.map((t) => ROMAN[t] ?? t)
|
||||||
|
.filter((t) => t.length >= 2 || /^\d$/.test(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNumeric = (t: string): boolean => /^\d+$/.test(t);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the id of the best candidate or null. A candidate qualifies when every
|
||||||
|
* "core" decoded token (everything except body qualifiers) appears among its
|
||||||
|
* tokens. Score, strongest first: market-qualifier penalty (wrong-market catalogs
|
||||||
|
* only win when alone), exact-match bonus, body-qualifier overlap bonus, a hard
|
||||||
|
* penalty for candidates that add a generation number the decode lacks
|
||||||
|
* ("Captur" must not pick "CAPTUR II"), a mild penalty per extra token, then the
|
||||||
|
* fuller catalog as tiebreak.
|
||||||
|
*/
|
||||||
|
export function pickRpartstoreCatalogMatch(
|
||||||
|
model: string | null | undefined,
|
||||||
|
candidates: RpartstoreCatalogCandidate[],
|
||||||
|
): string | null {
|
||||||
|
const decoded = rpartstoreModelTokens(model);
|
||||||
|
if (decoded.length === 0) return null;
|
||||||
|
const core = decoded.filter((t) => !BODY_QUALIFIERS.has(t));
|
||||||
|
const required = core.length > 0 ? core : decoded;
|
||||||
|
const decodedSet = new Set(decoded);
|
||||||
|
const decodedHasGeneration = decoded.some(isNumeric);
|
||||||
|
|
||||||
|
let best: { id: string; score: number; categoryCount: number } | null = null;
|
||||||
|
for (const c of candidates) {
|
||||||
|
const tokens = rpartstoreModelTokens(c.model);
|
||||||
|
if (tokens.length === 0) continue;
|
||||||
|
const tokenSet = new Set(tokens);
|
||||||
|
if (!required.every((t) => tokenSet.has(t))) continue;
|
||||||
|
|
||||||
|
const extras = tokens.filter((t) => !decodedSet.has(t));
|
||||||
|
const marketExtras = extras.filter((t) => MARKET_QUALIFIERS.has(t)).length;
|
||||||
|
const generationExtras = decodedHasGeneration ? 0 : extras.filter(isNumeric).length;
|
||||||
|
const qualifierOverlap = decoded.filter(
|
||||||
|
(t) => BODY_QUALIFIERS.has(t) && tokenSet.has(t),
|
||||||
|
).length;
|
||||||
|
const exact = extras.length === 0 && tokens.length === decoded.length;
|
||||||
|
|
||||||
|
const score =
|
||||||
|
1000 -
|
||||||
|
marketExtras * 5000 -
|
||||||
|
generationExtras * 150 -
|
||||||
|
extras.length * 20 +
|
||||||
|
qualifierOverlap * 100 +
|
||||||
|
(exact ? 300 : 0);
|
||||||
|
const categoryCount = c.categoryCount ?? 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!best ||
|
||||||
|
score > best.score ||
|
||||||
|
(score === best.score && categoryCount > best.categoryCount)
|
||||||
|
) {
|
||||||
|
best = { id: c.id, score, categoryCount };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best?.id ?? null;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user