Compare commits
285 Commits
feature/di
...
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 | |||
| a4e53f3cfd | |||
| 72889796e5 |
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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");
|
||||||
@@ -162,6 +162,111 @@
|
|||||||
"when": 1782652379976,
|
"when": 1782652379976,
|
||||||
"tag": "0022_discount_codes",
|
"tag": "0022_discount_codes",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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";
|
||||||
@@ -43,6 +44,7 @@ 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";
|
||||||
@@ -78,6 +80,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
|||||||
]),
|
]),
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
|
TelegramModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
@@ -112,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),
|
||||||
@@ -462,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;
|
||||||
@@ -443,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
|
||||||
@@ -466,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.
|
||||||
@@ -663,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 [];
|
||||||
}
|
}
|
||||||
@@ -758,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 }>>> {
|
||||||
@@ -1083,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);
|
||||||
@@ -1940,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,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",
|
||||||
@@ -163,6 +203,12 @@ export const userSubscriptions = pgTable(
|
|||||||
// NULL for trials and legacy one-time purchases. Renewal invoices resolve
|
// NULL for trials and legacy one-time purchases. Renewal invoices resolve
|
||||||
// our row through this.
|
// our row through this.
|
||||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
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(),
|
||||||
},
|
},
|
||||||
@@ -363,6 +409,36 @@ export const vinpinDecodes = pgTable("vinpin_decodes", {
|
|||||||
decodedAt: timestamp("decoded_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",
|
||||||
@@ -379,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) ─
|
||||||
@@ -431,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,
|
||||||
@@ -899,3 +1000,98 @@ export const discountCodes = pgTable(
|
|||||||
index("discount_codes_active_idx").on(table.isActive),
|
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)],
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -94,14 +94,16 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
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
|
const rawProvider = this.configService
|
||||||
.get<string>("EMEX_PROXY_PROVIDER", "floxy")
|
.get<string>("EMEX_PROXY_PROVIDER", "dataimpulse")
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
this.proxyProvider = !this.useProxy
|
this.proxyProvider = !this.useProxy
|
||||||
? "none"
|
? "none"
|
||||||
: rawProvider === "dataimpulse" || rawProvider === "none"
|
: rawProvider === "floxy" || rawProvider === "none"
|
||||||
? (rawProvider as "dataimpulse" | "none")
|
? (rawProvider as "floxy" | "none")
|
||||||
: "floxy";
|
: "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);
|
||||||
@@ -260,6 +262,42 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
"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
|
||||||
|
|
||||||
|
|||||||
@@ -215,11 +215,15 @@ export class EmexService {
|
|||||||
// random-port-per-call primary proved that), but a sticky Floxy IP across the
|
// 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
|
// 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.
|
// 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
|
this.emexProxyProvider = !useProxy
|
||||||
? "none"
|
? "none"
|
||||||
: (() => {
|
: (() => {
|
||||||
const p = this.configService.get<string>("EMEX_PROXY_PROVIDER", "floxy").toLowerCase();
|
const p = this.configService
|
||||||
return p === "dataimpulse" || p === "none" ? p : "floxy";
|
.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
|
// 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.
|
// fresh agent per request so a flaky exit can't pin every call.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
isProxyConnectFailure,
|
isProxyConnectFailure,
|
||||||
resolveExitIp,
|
resolveExitIp,
|
||||||
} from "../proxy-telemetry/proxy-telemetry.service";
|
} 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
|
||||||
@@ -187,6 +188,21 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly diHost: string;
|
private readonly diHost: string;
|
||||||
private readonly diUser: string;
|
private readonly diUser: string;
|
||||||
private readonly diPass: 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,
|
||||||
@@ -208,13 +224,16 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.semaphore = new Semaphore(this.jwtSites.length);
|
this.semaphore = new Semaphore(this.jwtSites.length);
|
||||||
|
|
||||||
// Provider selection. Back-compat: PCAT_USE_PROXY=false still forces direct.
|
// Provider selection. Back-compat: PCAT_USE_PROXY=false still forces direct.
|
||||||
const rawProvider = cfg.get<string>("PCAT_PROXY_PROVIDER", "floxy").toLowerCase();
|
// Default is dataimpulse: Floxy was retired 2026-07 (permanently down —
|
||||||
|
// balance/tunnel dead), so floxy-primary just burned a failed attempt per
|
||||||
|
// call before failing over. Set PCAT_PROXY_PROVIDER=floxy to re-enable it.
|
||||||
|
const rawProvider = cfg.get<string>("PCAT_PROXY_PROVIDER", "dataimpulse").toLowerCase();
|
||||||
const useProxy = cfg.get<string>("PCAT_USE_PROXY", "true") === "true";
|
const useProxy = cfg.get<string>("PCAT_USE_PROXY", "true") === "true";
|
||||||
this.proxyProvider = !useProxy
|
this.proxyProvider = !useProxy
|
||||||
? "none"
|
? "none"
|
||||||
: rawProvider === "dataimpulse" || rawProvider === "none"
|
: rawProvider === "floxy" || rawProvider === "none"
|
||||||
? (rawProvider as ProxyProvider)
|
? (rawProvider as ProxyProvider)
|
||||||
: "floxy";
|
: "dataimpulse";
|
||||||
|
|
||||||
const toInt = (key: string, def: number): number => {
|
const toInt = (key: string, def: number): number => {
|
||||||
const n = Number(cfg.get(key, def));
|
const n = Number(cfg.get(key, def));
|
||||||
@@ -234,6 +253,13 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.diHost = cfg.get<string>("PCAT_PROXY_HOST", DI_HOST);
|
this.diHost = cfg.get<string>("PCAT_PROXY_HOST", DI_HOST);
|
||||||
this.diUser = cfg.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
|
this.diUser = cfg.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
|
||||||
this.diPass = cfg.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS);
|
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.logger.log(
|
||||||
this.proxyProvider === "floxy"
|
this.proxyProvider === "floxy"
|
||||||
@@ -403,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
|
||||||
@@ -477,8 +517,15 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// 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(() => {});
|
||||||
@@ -747,18 +794,36 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
// ─── JWT capture via Playwright ───────────────────────────
|
// ─── JWT capture via Playwright ───────────────────────────
|
||||||
|
|
||||||
private async attemptCapture(siteUrl: string): 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
|
// Sticky capture proxy: one residential exit IP pinned for the lifetime of
|
||||||
// this capture so the partner site + widget see a coherent session.
|
// this capture so the partner site + widget see a coherent session.
|
||||||
const { proxyConfig, proxyUrl, sessionKey, provider } = this.buildProxy("capture");
|
// 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 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.
|
// the capture will use — that's what makes banned-IP tracking concrete.
|
||||||
// Resolves in ~1-2s while the capture itself takes 7s+; never throws.
|
// Resolves in ~1-2s while the capture itself takes 7s+; never throws.
|
||||||
const exitIpPromise = proxyUrl ? resolveExitIp(proxyUrl) : Promise.resolve(null);
|
// Gated off by default (PCAT_EXIT_IP_PROBE): the probe is billed proxy
|
||||||
|
// traffic and only feeds exit-IP telemetry, not the capture itself.
|
||||||
|
const exitIpPromise =
|
||||||
|
this.exitIpProbe && proxyUrl ? resolveExitIp(proxyUrl) : Promise.resolve(null);
|
||||||
const logCapture = (ok: boolean, errorKind?: string): void => {
|
const logCapture = (ok: boolean, errorKind?: string): void => {
|
||||||
void exitIpPromise.then((exitIp) =>
|
void exitIpPromise.then((exitIp) =>
|
||||||
this.proxyTelemetry.record({
|
this.proxyTelemetry.record({
|
||||||
@@ -808,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();
|
||||||
|
|
||||||
@@ -830,6 +902,29 @@ 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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -850,43 +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
|
// 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
|
// Floxy exit is dead — trip the shared gate so this + EMEX fail over to
|
||||||
// DataImpulse for the cooldown window.
|
// DataImpulse for the cooldown window.
|
||||||
if (provider === "floxy" && isProxyConnectFailure(navErr)) {
|
if (provider === "floxy" && connectFailed) {
|
||||||
this.proxyHealth.reportFloxyFailure(`pcat capture: ${classifyTransportError(navErr)}`);
|
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) {
|
|
||||||
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}`);
|
|
||||||
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;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logCapture(false, classifyTransportError(err));
|
logCapture(false, classifyTransportError(err));
|
||||||
if (provider === "floxy" && isProxyConnectFailure(err)) {
|
const connectFailed = isProxyConnectFailure(err);
|
||||||
|
if (provider === "floxy" && connectFailed) {
|
||||||
this.proxyHealth.reportFloxyFailure(`pcat capture: ${classifyTransportError(err)}`);
|
this.proxyHealth.reportFloxyFailure(`pcat capture: ${classifyTransportError(err)}`);
|
||||||
}
|
}
|
||||||
this.logger.warn(`Token capture error: ${(err as Error).message}`);
|
if (connectFailed && proxyConfig && this.captureAllowDirect && !forceDirect) {
|
||||||
return null;
|
retryDirect = true;
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`Token capture error: ${(err as Error).message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (context) {
|
if (context) {
|
||||||
try {
|
try {
|
||||||
@@ -899,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 ───────────────────────────────────
|
||||||
|
|||||||
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,9 +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: "opel_parts", // PSA/Stellantis-platform Opel (Corsa F, Mokka B — France/Spain plants)
|
VXK: "psa_opel_parts", // PSA-platform Opel (Corsa F, Mokka B) → Stellantis kataloğu /p5psa
|
||||||
|
|
||||||
// Citroën (PSA)
|
// Citroën (PSA)
|
||||||
VF7: "citroen_parts", // Citroën SA (France)
|
VF7: "citroen_parts", // Citroën SA (France)
|
||||||
@@ -574,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)
|
||||||
@@ -794,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",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { proxyLogs } from "../../database/schema/core";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy telemetry — fire-and-forget logging of every proxied upstream attempt
|
* Proxy telemetry — fire-and-forget logging of every proxied upstream attempt
|
||||||
* (pcat call/capture/validate, emex http) into `proxy_logs`, so the Süper Panel
|
* (pcat call/capture/validate, emex http, pl24 catalog/auth) into `proxy_logs`, so the Süper Panel
|
||||||
* can grade proxy providers per service and track banned exit IPs / sessions.
|
* can grade proxy providers per service and track banned exit IPs / sessions.
|
||||||
*
|
*
|
||||||
* Design constraints:
|
* Design constraints:
|
||||||
@@ -13,7 +13,13 @@ import { proxyLogs } from "../../database/schema/core";
|
|||||||
* - Bounded memory: the buffer is capped; when full, oldest rows are dropped.
|
* - Bounded memory: the buffer is capped; when full, oldest rows are dropped.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ProxyServiceLeg = "pcat_call" | "pcat_capture" | "pcat_validate" | "emex_http";
|
export type ProxyServiceLeg =
|
||||||
|
| "pcat_call"
|
||||||
|
| "pcat_capture"
|
||||||
|
| "pcat_validate"
|
||||||
|
| "emex_http"
|
||||||
|
| "pl24_http"
|
||||||
|
| "pl24_auth";
|
||||||
export type ProxyProviderName = "floxy" | "dataimpulse" | "none";
|
export type ProxyProviderName = "floxy" | "dataimpulse" | "none";
|
||||||
|
|
||||||
export interface ProxyLogEvent {
|
export interface ProxyLogEvent {
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
32
apps/api/src/integrations/rpartstore/rpartstore.routing.ts
Normal file
32
apps/api/src/integrations/rpartstore/rpartstore.routing.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { getBrandFromWmi } from "@sase/shared";
|
||||||
|
|
||||||
|
/** WMIs routed to RPartStore even when the shared WMI table is silent. VF1/VF2 =
|
||||||
|
* Renault (France), VF6 = Renault (Trucks/Sofasa), UU1 = Dacia (Romania). VF7 is
|
||||||
|
* Citroën and is deliberately NOT here. */
|
||||||
|
const RPARTSTORE_WMIS = new Set<string>(["VF1", "VF2", "VF6", "UU1"]);
|
||||||
|
|
||||||
|
const RPARTSTORE_BRANDS = new Set<string>(["renault", "dacia"]);
|
||||||
|
|
||||||
|
/** Canonical brand names as they appear in `catalog_vehicles.brand_name`. */
|
||||||
|
export function canonicalRpartstoreBrand(
|
||||||
|
brand: string | null | undefined,
|
||||||
|
): "Renault" | "Dacia" | null {
|
||||||
|
const b = (brand ?? "").trim().toLowerCase();
|
||||||
|
if (b === "renault") return "Renault";
|
||||||
|
if (b === "dacia") return "Dacia";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should this VIN be offered to the RPartStore decode fallback? Renault/Dacia
|
||||||
|
* only: decided by the WMI first (shared table, then the explicit allowlist),
|
||||||
|
* with the identified browse brand as a last resort (Dacia-badged cars built
|
||||||
|
* under a Renault WMI still say "Dacia" in the identification).
|
||||||
|
*/
|
||||||
|
export function isRpartstoreVin(vin: string, browseBrand?: string | null): boolean {
|
||||||
|
const wmi = (vin || "").toUpperCase().slice(0, 3);
|
||||||
|
const wmiBrand = getBrandFromWmi(wmi);
|
||||||
|
if (wmiBrand && RPARTSTORE_BRANDS.has(wmiBrand.toLowerCase())) return true;
|
||||||
|
if (RPARTSTORE_WMIS.has(wmi)) return true;
|
||||||
|
return !!browseBrand && RPARTSTORE_BRANDS.has(browseBrand.trim().toLowerCase());
|
||||||
|
}
|
||||||
256
apps/api/src/integrations/rpartstore/rpartstore.session.ts
Normal file
256
apps/api/src/integrations/rpartstore/rpartstore.session.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* One STOMP-over-WebSocket session against the RPartStore BFF.
|
||||||
|
*
|
||||||
|
* Request/response is correlated by the `trace-id` header we send and the `traceId`
|
||||||
|
* header the server echoes. One request can yield several MESSAGE frames
|
||||||
|
* (e.g. a VIN search → SEARCH_VEHICLE_RESPONSE, COMMAND_PROCESSING_RESPONSE,
|
||||||
|
* EXPLODED_TREE_RESPONSE), so a request resolves on the first frame whose `type`
|
||||||
|
* matches one of the expected terminal types, and errors on a frame from
|
||||||
|
* `/user/queue/error` or a `*_RATE_LIMIT_EXCEEDED` type.
|
||||||
|
*
|
||||||
|
* Uses Node 22's built-in WebSocket (no Origin / subprotocol required by the server).
|
||||||
|
*/
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { decodeFrame, encodeFrame } from "./rpartstore.stomp";
|
||||||
|
|
||||||
|
export interface BffMessage<T = unknown> {
|
||||||
|
type: string;
|
||||||
|
payload: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionOptions {
|
||||||
|
brokerUrl: string;
|
||||||
|
accessToken: string;
|
||||||
|
profile: string;
|
||||||
|
appVersion: string;
|
||||||
|
webLanguage: string;
|
||||||
|
connectTimeoutMs?: number;
|
||||||
|
/** STOMP heart-beat interval (ms) negotiated with the server. */
|
||||||
|
heartbeatMs?: number;
|
||||||
|
onClose?: (reason: string) => void;
|
||||||
|
logger?: { debug: (msg: string) => void; warn: (msg: string) => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestOptions {
|
||||||
|
/** Message `type`s that complete the request. */
|
||||||
|
expect: string[];
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RpartstoreRateLimitError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly retryAfterSeconds: number,
|
||||||
|
readonly limitType: string,
|
||||||
|
) {
|
||||||
|
super(`RPartStore rate limit (${limitType}), retry after ${retryAfterSeconds}s`);
|
||||||
|
this.name = "RpartstoreRateLimitError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RpartstoreBffError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly type: string,
|
||||||
|
readonly payload: unknown,
|
||||||
|
) {
|
||||||
|
super(`RPartStore BFF error ${type}`);
|
||||||
|
this.name = "RpartstoreBffError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pending {
|
||||||
|
expect: Set<string>;
|
||||||
|
resolve: (msg: BffMessage) => void;
|
||||||
|
reject: (err: Error) => void;
|
||||||
|
timer: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RpartstoreSession {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private readonly pending = new Map<string, Pending>();
|
||||||
|
private heartbeatTimer: NodeJS.Timeout | null = null;
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
|
constructor(private readonly opts: SessionOptions) {}
|
||||||
|
|
||||||
|
get isOpen(): boolean {
|
||||||
|
return !this.closed && this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the socket, sends CONNECT and subscribes to the user queues. Resolves on CONNECTED. */
|
||||||
|
connect(): Promise<void> {
|
||||||
|
const {
|
||||||
|
brokerUrl,
|
||||||
|
accessToken,
|
||||||
|
profile,
|
||||||
|
appVersion,
|
||||||
|
webLanguage,
|
||||||
|
connectTimeoutMs = 10_000,
|
||||||
|
heartbeatMs = 60_000,
|
||||||
|
} = this.opts;
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const fail = (err: Error): void => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
this.teardown(err.message);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => fail(new Error("STOMP CONNECT timeout")), connectTimeoutMs);
|
||||||
|
let ws: WebSocket;
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(brokerUrl, ["v12.stomp"]);
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ws = ws;
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(
|
||||||
|
encodeFrame("CONNECT", {
|
||||||
|
"trace-id": randomUUID(),
|
||||||
|
"x-auth-token": accessToken,
|
||||||
|
"selected-profile": profile,
|
||||||
|
"app-version": appVersion,
|
||||||
|
"web-language": webLanguage,
|
||||||
|
"accept-version": "1.2,1.1,1.0",
|
||||||
|
"heart-beat": `${heartbeatMs},${heartbeatMs}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
ws.onmessage = (event: MessageEvent) => {
|
||||||
|
const frame = decodeFrame(typeof event.data === "string" ? event.data : String(event.data));
|
||||||
|
if (!frame) return; // heartbeat
|
||||||
|
if (frame.command === "CONNECTED") {
|
||||||
|
clearTimeout(timer);
|
||||||
|
ws.send(encodeFrame("SUBSCRIBE", { id: "sub-0", destination: "/user/queue/main" }));
|
||||||
|
ws.send(encodeFrame("SUBSCRIBE", { id: "sub-1", destination: "/user/queue/error" }));
|
||||||
|
this.startHeartbeat(heartbeatMs);
|
||||||
|
settled = true;
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (frame.command === "ERROR") {
|
||||||
|
fail(
|
||||||
|
new Error(`STOMP ERROR: ${frame.headers.message ?? ""} ${frame.body.slice(0, 200)}`),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (frame.command === "MESSAGE") this.onMessage(frame.headers, frame.body);
|
||||||
|
};
|
||||||
|
ws.onerror = () => fail(new Error("WebSocket error"));
|
||||||
|
ws.onclose = (event: { code: number; reason: string }) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
const reason = `WebSocket closed (${event.code}${event.reason ? ` ${event.reason}` : ""})`;
|
||||||
|
if (!settled) fail(new Error(reason));
|
||||||
|
else this.teardown(reason);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publishes `{payload}` to `/app/<path>` and resolves on the first expected message type. */
|
||||||
|
request<T = unknown>(
|
||||||
|
path: string,
|
||||||
|
payload: unknown,
|
||||||
|
options: RequestOptions,
|
||||||
|
): Promise<BffMessage<T>> {
|
||||||
|
const ws = this.ws;
|
||||||
|
if (!this.isOpen || !ws) return Promise.reject(new Error("STOMP session is not connected"));
|
||||||
|
const traceId = randomUUID();
|
||||||
|
const body = JSON.stringify({ payload });
|
||||||
|
const { expect, timeoutMs = 15_000 } = options;
|
||||||
|
return new Promise<BffMessage<T>>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pending.delete(traceId);
|
||||||
|
reject(new Error(`RPartStore request ${path} timed out after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
this.pending.set(traceId, {
|
||||||
|
expect: new Set(expect),
|
||||||
|
resolve: (msg) => resolve(msg as BffMessage<T>),
|
||||||
|
reject,
|
||||||
|
timer,
|
||||||
|
});
|
||||||
|
ws.send(encodeFrame("SEND", { destination: `/app${path}`, "trace-id": traceId }, body));
|
||||||
|
this.opts.logger?.debug(`→ ${path} trace=${traceId}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.teardown("closed by client");
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMessage(headers: Record<string, string>, body: string): void {
|
||||||
|
const traceId = headers.traceId;
|
||||||
|
const pending = traceId ? this.pending.get(traceId) : undefined;
|
||||||
|
let msg: BffMessage;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(body) as BffMessage;
|
||||||
|
} catch {
|
||||||
|
this.opts.logger?.warn(
|
||||||
|
`unparseable BFF message on ${headers.destination ?? "?"} trace=${traceId ?? "?"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pending) return; // unsolicited push (search-history refresh etc.)
|
||||||
|
const isError = headers.destination === "/user/queue/error";
|
||||||
|
if (/RATE_LIMIT_EXCEEDED$/.test(msg.type)) {
|
||||||
|
const p = msg.payload as { retryAfterSeconds?: number; limitType?: string };
|
||||||
|
this.settle(traceId, pending, (pend) =>
|
||||||
|
pend.reject(
|
||||||
|
new RpartstoreRateLimitError(p.retryAfterSeconds ?? 10, p.limitType ?? "SHORT_TERM"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isError) {
|
||||||
|
this.settle(traceId, pending, (pend) =>
|
||||||
|
pend.reject(new RpartstoreBffError(msg.type, msg.payload)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pending.expect.has(msg.type)) {
|
||||||
|
this.settle(traceId, pending, (pend) => pend.resolve(msg));
|
||||||
|
}
|
||||||
|
// other frames on the same trace (COMMAND_PROCESSING_RESPONSE, EXPLODED_TREE_RESPONSE…) are ignored here
|
||||||
|
}
|
||||||
|
|
||||||
|
private settle(traceId: string, pending: Pending, fn: (p: Pending) => void): void {
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
this.pending.delete(traceId);
|
||||||
|
fn(pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startHeartbeat(intervalMs: number): void {
|
||||||
|
this.stopHeartbeat();
|
||||||
|
this.heartbeatTimer = setInterval(() => {
|
||||||
|
if (this.isOpen) this.ws?.send("\n");
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopHeartbeat(): void {
|
||||||
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private teardown(reason: string): void {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
this.stopHeartbeat();
|
||||||
|
for (const [traceId, p] of this.pending) {
|
||||||
|
clearTimeout(p.timer);
|
||||||
|
p.reject(new Error(`STOMP session ended: ${reason}`));
|
||||||
|
this.pending.delete(traceId);
|
||||||
|
}
|
||||||
|
const ws = this.ws;
|
||||||
|
this.ws = null;
|
||||||
|
if (ws && ws.readyState !== WebSocket.CLOSED) {
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.opts.onClose?.(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { decodeFrame, encodeFrame } from "./rpartstore.stomp";
|
||||||
|
|
||||||
|
describe("rpartstore STOMP codec", () => {
|
||||||
|
it("encodes a SEND frame exactly like the RPartStore web app", () => {
|
||||||
|
const body = '{"payload":{"requestId":"r","value":"VF1RFE00653633190","queryType":"VIN"}}';
|
||||||
|
const frame = encodeFrame(
|
||||||
|
"SEND",
|
||||||
|
{ destination: "/app/vehicles/search/vin-or-vrn", "trace-id": "t-1" },
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
expect(frame).toBe(
|
||||||
|
`SEND\ndestination:/app/vehicles/search/vin-or-vrn\ntrace-id:t-1\ncontent-length:${Buffer.byteLength(body)}\n\n${body}\u0000`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not escape CONNECT header values (STOMP 1.2 rule) but escapes others", () => {
|
||||||
|
expect(encodeFrame("CONNECT", { "x-auth-token": "a:b" })).toBe(
|
||||||
|
"CONNECT\nx-auth-token:a:b\n\n\u0000",
|
||||||
|
);
|
||||||
|
expect(encodeFrame("SEND", { destination: "/app/x", note: "a:b\nc" })).toContain(
|
||||||
|
"note:a\\cb\\nc",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes a live MESSAGE frame with headers and JSON body", () => {
|
||||||
|
const raw =
|
||||||
|
"MESSAGE\ntraceId:f68cc229\ncontent-type:text/plain;charset=UTF-8\ndestination:/user/queue/main\nsubscription:sub-0\nmessage-id:975afec7-1\ncontent-length:64\n\n" +
|
||||||
|
'{"type":"1PO/COMMON/COMMAND_PROCESSING_RESPONSE","payload":true}\u0000';
|
||||||
|
const frame = decodeFrame(raw);
|
||||||
|
expect(frame?.command).toBe("MESSAGE");
|
||||||
|
expect(frame?.headers.traceId).toBe("f68cc229");
|
||||||
|
expect(frame?.headers["content-type"]).toBe("text/plain;charset=UTF-8");
|
||||||
|
expect(JSON.parse(frame!.body)).toEqual({
|
||||||
|
type: "1PO/COMMON/COMMAND_PROCESSING_RESPONSE",
|
||||||
|
payload: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats heartbeat newlines as no frame", () => {
|
||||||
|
expect(decodeFrame("\n")).toBeNull();
|
||||||
|
expect(decodeFrame("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the first value of a repeated header", () => {
|
||||||
|
const frame = decodeFrame("MESSAGE\nk:first\nk:second\n\nbody\u0000");
|
||||||
|
expect(frame?.headers.k).toBe("first");
|
||||||
|
expect(frame?.body).toBe("body");
|
||||||
|
});
|
||||||
|
});
|
||||||
69
apps/api/src/integrations/rpartstore/rpartstore.stomp.ts
Normal file
69
apps/api/src/integrations/rpartstore/rpartstore.stomp.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Minimal STOMP 1.2 codec used by the RPartStore BFF client.
|
||||||
|
* The BFF speaks STOMP over a plain WebSocket (`wss://1po-bff.renault-edh.com/ws`):
|
||||||
|
* CONNECT → CONNECTED, SUBSCRIBE /user/queue/main + /user/queue/error,
|
||||||
|
* SEND /app/<path> with a `trace-id` header, and 1..N MESSAGE frames back
|
||||||
|
* carrying the same `traceId` header and a `{type, payload}` JSON body.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface StompFrame {
|
||||||
|
command: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NULL = "\u0000";
|
||||||
|
|
||||||
|
/** STOMP 1.2 header value escaping (RFC: `\\`, `\n` → `\\n`, `:` → `\\c`, `\r` → `\\r`). */
|
||||||
|
function escapeHeader(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/\r/g, "\\r")
|
||||||
|
.replace(/\n/g, "\\n")
|
||||||
|
.replace(/:/g, "\\c");
|
||||||
|
}
|
||||||
|
|
||||||
|
function unescapeHeader(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/\\n/g, "\n")
|
||||||
|
.replace(/\\r/g, "\r")
|
||||||
|
.replace(/\\c/g, ":")
|
||||||
|
.replace(/\\\\/g, "\\");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeFrame(
|
||||||
|
command: string,
|
||||||
|
headers: Record<string, string | number>,
|
||||||
|
body = "",
|
||||||
|
): string {
|
||||||
|
const lines = [command];
|
||||||
|
for (const [k, v] of Object.entries(headers)) {
|
||||||
|
lines.push(`${k}:${command === "CONNECT" ? String(v) : escapeHeader(String(v))}`);
|
||||||
|
}
|
||||||
|
if (body && headers["content-length"] === undefined) {
|
||||||
|
lines.push(`content-length:${Buffer.byteLength(body, "utf8")}`);
|
||||||
|
}
|
||||||
|
return `${lines.join("\n")}\n\n${body}${NULL}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Heartbeat frames are a bare newline; they decode to `null`. */
|
||||||
|
export function decodeFrame(raw: string): StompFrame | null {
|
||||||
|
if (raw === "\n" || raw === "\r\n" || raw === "") return null;
|
||||||
|
const headerEnd = raw.indexOf("\n\n");
|
||||||
|
if (headerEnd < 0) return null;
|
||||||
|
const headerBlock = raw.slice(0, headerEnd).split("\n");
|
||||||
|
const command = headerBlock[0].trim();
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
for (const line of headerBlock.slice(1)) {
|
||||||
|
const i = line.indexOf(":");
|
||||||
|
if (i < 0) continue;
|
||||||
|
const key = line.slice(0, i);
|
||||||
|
// STOMP: the first occurrence of a repeated header wins.
|
||||||
|
if (headers[key] === undefined)
|
||||||
|
headers[key] =
|
||||||
|
command === "CONNECTED" ? line.slice(i + 1) : unescapeHeader(line.slice(i + 1));
|
||||||
|
}
|
||||||
|
let body = raw.slice(headerEnd + 2);
|
||||||
|
if (body.endsWith(NULL)) body = body.slice(0, -1);
|
||||||
|
return { command, headers, body };
|
||||||
|
}
|
||||||
54
apps/api/src/integrations/rpartstore/rpartstore.types.ts
Normal file
54
apps/api/src/integrations/rpartstore/rpartstore.types.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Shapes of the RPartStore BFF `SEARCH_VEHICLE_RESPONSE` payload (DATAHUB
|
||||||
|
* catalog source, TR market). Captured live 2026-09-25; see
|
||||||
|
* /home/s/ss/rpartstore-dogrudan-vin-decode-2026-09-25.md for the protocol notes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RpartstoreDataHubVehicle {
|
||||||
|
name?: string; // "RENAULT Kadjar (HFE)"
|
||||||
|
modelType?: string; // "SUV"
|
||||||
|
modelTypeCode?: string; // "HFE"
|
||||||
|
familyCode?: string; // "XFE"
|
||||||
|
bodyType?: string; // "HFE"
|
||||||
|
engine?: string; // "1.5 DCI DİZEL MOTOR [K9K]"
|
||||||
|
gearbox?: string; // "6 VİTESLİ KAVRAMA VİTES KUTUSU:DC4 [DC4]"
|
||||||
|
energyType?: string; // "MOTORIN"
|
||||||
|
powerKw?: string; // "066 KW POWER" | ""
|
||||||
|
modelYear?: string; // "2015"
|
||||||
|
vehicleAge?: number;
|
||||||
|
wheelbaseLength?: string;
|
||||||
|
roofHeight?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RpartstoreVehicle {
|
||||||
|
catalogSource: string; // "DATAHUB"
|
||||||
|
vin: string;
|
||||||
|
vehicleKey: string;
|
||||||
|
model: string | undefined; // "Kadjar (HFE)" — undefined on some pre-2000 cars
|
||||||
|
vehicleBrand: string; // "RENAULT" | "DACIA"
|
||||||
|
country: string; // "TR"
|
||||||
|
manufacturingDate?: string; // "2015-07-28"
|
||||||
|
imageUrl?: string;
|
||||||
|
dataHubVehicle?: RpartstoreDataHubVehicle;
|
||||||
|
vehicleIdentifiedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchVehicleResponsePayload {
|
||||||
|
vehicles: RpartstoreVehicle[];
|
||||||
|
requestId: string;
|
||||||
|
searchedCountry: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalised decode result stored in `rpartstore_decodes`. */
|
||||||
|
export interface RpartstoreDecoded {
|
||||||
|
brandName: string; // "Renault" | "Dacia" (canonical casing, matches catalog_vehicles.brand_name)
|
||||||
|
model: string | null; // "Kadjar (HFE)"
|
||||||
|
modelCode: string | null; // "HFE"
|
||||||
|
familyCode: string | null; // "XFE"
|
||||||
|
modelYear: string | null; // "2015"
|
||||||
|
engine: string | null;
|
||||||
|
gearbox: string | null;
|
||||||
|
energyType: string | null;
|
||||||
|
manufacturingDate: string | null;
|
||||||
|
raw: RpartstoreVehicle;
|
||||||
|
}
|
||||||
213
apps/api/src/integrations/vinpin/vinpin-daemon.service.spec.ts
Normal file
213
apps/api/src/integrations/vinpin/vinpin-daemon.service.spec.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { VinpinDaemonService } from "./vinpin-daemon.service";
|
||||||
|
import type { VinpinDriverService } from "./vinpin-driver.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the Vinpin warm-session daemon lifecycle: the business-hours
|
||||||
|
* scheduler (warm/teardown reconciliation with an injected clock), warm-on-demand
|
||||||
|
* decode routing, and the reentrancy guard. The driver is a fake — the real seat
|
||||||
|
* lives on prod and can't be driven from a test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface FakeDriver {
|
||||||
|
isWarm: ReturnType<typeof vi.fn>;
|
||||||
|
warmUp: ReturnType<typeof vi.fn>;
|
||||||
|
teardownWarm: ReturnType<typeof vi.fn>;
|
||||||
|
decode: ReturnType<typeof vi.fn>;
|
||||||
|
keepalivePing: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDriver(warm = false): FakeDriver {
|
||||||
|
return {
|
||||||
|
isWarm: vi.fn(() => warm),
|
||||||
|
warmUp: vi.fn(async () => true),
|
||||||
|
teardownWarm: vi.fn(async () => undefined),
|
||||||
|
decode: vi.fn(async () => null),
|
||||||
|
keepalivePing: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function daemon(driver: FakeDriver, opts: { hours: boolean; enabled?: boolean }) {
|
||||||
|
return new VinpinDaemonService({
|
||||||
|
driver: driver as unknown as VinpinDriverService,
|
||||||
|
isBusinessHours: () => opts.hours,
|
||||||
|
isEnabled: () => opts.enabled ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VinpinDaemonService — scheduler reconcile", () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
it("warms up inside business hours when enabled and not already warm", async () => {
|
||||||
|
const driver = makeDriver(false);
|
||||||
|
await daemon(driver, { hours: true }).reconcile();
|
||||||
|
expect(driver.warmUp).toHaveBeenCalledTimes(1);
|
||||||
|
expect(driver.teardownWarm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT re-warm when already warm inside hours", async () => {
|
||||||
|
const driver = makeDriver(true);
|
||||||
|
await daemon(driver, { hours: true }).reconcile();
|
||||||
|
expect(driver.warmUp).not.toHaveBeenCalled();
|
||||||
|
expect(driver.teardownWarm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tears down the warm seat outside business hours", async () => {
|
||||||
|
const driver = makeDriver(true);
|
||||||
|
await daemon(driver, { hours: false }).reconcile();
|
||||||
|
expect(driver.teardownWarm).toHaveBeenCalledTimes(1);
|
||||||
|
expect(driver.warmUp).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tears down a warm seat when the daemon is disabled (kill-switch)", async () => {
|
||||||
|
const driver = makeDriver(true);
|
||||||
|
await daemon(driver, { hours: true, enabled: false }).reconcile();
|
||||||
|
expect(driver.teardownWarm).toHaveBeenCalledTimes(1);
|
||||||
|
expect(driver.warmUp).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when disabled and already cold", async () => {
|
||||||
|
const driver = makeDriver(false);
|
||||||
|
await daemon(driver, { hours: true, enabled: false }).reconcile();
|
||||||
|
expect(driver.warmUp).not.toHaveBeenCalled();
|
||||||
|
expect(driver.teardownWarm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not stack overlapping reconciles (reentrancy guard)", async () => {
|
||||||
|
const driver = makeDriver(false);
|
||||||
|
let release!: () => void;
|
||||||
|
driver.warmUp.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
release = () => resolve(true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const d = daemon(driver, { hours: true });
|
||||||
|
const first = d.reconcile();
|
||||||
|
const second = d.reconcile(); // should early-return while the first is in flight
|
||||||
|
release();
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
expect(driver.warmUp).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VinpinDaemonService — decode routing", () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
it("warms on-demand then delegates when inside hours and cold", async () => {
|
||||||
|
const driver = makeDriver(false);
|
||||||
|
driver.decode.mockResolvedValue({ brand: "Fiat", model: "EGEA" });
|
||||||
|
const result = await daemon(driver, { hours: true }).decode("NM435600006H43436");
|
||||||
|
expect(driver.warmUp).toHaveBeenCalledTimes(1);
|
||||||
|
expect(driver.decode).toHaveBeenCalledWith("NM435600006H43436");
|
||||||
|
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT warm on-demand when already warm — just delegates", async () => {
|
||||||
|
const driver = makeDriver(true);
|
||||||
|
await daemon(driver, { hours: true }).decode("NM435600006H43436");
|
||||||
|
expect(driver.warmUp).not.toHaveBeenCalled();
|
||||||
|
expect(driver.decode).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("off-hours delegates straight to the cold path (never holds the seat)", async () => {
|
||||||
|
const driver = makeDriver(false);
|
||||||
|
await daemon(driver, { hours: false }).decode("NM435600006H43436");
|
||||||
|
expect(driver.warmUp).not.toHaveBeenCalled();
|
||||||
|
expect(driver.decode).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null (never throws) if the driver decode rejects", async () => {
|
||||||
|
const driver = makeDriver(true);
|
||||||
|
driver.decode.mockRejectedValue(new Error("boom"));
|
||||||
|
const result = await daemon(driver, { hours: true }).decode("NM435600006H43436");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VinpinDaemonService — warm-up backoff", () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
function backoffDaemon(driver: FakeDriver, clock: { t: number }) {
|
||||||
|
return new VinpinDaemonService({
|
||||||
|
driver: driver as unknown as VinpinDriverService,
|
||||||
|
isBusinessHours: () => true,
|
||||||
|
isEnabled: () => true,
|
||||||
|
now: () => clock.t,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("a failed warmUp sets a cooldown that reconcile() respects (no immediate re-warm)", async () => {
|
||||||
|
const driver = makeDriver(false);
|
||||||
|
driver.warmUp.mockResolvedValue(false); // warm-up keeps failing
|
||||||
|
const clock = { t: 0 };
|
||||||
|
const d = backoffDaemon(driver, clock);
|
||||||
|
|
||||||
|
await d.reconcile();
|
||||||
|
expect(driver.warmUp).toHaveBeenCalledTimes(1); // first attempt ran
|
||||||
|
|
||||||
|
// Still cold + in hours, but inside the cooldown → no second attempt.
|
||||||
|
clock.t = 30_000;
|
||||||
|
await d.reconcile();
|
||||||
|
expect(driver.warmUp).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// After the base cooldown (60s) elapses → it tries again.
|
||||||
|
clock.t = 61_000;
|
||||||
|
await d.reconcile();
|
||||||
|
expect(driver.warmUp).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warm-on-demand decode ALSO respects the cooldown, but still delegates the cold decode", async () => {
|
||||||
|
const driver = makeDriver(false);
|
||||||
|
driver.warmUp.mockResolvedValue(false);
|
||||||
|
const clock = { t: 0 };
|
||||||
|
const d = backoffDaemon(driver, clock);
|
||||||
|
|
||||||
|
await d.reconcile(); // fails → cooldown until 60_000
|
||||||
|
expect(driver.warmUp).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
clock.t = 20_000;
|
||||||
|
await d.decode("NM435600006H43436"); // in cooldown → no warm-on-demand
|
||||||
|
expect(driver.warmUp).toHaveBeenCalledTimes(1);
|
||||||
|
expect(driver.decode).toHaveBeenCalledTimes(1); // still decodes via the cold path
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a successful warm resets the backoff (next attempt is not blocked)", async () => {
|
||||||
|
const driver = makeDriver(false);
|
||||||
|
driver.warmUp.mockResolvedValueOnce(false).mockResolvedValueOnce(true).mockResolvedValue(false);
|
||||||
|
const clock = { t: 0 };
|
||||||
|
const d = backoffDaemon(driver, clock);
|
||||||
|
|
||||||
|
await d.reconcile(); // fail → cooldown until 60_000
|
||||||
|
clock.t = 61_000;
|
||||||
|
await d.reconcile(); // success → cooldown reset
|
||||||
|
expect(driver.warmUp).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// isWarm() is still false in the fake, so reconcile would warm again — and with
|
||||||
|
// the cooldown reset it may attempt immediately (no leftover backoff window).
|
||||||
|
clock.t = 61_500;
|
||||||
|
await d.reconcile();
|
||||||
|
expect(driver.warmUp).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VinpinDaemonService — start/stop lifecycle", () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconciles on start and drives keepalive on the interval; stop tears down", async () => {
|
||||||
|
const driver = makeDriver(false);
|
||||||
|
const d = daemon(driver, { hours: true });
|
||||||
|
d.start();
|
||||||
|
// Immediate reconcile → warm.
|
||||||
|
await vi.waitFor(() => expect(driver.warmUp).toHaveBeenCalledTimes(1));
|
||||||
|
// Advance past a keepalive interval → a ping fires.
|
||||||
|
await vi.advanceTimersByTimeAsync(80_000);
|
||||||
|
expect(driver.keepalivePing).toHaveBeenCalled();
|
||||||
|
await d.stop();
|
||||||
|
expect(driver.teardownWarm).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
195
apps/api/src/integrations/vinpin/vinpin-daemon.service.ts
Normal file
195
apps/api/src/integrations/vinpin/vinpin-daemon.service.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Vinpin warm-session daemon (worker-process singleton).
|
||||||
|
*
|
||||||
|
* Owns the LIFECYCLE of the persistent warm Vinpin seat, on top of the browser
|
||||||
|
* mechanics in VinpinDriverService:
|
||||||
|
* - a scheduler that warms the seat at 08:00 and tears it down at 21:00
|
||||||
|
* Europe/Istanbul (and warms on worker start if already inside the window),
|
||||||
|
* - an idle keepalive that nudges the RDS session every ~75s so it never drops,
|
||||||
|
* - `decode(vin)` — the entry the vinpin-decode processor calls. Inside business
|
||||||
|
* hours it makes sure the seat is warm (warming on-demand once), then delegates
|
||||||
|
* to the driver, which self-routes to the HOT warm path when warm and the
|
||||||
|
* proven COLD per-decode path otherwise. Outside hours it never holds the seat
|
||||||
|
* — the driver's cold path handles the decode and is torn down after.
|
||||||
|
*
|
||||||
|
* ADDITIVE + fail-safe: every warm operation degrades to the cold path, and
|
||||||
|
* `decode()` never throws (the driver returns null on any failure). Gated by
|
||||||
|
* VINPIN_ENABLED (feature flag) and VINPIN_WARM_DAEMON (kill-switch: set to
|
||||||
|
* "false" to force the legacy per-decode cold path with the daemon inert).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
import { getVinpinDriver } from "./vinpin-driver.service";
|
||||||
|
import type { VinpinDecodeResult, VinpinDriverService } from "./vinpin-driver.service";
|
||||||
|
import { VINPIN_WARM, isVinpinBusinessHours } from "./vinpin.constants";
|
||||||
|
|
||||||
|
/** Whether the warm daemon is allowed to run (feature flag + kill-switch). */
|
||||||
|
export function isVinpinWarmDaemonEnabled(): boolean {
|
||||||
|
return process.env.VINPIN_ENABLED === "true" && process.env.VINPIN_WARM_DAEMON !== "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VinpinDaemonDeps {
|
||||||
|
/** Driver singleton (injectable for tests). Defaults to the process-wide one. */
|
||||||
|
driver?: VinpinDriverService;
|
||||||
|
/** Business-hours predicate (injectable so tests can drive the clock). */
|
||||||
|
isBusinessHours?: () => boolean;
|
||||||
|
/** Warm-daemon enabled predicate (injectable for tests). */
|
||||||
|
isEnabled?: () => boolean;
|
||||||
|
/** Monotonic-ish clock (injectable so tests can drive the warm-up backoff). */
|
||||||
|
now?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VinpinDaemonService {
|
||||||
|
private readonly logger = new Logger(VinpinDaemonService.name);
|
||||||
|
private readonly driver: VinpinDriverService;
|
||||||
|
private readonly isBusinessHours: () => boolean;
|
||||||
|
private readonly isEnabled: () => boolean;
|
||||||
|
private readonly now: () => number;
|
||||||
|
|
||||||
|
private schedulerTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private reconciling = false;
|
||||||
|
private started = false;
|
||||||
|
|
||||||
|
// ─── Warm-up backoff ─────────────────────────────────────
|
||||||
|
/** Wall-clock time until which a re-warm is suppressed after a failed warmUp.
|
||||||
|
* Both reconcile() and decode()'s warm-on-demand honour this so a failing seat
|
||||||
|
* isn't hammered every ~60s (each failed attempt would leave a fresh dirty
|
||||||
|
* window). A successful warm resets it. */
|
||||||
|
private warmCooldownUntil = 0;
|
||||||
|
/** Current backoff span (ms): 0 when healthy, else base…max, doubling per
|
||||||
|
* consecutive failure. */
|
||||||
|
private warmBackoffMs = 0;
|
||||||
|
|
||||||
|
constructor(deps: VinpinDaemonDeps = {}) {
|
||||||
|
this.driver = deps.driver ?? getVinpinDriver();
|
||||||
|
this.isBusinessHours = deps.isBusinessHours ?? (() => isVinpinBusinessHours());
|
||||||
|
this.isEnabled = deps.isEnabled ?? isVinpinWarmDaemonEnabled;
|
||||||
|
this.now = deps.now ?? (() => Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while a failed warmUp's cooldown is still in effect. */
|
||||||
|
private inWarmCooldown(): boolean {
|
||||||
|
return this.now() < this.warmCooldownUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a warm-up outcome and update the backoff. Success clears the cooldown;
|
||||||
|
* failure sets/extends it (base, then exponential up to max). Returns `ok` so
|
||||||
|
* callers can chain.
|
||||||
|
*/
|
||||||
|
private noteWarmResult(ok: boolean): boolean {
|
||||||
|
if (ok) {
|
||||||
|
this.warmBackoffMs = 0;
|
||||||
|
this.warmCooldownUntil = 0;
|
||||||
|
} else {
|
||||||
|
this.warmBackoffMs =
|
||||||
|
this.warmBackoffMs === 0
|
||||||
|
? VINPIN_WARM.warmBackoffBaseMs
|
||||||
|
: Math.min(this.warmBackoffMs * 2, VINPIN_WARM.warmBackoffMaxMs);
|
||||||
|
this.warmCooldownUntil = this.now() + this.warmBackoffMs;
|
||||||
|
this.logger.warn(
|
||||||
|
`warmUp failed — backing off ${Math.round(this.warmBackoffMs / 1000)}s before the next attempt`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start the scheduler + keepalive loops (idempotent). */
|
||||||
|
start(): void {
|
||||||
|
if (this.started) return;
|
||||||
|
this.started = true;
|
||||||
|
// Reconcile once now (warm immediately if the worker booted inside hours).
|
||||||
|
void this.reconcile();
|
||||||
|
this.schedulerTimer = setInterval(() => {
|
||||||
|
void this.reconcile();
|
||||||
|
}, VINPIN_WARM.schedulerIntervalMs);
|
||||||
|
this.keepaliveTimer = setInterval(() => {
|
||||||
|
void this.driver.keepalivePing();
|
||||||
|
}, VINPIN_WARM.keepaliveIntervalMs);
|
||||||
|
// Don't keep the event loop alive just for these timers.
|
||||||
|
this.schedulerTimer.unref?.();
|
||||||
|
this.keepaliveTimer.unref?.();
|
||||||
|
this.logger.log(
|
||||||
|
`Vinpin warm daemon started (enabled=${this.isEnabled()}, hours ${VINPIN_WARM.businessStartHour}:00–${VINPIN_WARM.businessEndHour}:00 Europe/Istanbul)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the loops and tear the warm seat down (worker shutdown). */
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.schedulerTimer) clearInterval(this.schedulerTimer);
|
||||||
|
if (this.keepaliveTimer) clearInterval(this.keepaliveTimer);
|
||||||
|
this.schedulerTimer = null;
|
||||||
|
this.keepaliveTimer = null;
|
||||||
|
this.started = false;
|
||||||
|
await this.driver.teardownWarm().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile the warm seat against the schedule: warm up when enabled + inside
|
||||||
|
* hours + not already warm; tear down when warm but disabled or outside hours.
|
||||||
|
* Guards against overlapping runs (a slow warmUp must not stack). Never throws.
|
||||||
|
*/
|
||||||
|
async reconcile(): Promise<void> {
|
||||||
|
if (this.reconciling) return;
|
||||||
|
this.reconciling = true;
|
||||||
|
try {
|
||||||
|
const enabled = this.isEnabled();
|
||||||
|
const inHours = this.isBusinessHours();
|
||||||
|
if (enabled && inHours && !this.driver.isWarm()) {
|
||||||
|
if (this.inWarmCooldown()) {
|
||||||
|
this.logger.debug("scheduler: in warm-up backoff — skipping this cycle");
|
||||||
|
} else {
|
||||||
|
this.logger.log("scheduler: inside business hours — warming the seat");
|
||||||
|
this.noteWarmResult(await this.driver.warmUp());
|
||||||
|
}
|
||||||
|
} else if (this.driver.isWarm() && (!enabled || !inHours)) {
|
||||||
|
this.logger.log("scheduler: outside business hours / disabled — tearing the seat down");
|
||||||
|
await this.driver.teardownWarm();
|
||||||
|
// Intentional teardown → clear any stale warm-up backoff so the next window
|
||||||
|
// (e.g. tomorrow 08:00) isn't blocked by a leftover cooldown.
|
||||||
|
this.warmBackoffMs = 0;
|
||||||
|
this.warmCooldownUntil = 0;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`reconcile failed: ${(err as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
this.reconciling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode entry the processor calls. Inside hours, ensure the seat is warm (warm
|
||||||
|
* on-demand once), then delegate to the driver — which uses the hot warm path
|
||||||
|
* when warm, else the cold per-decode path. Off-hours, delegate straight to the
|
||||||
|
* driver's cold path (no seat held). Never throws — returns null on any failure.
|
||||||
|
*/
|
||||||
|
async decode(vin: string): Promise<VinpinDecodeResult | null> {
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
this.isEnabled() &&
|
||||||
|
this.isBusinessHours() &&
|
||||||
|
!this.driver.isWarm() &&
|
||||||
|
!this.inWarmCooldown()
|
||||||
|
) {
|
||||||
|
// Warm-on-demand: a decode arrived inside hours before the scheduler warmed
|
||||||
|
// (e.g. right after 08:00, or after a drop). Best-effort — if it fails the
|
||||||
|
// driver silently runs the cold path for this decode, and the backoff spaces
|
||||||
|
// out the next warm attempt (respected by both this check and reconcile()).
|
||||||
|
this.noteWarmResult(await this.driver.warmUp().catch(() => false));
|
||||||
|
}
|
||||||
|
return await this.driver.decode(vin);
|
||||||
|
} catch (err) {
|
||||||
|
// Defensive: driver.decode never throws, but guarantee the processor a null.
|
||||||
|
this.logger.warn(`decode(${vin}) unexpected error: ${(err as Error).message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Worker singleton ────────────────────────────────────────
|
||||||
|
let daemonSingleton: VinpinDaemonService | null = null;
|
||||||
|
export function getVinpinDaemon(): VinpinDaemonService {
|
||||||
|
if (!daemonSingleton) daemonSingleton = new VinpinDaemonService();
|
||||||
|
return daemonSingleton;
|
||||||
|
}
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renault-decode robustness tests (Rpartstore-down hardening). These exercise the
|
||||||
|
* fixes that stop a DOWN Rpartstore from thrashing the shared seat:
|
||||||
|
* (2) ensureRpartstore no longer short-circuits "already open" on the flyout /
|
||||||
|
* window-title word "Rpartstore" — it gates on a CONTENT token and clicks the
|
||||||
|
* flyout entry when the submenu is up over the grid;
|
||||||
|
* (3) closeRpartstoreTab never clicks the language-selector coord (1298,14);
|
||||||
|
* (6) an in-memory cooldown routes Renault decodes straight to Dialogys during a
|
||||||
|
* persistent Rpartstore outage, and a successful load clears it.
|
||||||
|
* OCR is mocked so the screen-state sequence is fully controllable — the real seat
|
||||||
|
* lives on prod and can't be driven from a test.
|
||||||
|
*/
|
||||||
|
vi.mock("./vinpin.ocr", () => ({
|
||||||
|
ocrRegion: vi.fn(async () => ""),
|
||||||
|
pollForText: vi.fn(async () => ({ matched: false, text: "" })),
|
||||||
|
terminateOcr: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { VinpinDriverService } from "./vinpin-driver.service";
|
||||||
|
import { VINPIN_COORDS } from "./vinpin.constants";
|
||||||
|
import { ocrRegion } from "./vinpin.ocr";
|
||||||
|
|
||||||
|
const mockOcr = vi.mocked(ocrRegion);
|
||||||
|
type AnyDriver = Record<string, unknown>;
|
||||||
|
|
||||||
|
function fakePage(sink?: (x: number, y: number) => void) {
|
||||||
|
return {
|
||||||
|
isClosed: () => false,
|
||||||
|
frames: () => [] as unknown[],
|
||||||
|
mouse: {
|
||||||
|
click: vi.fn(async (x: number, y: number) => sink?.(x, y)),
|
||||||
|
move: vi.fn(async () => undefined),
|
||||||
|
down: vi.fn(async () => undefined),
|
||||||
|
up: vi.fn(async () => undefined),
|
||||||
|
},
|
||||||
|
keyboard: { press: vi.fn(async () => undefined) },
|
||||||
|
waitForTimeout: vi.fn(async () => undefined),
|
||||||
|
locator: () => ({
|
||||||
|
first: () => ({
|
||||||
|
count: async () => 0,
|
||||||
|
isVisible: async () => false,
|
||||||
|
click: async () => undefined,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VinpinDriverService — ensureRpartstore flyout false-positive (change 2)", () => {
|
||||||
|
const savedEnv = { ...process.env };
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.VINPIN_ENABLED = "true";
|
||||||
|
mockOcr.mockReset();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.env = { ...savedEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
const ensure = (driver: VinpinDriverService, page: unknown) =>
|
||||||
|
((driver as unknown as AnyDriver).ensureRpartstore as (p: unknown) => Promise<boolean>).call(
|
||||||
|
driver,
|
||||||
|
page,
|
||||||
|
);
|
||||||
|
|
||||||
|
it("does NOT declare Rpartstore open on the FLYOUT — it clicks the Rpartstore entry (584,779)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const page = fakePage();
|
||||||
|
// Flyout is open OVER the grid: brand tiles visible + the Renault submenu items
|
||||||
|
// "Rpartstore"/"Dialogys" — matches renaultSubmenu but has NO search-home content.
|
||||||
|
mockOcr
|
||||||
|
.mockResolvedValueOnce("Volkswagen Mitsubishi TecDoc Renault Rpartstore Dialogys")
|
||||||
|
.mockResolvedValue("Şasi no ile arama Güncel araçlar"); // then the LOADED home
|
||||||
|
|
||||||
|
const ok = await ensure(driver, page);
|
||||||
|
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
// The flyout Rpartstore entry was clicked (old code short-circuited without it).
|
||||||
|
expect(page.mouse.click).toHaveBeenCalledWith(
|
||||||
|
VINPIN_COORDS.renaultRpartstore.x,
|
||||||
|
VINPIN_COORDS.renaultRpartstore.y,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("short-circuits (no flyout/tile click) when the LOADED search-home content is already on screen", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const page = fakePage();
|
||||||
|
mockOcr.mockResolvedValue("Şasi no ile arama Ne arıyorsunuz Güncel araçlar");
|
||||||
|
|
||||||
|
expect(await ensure(driver, page)).toBe(true);
|
||||||
|
expect(page.mouse.click).not.toHaveBeenCalledWith(
|
||||||
|
VINPIN_COORDS.renaultRpartstore.x,
|
||||||
|
VINPIN_COORDS.renaultRpartstore.y,
|
||||||
|
);
|
||||||
|
expect(page.mouse.move).not.toHaveBeenCalledWith(
|
||||||
|
VINPIN_COORDS.renaultBrand.x,
|
||||||
|
VINPIN_COORDS.renaultBrand.y,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an open Rpartstore WINDOW (title only, no grid behind) as present without re-clicking the flyout", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const page = fakePage();
|
||||||
|
// Window title bar "Renault Rpartstore" matches renaultSubmenu too, but there's
|
||||||
|
// NO grid behind it → must be handled by the open-window branch, not the flyout.
|
||||||
|
mockOcr.mockResolvedValue("Renault Rpartstore");
|
||||||
|
|
||||||
|
expect(await ensure(driver, page)).toBe(true);
|
||||||
|
expect(page.mouse.click).not.toHaveBeenCalledWith(
|
||||||
|
VINPIN_COORDS.renaultRpartstore.x,
|
||||||
|
VINPIN_COORDS.renaultRpartstore.y,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bails on the FIRST iteration when the DOWN launch-error modal is over the grid (no repeated flyout clicks)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const page = fakePage();
|
||||||
|
// DOWN Rpartstore: the launch-error modal sits OVER the brand grid. The FULL-frame
|
||||||
|
// read (no clip) sees the grid tiles + the modal TITLE "Renault Rpartstore" → it
|
||||||
|
// matches brandGrid AND renaultSubmenu, which — without the fix — makes the flyout
|
||||||
|
// branch re-click renaultRpartstore(584,779) on all ~6 iterations. The UPSCALED
|
||||||
|
// modal crop (clip passed) reads the Cyrillic launch error, so the launch-error
|
||||||
|
// short-circuit must fire and return false on iteration 1.
|
||||||
|
mockOcr.mockImplementation(async (_page: unknown, clip?: unknown) =>
|
||||||
|
clip
|
||||||
|
? "Owwu6ka 3anycka KaTanora" // upscaled modal crop → matches rpartstoreLaunchError
|
||||||
|
: "Volkswagen Mitsubishi Renault Rpartstore Dialogys",
|
||||||
|
);
|
||||||
|
|
||||||
|
const ok = await ensure(driver, page);
|
||||||
|
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
// Never re-clicked the flyout Rpartstore entry (would be up to 6× without the fix).
|
||||||
|
expect(page.mouse.click).not.toHaveBeenCalledWith(
|
||||||
|
VINPIN_COORDS.renaultRpartstore.x,
|
||||||
|
VINPIN_COORDS.renaultRpartstore.y,
|
||||||
|
);
|
||||||
|
// Exactly ONE full-frame OCR read (no clip) proves it bailed on the first iteration
|
||||||
|
// rather than looping the 6-try budget.
|
||||||
|
const fullFrameReads = mockOcr.mock.calls.filter(([, clip]) => clip === undefined);
|
||||||
|
expect(fullFrameReads).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still enters the flyout branch when the modal crop is ILLEGIBLE (no ffmpeg) — behaviour unchanged", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const page = fakePage();
|
||||||
|
// Same grid+flyout-title screen, but the crop OCR yields no launch-error text (the
|
||||||
|
// no-ffmpeg / illegible case). The launch-error short-circuit must NOT fire, so the
|
||||||
|
// proven flyout path still runs and clicks the Rpartstore entry.
|
||||||
|
mockOcr.mockImplementation(async (_page: unknown, clip?: unknown) =>
|
||||||
|
clip
|
||||||
|
? "" // illegible crop → rpartstoreLaunchErrorPresent === false
|
||||||
|
: "Volkswagen Mitsubishi Renault Rpartstore Dialogys",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bounded loop; we only assert the flyout entry was clicked (grid+submenu branch).
|
||||||
|
await ensure(driver, page);
|
||||||
|
|
||||||
|
expect(page.mouse.click).toHaveBeenCalledWith(
|
||||||
|
VINPIN_COORDS.renaultRpartstore.x,
|
||||||
|
VINPIN_COORDS.renaultRpartstore.y,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VinpinDriverService — closeRpartstoreTab never hits the language selector (change 3)", () => {
|
||||||
|
const savedEnv = { ...process.env };
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.VINPIN_ENABLED = "true";
|
||||||
|
mockOcr.mockReset();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.env = { ...savedEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = (driver: VinpinDriverService, page: unknown) =>
|
||||||
|
((driver as unknown as AnyDriver).closeRpartstoreTab as (p: unknown) => Promise<void>).call(
|
||||||
|
driver,
|
||||||
|
page,
|
||||||
|
);
|
||||||
|
|
||||||
|
it("uses the tab-✕ (93,45) + Escape and NEVER clicks windowClose (1298,14) on a stuck spinner", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const page = fakePage();
|
||||||
|
// Chrome/spinner only (no search-home content) → the second close must NOT fire.
|
||||||
|
mockOcr.mockResolvedValue("Renault Rpartstore");
|
||||||
|
|
||||||
|
await close(driver, page);
|
||||||
|
|
||||||
|
expect(page.mouse.click).toHaveBeenCalledWith(
|
||||||
|
VINPIN_COORDS.catalogTabClose.x,
|
||||||
|
VINPIN_COORDS.catalogTabClose.y,
|
||||||
|
);
|
||||||
|
expect(page.mouse.click).not.toHaveBeenCalledWith(
|
||||||
|
VINPIN_COORDS.windowClose.x,
|
||||||
|
VINPIN_COORDS.windowClose.y,
|
||||||
|
);
|
||||||
|
expect(page.keyboard.press).toHaveBeenCalledWith("Escape");
|
||||||
|
// Exactly ONE close click (no content → no retry).
|
||||||
|
const closeClicks = page.mouse.click.mock.calls.filter(
|
||||||
|
([x, y]) => x === VINPIN_COORDS.catalogTabClose.x && y === VINPIN_COORDS.catalogTabClose.y,
|
||||||
|
);
|
||||||
|
expect(closeClicks).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries the tab-✕ (NOT windowClose) when a LOADED home is still up after the first close", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const page = fakePage();
|
||||||
|
mockOcr.mockResolvedValue("Şasi no ile arama Güncel araçlar"); // loaded home persists
|
||||||
|
|
||||||
|
await close(driver, page);
|
||||||
|
|
||||||
|
const closeClicks = page.mouse.click.mock.calls.filter(
|
||||||
|
([x, y]) => x === VINPIN_COORDS.catalogTabClose.x && y === VINPIN_COORDS.catalogTabClose.y,
|
||||||
|
);
|
||||||
|
expect(closeClicks).toHaveLength(2); // first + one content-gated retry
|
||||||
|
expect(page.mouse.click).not.toHaveBeenCalledWith(
|
||||||
|
VINPIN_COORDS.windowClose.x,
|
||||||
|
VINPIN_COORDS.windowClose.y,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VinpinDriverService — Rpartstore-down cooldown routes Renault → Dialogys (change 6)", () => {
|
||||||
|
const savedEnv = { ...process.env };
|
||||||
|
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.VINPIN_ENABLED = "true";
|
||||||
|
process.env.VINPIN_USER = "user";
|
||||||
|
process.env.VINPIN_PASS = "pass";
|
||||||
|
mockOcr.mockReset();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.env = { ...savedEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
const inCooldown = (driver: VinpinDriverService) =>
|
||||||
|
((driver as unknown as AnyDriver).rpartstoreInCooldown as () => boolean).call(driver);
|
||||||
|
|
||||||
|
const acquire = (driver: VinpinDriverService, page: unknown, warm: boolean) =>
|
||||||
|
(
|
||||||
|
(driver as unknown as AnyDriver).acquireLoadedRpartstore as (
|
||||||
|
p: unknown,
|
||||||
|
c: unknown,
|
||||||
|
d: number,
|
||||||
|
w: boolean,
|
||||||
|
) => Promise<boolean>
|
||||||
|
).call(driver, page, {}, FAR_DEADLINE(), warm);
|
||||||
|
|
||||||
|
it("while in cooldown, warm Renault decode SKIPS Rpartstore entirely and uses Dialogys", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
any.rpartstoreCooldownUntil = Date.now() + 60_000; // cooldown active
|
||||||
|
const acquireSpy = vi.spyOn(any as never, "acquireLoadedRpartstore");
|
||||||
|
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
|
||||||
|
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
|
||||||
|
vi.spyOn(any as never, "runDialogysSearch").mockResolvedValue({
|
||||||
|
status: "found",
|
||||||
|
parsed: { brand: "RENAULT", model: "MEGANE", modelYear: null },
|
||||||
|
rawText: "Megane",
|
||||||
|
via: "dialogys",
|
||||||
|
} as never);
|
||||||
|
const page = { keyboard: { press: vi.fn(async () => undefined) } };
|
||||||
|
|
||||||
|
const result = await (
|
||||||
|
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
|
||||||
|
).call(driver, page, "VF1LM1B0A37829019", { rpartstoreEnabled: true }, FAR_DEADLINE());
|
||||||
|
|
||||||
|
expect(acquireSpy).not.toHaveBeenCalled(); // Rpartstore never opened during the outage
|
||||||
|
expect(runRpartstore).not.toHaveBeenCalled();
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
brand: "RENAULT",
|
||||||
|
model: "MEGANE",
|
||||||
|
raw: { source: "vinpin-dialogys" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a hard launch-error SETS the cooldown", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
||||||
|
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
|
||||||
|
loaded: false,
|
||||||
|
launchError: true,
|
||||||
|
text: "Owwu6ka 3anycka KaTanora",
|
||||||
|
} as never);
|
||||||
|
const page = fakePage();
|
||||||
|
|
||||||
|
expect(inCooldown(driver)).toBe(false);
|
||||||
|
expect(await acquire(driver, page, true)).toBe(false);
|
||||||
|
expect(inCooldown(driver)).toBe(true); // → next Renault VIN routes straight to Dialogys
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a confirmed Rpartstore load CLEARS an active cooldown", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
any.rpartstoreCooldownUntil = Date.now() + 60_000; // pretend it was marked down
|
||||||
|
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
||||||
|
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
|
||||||
|
loaded: true,
|
||||||
|
launchError: false,
|
||||||
|
text: "Şasi no ile arama",
|
||||||
|
} as never);
|
||||||
|
const page = fakePage();
|
||||||
|
|
||||||
|
expect(inCooldown(driver)).toBe(true);
|
||||||
|
expect(await acquire(driver, page, true)).toBe(true);
|
||||||
|
expect(inCooldown(driver)).toBe(false); // healthy again → Rpartstore resumes as primary
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a transient born-stuck-spinner streak (maxOpens exhausted) does NOT set the cooldown", async () => {
|
||||||
|
// A born-stuck spinner is a TRANSIENT reopen-recoverable blip, not a server
|
||||||
|
// outage — exhausting maxOpens must fall back to Dialogys for THIS vin only and
|
||||||
|
// leave Rpartstore healthy for the next one, NOT suppress it for the full
|
||||||
|
// cooldown (which can't self-clear while it's being skipped).
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
||||||
|
vi.spyOn(any as never, "reopenFreshRpartstore").mockResolvedValue(true as never);
|
||||||
|
vi.spyOn(any as never, "closeRpartstoreTab").mockResolvedValue(undefined as never);
|
||||||
|
// Every poll = still spinning (never loaded, no launch error) → born-stuck path.
|
||||||
|
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
|
||||||
|
loaded: false,
|
||||||
|
launchError: false,
|
||||||
|
text: "",
|
||||||
|
} as never);
|
||||||
|
const page = fakePage();
|
||||||
|
|
||||||
|
expect(inCooldown(driver)).toBe(false);
|
||||||
|
expect(await acquire(driver, page, true)).toBe(false); // gave up → Dialogys
|
||||||
|
expect(inCooldown(driver)).toBe(false); // but NOT cooled down → next VIN retries Rpartstore
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ambiguous-retry cheap in-session reset (the dirty-resume relaunch-thrash fix).
|
||||||
|
* An AMBIGUOUS Renault outcome is transient/state-dependent, so the retry must NOT
|
||||||
|
* tear the browser down (a full teardown forces a ~60–90s relaunch + re-login that
|
||||||
|
* re-hits the seat's "catalog window resumed open" dirty-resume every attempt →
|
||||||
|
* 3 attempts blow the 180s budget → not_found). Instead it returns to a clean
|
||||||
|
* VinPower brand grid on the SAME live session via ensureBrandGrid (which runs the
|
||||||
|
* closeStrayRunningApps DOM recovery first), and re-runs runRenaultFlow — no
|
||||||
|
* close()/relaunch. GRADUATED safety: only when that cheap reset can't reach a clean
|
||||||
|
* grid does it fall back to close() + cold re-establish. OCR is mocked so the grid
|
||||||
|
* confirmation is controllable.
|
||||||
|
*/
|
||||||
|
describe("VinpinDriverService — ambiguous Renault retry is a CHEAP in-session grid reset (no relaunch)", () => {
|
||||||
|
const savedEnv = { ...process.env };
|
||||||
|
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
|
||||||
|
const RENAULT_VIN = "VF1RFE00653633190"; // the live-trace VIN that thrashed on relaunch
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.VINPIN_ENABLED = "true";
|
||||||
|
process.env.VINPIN_USER = "user";
|
||||||
|
process.env.VINPIN_PASS = "pass";
|
||||||
|
mockOcr.mockReset();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.env = { ...savedEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Present the driver as a live, authenticated session so the cheap reset's
|
||||||
|
// precheck (browser connected + context + open page + authed) passes and it never
|
||||||
|
// short-circuits to a cold re-establish.
|
||||||
|
function liveSession(driver: VinpinDriverService): AnyDriver {
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
any.browser = { isConnected: () => true };
|
||||||
|
any.context = {};
|
||||||
|
any.page = fakePage();
|
||||||
|
any.authed = true;
|
||||||
|
return any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodeRenault = (driver: VinpinDriverService, vin: string) =>
|
||||||
|
(
|
||||||
|
(driver as unknown as AnyDriver).decodeRenaultLocked as (
|
||||||
|
v: string,
|
||||||
|
c: unknown,
|
||||||
|
d: number,
|
||||||
|
) => Promise<unknown>
|
||||||
|
).call(driver, vin, { maxAttempts: 3, budgetMs: 5 * 60_000 }, FAR_DEADLINE());
|
||||||
|
|
||||||
|
it("first ambiguous retry returns to the grid IN-SESSION (ensureBrandGrid) and re-runs runRenaultFlow WITHOUT close()/relaunch", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = liveSession(driver);
|
||||||
|
|
||||||
|
// ensureAuthenticated is a no-op (session already up → NO relaunch, NO web login).
|
||||||
|
vi.spyOn(any as never, "ensureAuthenticated").mockResolvedValue(undefined as never);
|
||||||
|
// The cheap reset reaches a clean grid: ensureBrandGrid runs on the SAME session
|
||||||
|
// and the OCR read confirms the brand grid.
|
||||||
|
const ensureGrid = vi
|
||||||
|
.spyOn(any as never, "ensureBrandGrid")
|
||||||
|
.mockResolvedValue(undefined as never);
|
||||||
|
mockOcr.mockResolvedValue("Volkswagen Mitsubishi TecDoc"); // matches VINPIN_OCR.brandGrid
|
||||||
|
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
|
||||||
|
// attempt 1 → ambiguous (transient); attempt 2, same live session → found.
|
||||||
|
const runFlow = vi
|
||||||
|
.spyOn(any as never, "runRenaultFlow")
|
||||||
|
.mockResolvedValueOnce({ status: "ambiguous" } as never)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: "found",
|
||||||
|
parsed: { brand: "RENAULT", model: "KADJAR", modelYear: null },
|
||||||
|
rawText: "RENAULT Kadjar",
|
||||||
|
via: "dialogys",
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const result = await decodeRenault(driver, RENAULT_VIN);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ brand: "RENAULT", model: "KADJAR" });
|
||||||
|
expect(runFlow).toHaveBeenCalledTimes(2); // ambiguous, then a cheap in-session re-run
|
||||||
|
expect(ensureGrid).toHaveBeenCalled(); // the in-session grid reset ran (closeStrayRunningApps path)
|
||||||
|
expect(close).not.toHaveBeenCalled(); // NO teardown/relaunch on the cheap first retry
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GRADUATED fallback: when the cheap grid reset can't confirm a clean grid, it close()s + cold re-establishes", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = liveSession(driver);
|
||||||
|
|
||||||
|
vi.spyOn(any as never, "ensureAuthenticated").mockResolvedValue(undefined as never);
|
||||||
|
// ensureBrandGrid runs but the grid is NEVER confirmed (OCR reads no grid) → the
|
||||||
|
// cheap reset returns false → the graduated hard reset (close) must fire.
|
||||||
|
const ensureGrid = vi
|
||||||
|
.spyOn(any as never, "ensureBrandGrid")
|
||||||
|
.mockResolvedValue(undefined as never);
|
||||||
|
mockOcr.mockResolvedValue(""); // no brand grid → cheap reset fails
|
||||||
|
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
|
||||||
|
const runFlow = vi
|
||||||
|
.spyOn(any as never, "runRenaultFlow")
|
||||||
|
.mockResolvedValueOnce({ status: "ambiguous" } as never)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: "found",
|
||||||
|
parsed: { brand: "RENAULT", model: "MEGANE", modelYear: null },
|
||||||
|
rawText: "Megane",
|
||||||
|
via: "dialogys",
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const result = await decodeRenault(driver, RENAULT_VIN);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ brand: "RENAULT", model: "MEGANE" });
|
||||||
|
expect(ensureGrid).toHaveBeenCalled(); // the CHEAP reset was attempted first…
|
||||||
|
expect(close).toHaveBeenCalledTimes(1); // …then the graduated hard reset fired exactly once
|
||||||
|
expect(runFlow).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT cheap-reset when the session is already gone — falls straight to close() (graduated)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = liveSession(driver);
|
||||||
|
// Session looks broken: the page is closed → the cheap reset's precheck fails and
|
||||||
|
// it returns false without touching ensureBrandGrid, so the hard reset runs.
|
||||||
|
(any.page as { isClosed: () => boolean }).isClosed = () => true;
|
||||||
|
|
||||||
|
vi.spyOn(any as never, "ensureAuthenticated").mockResolvedValue(undefined as never);
|
||||||
|
const ensureGrid = vi
|
||||||
|
.spyOn(any as never, "ensureBrandGrid")
|
||||||
|
.mockResolvedValue(undefined as never);
|
||||||
|
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
|
||||||
|
vi.spyOn(any as never, "runRenaultFlow")
|
||||||
|
.mockResolvedValueOnce({ status: "ambiguous" } as never)
|
||||||
|
.mockResolvedValueOnce({ status: "not_found" } as never);
|
||||||
|
|
||||||
|
const result = await decodeRenault(driver, RENAULT_VIN);
|
||||||
|
|
||||||
|
expect(result).toBeNull(); // 2nd attempt not_found → null
|
||||||
|
expect(ensureGrid).not.toHaveBeenCalled(); // broken session → no in-session grid work
|
||||||
|
expect(close).toHaveBeenCalledTimes(1); // graduated hard reset on the broken session
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* runRenaultFlow definitive-not_found gate (the budget-burn + seat-poison fix).
|
||||||
|
* When Rpartstore is UNAVAILABLE (down-cooldown, or it never loaded so `primary`
|
||||||
|
* is only the placeholder ambiguous), a CLEAN Dialogys not_found is DEFINITIVE on
|
||||||
|
* its own — the old gate required BOTH catalogs to say not_found, so every
|
||||||
|
* undecodable Renault while Rpartstore was down got downgraded to ambiguous →
|
||||||
|
* retry loop → 180s budget → sessionPoisoned. A genuinely-ambiguous Dialogys
|
||||||
|
* (couldn't be reached) still returns ambiguous so the transient-failure retry
|
||||||
|
* survives. When Rpartstore actually RAN, the both-must-agree gate is preserved.
|
||||||
|
*/
|
||||||
|
describe("VinpinDriverService — runRenaultFlow definitive not_found when Rpartstore unavailable", () => {
|
||||||
|
const savedEnv = { ...process.env };
|
||||||
|
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
|
||||||
|
const VIN = "VF1553K05TR596166"; // old (~1996) Renault 19 — the live budget-burn case
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.VINPIN_ENABLED = "true";
|
||||||
|
mockOcr.mockReset();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.env = { ...savedEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
const runFlow = (driver: VinpinDriverService, page: unknown) =>
|
||||||
|
(
|
||||||
|
(driver as unknown as AnyDriver).runRenaultFlow as (
|
||||||
|
p: unknown,
|
||||||
|
v: string,
|
||||||
|
c: unknown,
|
||||||
|
d: number,
|
||||||
|
) => Promise<{ status: string }>
|
||||||
|
).call(driver, page, VIN, { rpartstoreEnabled: true }, FAR_DEADLINE());
|
||||||
|
|
||||||
|
it("cooldown (Rpartstore down) + Dialogys not_found → DEFINITIVE not_found, no Rpartstore, no retry", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
any.rpartstoreCooldownUntil = Date.now() + 60_000; // Rpartstore in down-cooldown
|
||||||
|
const acquire = vi.spyOn(any as never, "acquireLoadedRpartstore");
|
||||||
|
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
|
||||||
|
const runDialogys = vi
|
||||||
|
.spyOn(any as never, "runDialogys")
|
||||||
|
.mockResolvedValue({ status: "not_found" } as never);
|
||||||
|
|
||||||
|
const outcome = await runFlow(driver, fakePage());
|
||||||
|
|
||||||
|
expect(outcome.status).toBe("not_found"); // definitive — no ambiguous downgrade
|
||||||
|
expect(acquire).not.toHaveBeenCalled(); // Rpartstore skipped during the outage
|
||||||
|
expect(runRpartstore).not.toHaveBeenCalled();
|
||||||
|
expect(runDialogys).toHaveBeenCalledTimes(1); // single shot — no budget-burn retry
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Rpartstore never loaded (not cooldown) + Dialogys not_found → DEFINITIVE not_found", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
// Not in cooldown, but the acquire fails to load Rpartstore → primary never ran.
|
||||||
|
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(false as never);
|
||||||
|
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
|
||||||
|
vi.spyOn(any as never, "runDialogys").mockResolvedValue({ status: "not_found" } as never);
|
||||||
|
|
||||||
|
const outcome = await runFlow(driver, fakePage());
|
||||||
|
|
||||||
|
expect(outcome.status).toBe("not_found");
|
||||||
|
expect(runRpartstore).not.toHaveBeenCalled(); // acquire failed → primary placeholder only
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Rpartstore unavailable + Dialogys AMBIGUOUS (unreachable) → still ambiguous (transient retry survives)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
any.rpartstoreCooldownUntil = Date.now() + 60_000; // down-cooldown
|
||||||
|
vi.spyOn(any as never, "runDialogys").mockResolvedValue({ status: "ambiguous" } as never);
|
||||||
|
|
||||||
|
const outcome = await runFlow(driver, fakePage());
|
||||||
|
|
||||||
|
expect(outcome.status).toBe("ambiguous"); // couldn't confirm not_found → caller retries
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REGRESSION: Rpartstore RAN + ambiguous, Dialogys not_found → still ambiguous (both-must-agree preserved)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
// Rpartstore is UP: it loads and runs but is unsure (ambiguous). Dialogys then
|
||||||
|
// cleanly misses. Because Rpartstore actually ran, we must NOT treat this as a
|
||||||
|
// definitive not_found — the pre-fix both-must-agree behaviour is preserved.
|
||||||
|
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(true as never);
|
||||||
|
vi.spyOn(any as never, "runRpartstore").mockResolvedValue({ status: "ambiguous" } as never);
|
||||||
|
vi.spyOn(any as never, "runDialogys").mockResolvedValue({ status: "not_found" } as never);
|
||||||
|
|
||||||
|
const outcome = await runFlow(driver, fakePage());
|
||||||
|
|
||||||
|
expect(outcome.status).toBe("ambiguous");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REGRESSION: Rpartstore RAN + not_found, Dialogys not_found → definitive not_found (unchanged)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(true as never);
|
||||||
|
vi.spyOn(any as never, "runRpartstore").mockResolvedValue({ status: "not_found" } as never);
|
||||||
|
vi.spyOn(any as never, "runDialogys").mockResolvedValue({ status: "not_found" } as never);
|
||||||
|
|
||||||
|
const outcome = await runFlow(driver, fakePage());
|
||||||
|
|
||||||
|
expect(outcome.status).toBe("not_found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VINPIN_RPARTSTORE_ENABLED=false kill-switch (KNOWN Rpartstore outage). Unlike the
|
||||||
|
* in-memory down-cooldown (which resets on every worker restart, so the first decode
|
||||||
|
* after each restart pays the full Rpartstore launch cost + leaves a stuck app), this
|
||||||
|
* config flag persists: when set, BOTH Renault paths (warm + cold) skip opening
|
||||||
|
* Rpartstore entirely and go straight to Dialogys, and a clean Dialogys not_found is
|
||||||
|
* DEFINITIVE (a flag-disabled Rpartstore is "unavailable" exactly like the cooldown).
|
||||||
|
* Default (unset / not "false") is TRUE — behaviour completely unchanged.
|
||||||
|
*/
|
||||||
|
describe("VinpinDriverService — VINPIN_RPARTSTORE_ENABLED=false skips Rpartstore in both Renault paths", () => {
|
||||||
|
const savedEnv = { ...process.env };
|
||||||
|
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.VINPIN_ENABLED = "true";
|
||||||
|
mockOcr.mockReset();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.env = { ...savedEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
it("COLD runRenaultFlow (flag=false): never opens Rpartstore, goes straight to Dialogys, and a Dialogys not_found is DEFINITIVE (no retry)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
// NOT in cooldown — the ONLY reason to skip is the disabled flag.
|
||||||
|
const acquire = vi.spyOn(any as never, "acquireLoadedRpartstore");
|
||||||
|
const ensureR = vi.spyOn(any as never, "ensureRpartstore");
|
||||||
|
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
|
||||||
|
const runDialogys = vi
|
||||||
|
.spyOn(any as never, "runDialogys")
|
||||||
|
.mockResolvedValue({ status: "not_found" } as never);
|
||||||
|
|
||||||
|
const outcome = await (
|
||||||
|
(any as AnyDriver).runRenaultFlow as (
|
||||||
|
p: unknown,
|
||||||
|
v: string,
|
||||||
|
c: unknown,
|
||||||
|
d: number,
|
||||||
|
) => Promise<{ status: string }>
|
||||||
|
).call(driver, fakePage(), "VF1553K05TR596166", { rpartstoreEnabled: false }, FAR_DEADLINE());
|
||||||
|
|
||||||
|
expect(acquire).not.toHaveBeenCalled(); // Rpartstore never opened
|
||||||
|
expect(ensureR).not.toHaveBeenCalled();
|
||||||
|
expect(runRpartstore).not.toHaveBeenCalled();
|
||||||
|
expect(runDialogys).toHaveBeenCalledTimes(1); // single shot — no budget-burn retry
|
||||||
|
expect(outcome.status).toBe("not_found"); // definitive — no ambiguous downgrade
|
||||||
|
});
|
||||||
|
|
||||||
|
it("WARM warmRenaultDecode (flag=false): never opens Rpartstore, decodes via Dialogys", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
const acquire = vi.spyOn(any as never, "acquireLoadedRpartstore");
|
||||||
|
const ensureR = vi.spyOn(any as never, "ensureRpartstore");
|
||||||
|
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
|
||||||
|
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
|
||||||
|
vi.spyOn(any as never, "runDialogysSearch").mockResolvedValue({
|
||||||
|
status: "found",
|
||||||
|
parsed: { brand: "RENAULT", model: "MEGANE", modelYear: null },
|
||||||
|
rawText: "Megane",
|
||||||
|
via: "dialogys",
|
||||||
|
} as never);
|
||||||
|
const page = { keyboard: { press: vi.fn(async () => undefined) } };
|
||||||
|
|
||||||
|
const result = await (
|
||||||
|
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
|
||||||
|
).call(driver, page, "VF1LM1B0A37829019", { rpartstoreEnabled: false }, FAR_DEADLINE());
|
||||||
|
|
||||||
|
expect(acquire).not.toHaveBeenCalled(); // Rpartstore never opened
|
||||||
|
expect(ensureR).not.toHaveBeenCalled();
|
||||||
|
expect(runRpartstore).not.toHaveBeenCalled();
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
brand: "RENAULT",
|
||||||
|
model: "MEGANE",
|
||||||
|
raw: { source: "vinpin-dialogys" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DEFAULT (flag=true): COLD path still ATTEMPTS Rpartstore (behaviour unchanged)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
const acquire = vi
|
||||||
|
.spyOn(any as never, "acquireLoadedRpartstore")
|
||||||
|
.mockResolvedValue(true as never);
|
||||||
|
vi.spyOn(any as never, "runRpartstore").mockResolvedValue({
|
||||||
|
status: "found",
|
||||||
|
parsed: { brand: "RENAULT", model: "CLIO", modelYear: null },
|
||||||
|
rawText: "Clio",
|
||||||
|
via: "rpartstore",
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const outcome = await (
|
||||||
|
(any as AnyDriver).runRenaultFlow as (
|
||||||
|
p: unknown,
|
||||||
|
v: string,
|
||||||
|
c: unknown,
|
||||||
|
d: number,
|
||||||
|
) => Promise<{ status: string }>
|
||||||
|
).call(driver, fakePage(), "VF1553K05TR596166", { rpartstoreEnabled: true }, FAR_DEADLINE());
|
||||||
|
|
||||||
|
expect(acquire).toHaveBeenCalledTimes(1); // Rpartstore still primary by default
|
||||||
|
expect(outcome.status).toBe("found");
|
||||||
|
});
|
||||||
|
});
|
||||||
707
apps/api/src/integrations/vinpin/vinpin-driver.service.spec.ts
Normal file
707
apps/api/src/integrations/vinpin/vinpin-driver.service.spec.ts
Normal file
@@ -0,0 +1,707 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { VinpinDriverService, VinpinSessionDroppedError } from "./vinpin-driver.service";
|
||||||
|
import { VINPIN_COORDS, VINPIN_OCR, VINPIN_WINDOW_FOREGROUND } from "./vinpin.constants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Livelock-breaker unit tests for the Vinpin decode driver. These exercise the
|
||||||
|
* failure/abort machinery (wall-clock budget, sessionPoisoned cold re-establish,
|
||||||
|
* VIN sanity gate) in isolation by stubbing the browser-driving privates — the
|
||||||
|
* shared Vinpin seat lives on prod and can't be driven from a test.
|
||||||
|
*/
|
||||||
|
describe("VinpinDriverService — livelock breakers", () => {
|
||||||
|
const savedEnv = { ...process.env };
|
||||||
|
const VALID_FIAT_VIN = "NM435600006H43436"; // 17 alphanumerics → fiat flow
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// afterEach restores the full env snapshot, so VINPIN_DECODE_BUDGET_MS set by
|
||||||
|
// the budget test never leaks into the others.
|
||||||
|
process.env.VINPIN_ENABLED = "true";
|
||||||
|
process.env.VINPIN_USER = "user";
|
||||||
|
process.env.VINPIN_PASS = "pass";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.env = { ...savedEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts a single decode that blows past the wall-clock budget, tears down and poisons the seat", async () => {
|
||||||
|
process.env.VINPIN_DECODE_BUDGET_MS = "50";
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const anyDriver = driver as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
|
const closeSpy = vi.spyOn(anyDriver as never, "close").mockResolvedValue(undefined as never);
|
||||||
|
// ensureReady succeeds cheaply, then runVinFlow hangs forever → the budget
|
||||||
|
// timer must win the race.
|
||||||
|
vi.spyOn(anyDriver as never, "ensureReady").mockImplementation((async () => {
|
||||||
|
anyDriver.page = { isClosed: () => false };
|
||||||
|
}) as never);
|
||||||
|
vi.spyOn(anyDriver as never, "runVinFlow").mockReturnValue(
|
||||||
|
new Promise(() => {}) as never, // never resolves
|
||||||
|
);
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
const result = await driver.decode(VALID_FIAT_VIN);
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(closeSpy).toHaveBeenCalled(); // torn down on abort
|
||||||
|
expect(anyDriver.sessionPoisoned).toBe(true); // seat marked poisoned
|
||||||
|
// Aborted near the budget, not after minutes / all 3 internal attempts.
|
||||||
|
expect(elapsed).toBeLessThan(2_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forces a full cold re-establish (close) when the seat was poisoned by a prior decode", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const anyDriver = driver as unknown as Record<string, unknown>;
|
||||||
|
anyDriver.sessionPoisoned = true;
|
||||||
|
|
||||||
|
const closeSpy = vi.spyOn(anyDriver as never, "close").mockResolvedValue(undefined as never);
|
||||||
|
const fiatSpy = vi
|
||||||
|
.spyOn(anyDriver as never, "decodeFiatLocked")
|
||||||
|
.mockResolvedValue({ brand: "Fiat", model: "EGEA" } as never);
|
||||||
|
|
||||||
|
const result = await driver.decode(VALID_FIAT_VIN);
|
||||||
|
|
||||||
|
expect(closeSpy).toHaveBeenCalledTimes(1); // the poison-forced teardown
|
||||||
|
expect(anyDriver.sessionPoisoned).toBe(false); // flag consumed
|
||||||
|
expect(fiatSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT tear down a healthy warm session when the seat is not poisoned", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const anyDriver = driver as unknown as Record<string, unknown>;
|
||||||
|
anyDriver.sessionPoisoned = false;
|
||||||
|
|
||||||
|
const closeSpy = vi.spyOn(anyDriver as never, "close").mockResolvedValue(undefined as never);
|
||||||
|
vi.spyOn(anyDriver as never, "decodeFiatLocked").mockResolvedValue({
|
||||||
|
brand: "Fiat",
|
||||||
|
model: "EGEA",
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const result = await driver.decode(VALID_FIAT_VIN);
|
||||||
|
|
||||||
|
expect(closeSpy).not.toHaveBeenCalled(); // healthy path untouched
|
||||||
|
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips obviously-junk input before it ever touches the shared seat", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const anyDriver = driver as unknown as Record<string, unknown>;
|
||||||
|
const fiatSpy = vi.spyOn(anyDriver as never, "decodeFiatLocked");
|
||||||
|
|
||||||
|
expect(await driver.decode("SHORT")).toBeNull();
|
||||||
|
expect(await driver.decode("VF1RFE0065363319")).toBeNull(); // 16 chars
|
||||||
|
expect(await driver.decode("NM4356 0006H43436")).toBeNull(); // space (non-alnum)
|
||||||
|
expect(fiatSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still runs a well-formed VIN through the decode loop (sanity gate is not over-filtering)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const anyDriver = driver as unknown as Record<string, unknown>;
|
||||||
|
const fiatSpy = vi
|
||||||
|
.spyOn(anyDriver as never, "decodeFiatLocked")
|
||||||
|
.mockResolvedValue(null as never);
|
||||||
|
|
||||||
|
expect(await driver.decode(VALID_FIAT_VIN)).toBeNull();
|
||||||
|
expect(fiatSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warm-session daemon behaviours on the driver: warm-vs-cold routing, the
|
||||||
|
* keepalive that must SKIP while a decode holds the seat, and health-recovery
|
||||||
|
* (session-drop → teardown + re-warm + single retry). The browser-driving privates
|
||||||
|
* are stubbed — the real seat lives on prod.
|
||||||
|
*/
|
||||||
|
describe("VinpinDriverService — warm session", () => {
|
||||||
|
const savedEnv = { ...process.env };
|
||||||
|
const VALID_FIAT_VIN = "NM435600006H43436";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.VINPIN_ENABLED = "true";
|
||||||
|
process.env.VINPIN_USER = "user";
|
||||||
|
process.env.VINPIN_PASS = "pass";
|
||||||
|
process.env.VINPIN_WARM_DAEMON = "true";
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.env = { ...savedEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeWarm(driver: VinpinDriverService) {
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
any.warm = true;
|
||||||
|
any.browser = { isConnected: () => true };
|
||||||
|
any.page = { isClosed: () => false, mouse: { move: vi.fn(async () => undefined) } };
|
||||||
|
return any;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("routes a decode to the WARM path when a healthy warm session is up", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = makeWarm(driver);
|
||||||
|
const warmSpy = vi
|
||||||
|
.spyOn(any as never, "warmDecodeWithRecovery")
|
||||||
|
.mockResolvedValue({ brand: "Fiat", model: "EGEA" } as never);
|
||||||
|
const coldSpy = vi.spyOn(any as never, "decodeFiatLocked");
|
||||||
|
|
||||||
|
const result = await driver.decode(VALID_FIAT_VIN);
|
||||||
|
|
||||||
|
expect(warmSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(coldSpy).not.toHaveBeenCalled(); // cold path bypassed while warm
|
||||||
|
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the COLD path when VINPIN_WARM_DAEMON=false even if warm is set", async () => {
|
||||||
|
process.env.VINPIN_WARM_DAEMON = "false";
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = makeWarm(driver);
|
||||||
|
const warmSpy = vi.spyOn(any as never, "warmDecodeWithRecovery");
|
||||||
|
const coldSpy = vi
|
||||||
|
.spyOn(any as never, "decodeFiatLocked")
|
||||||
|
.mockResolvedValue({ brand: "Fiat", model: "EGEA" } as never);
|
||||||
|
|
||||||
|
await driver.decode(VALID_FIAT_VIN);
|
||||||
|
|
||||||
|
expect(warmSpy).not.toHaveBeenCalled();
|
||||||
|
expect(coldSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keepalive SKIPS while a decode holds the seat (busy), and nudges when idle", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = makeWarm(driver);
|
||||||
|
const move = (any.page as { mouse: { move: ReturnType<typeof vi.fn> } }).mouse.move;
|
||||||
|
|
||||||
|
// Busy (a decode is in flight) → no nudge, no lock contention.
|
||||||
|
any.busy = true;
|
||||||
|
await driver.keepalivePing();
|
||||||
|
expect(move).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Idle → the keepalive nudges the harmless in-window point.
|
||||||
|
any.busy = false;
|
||||||
|
await driver.keepalivePing();
|
||||||
|
expect(move).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on a dropped warm session, tears down + re-warms ONCE, then retries the decode once", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = makeWarm(driver);
|
||||||
|
|
||||||
|
const warmDecode = vi
|
||||||
|
.spyOn(any as never, "warmDecode")
|
||||||
|
.mockRejectedValueOnce(new VinpinSessionDroppedError("dropped") as never)
|
||||||
|
.mockResolvedValueOnce({ brand: "Fiat", model: "EGEA" } as never);
|
||||||
|
const rewarm = vi.spyOn(any as never, "_warmUp").mockResolvedValue(true as never);
|
||||||
|
|
||||||
|
const result = await driver.decode(VALID_FIAT_VIN);
|
||||||
|
|
||||||
|
expect(warmDecode).toHaveBeenCalledTimes(2); // initial + one retry
|
||||||
|
expect(rewarm).toHaveBeenCalledTimes(1); // re-warmed exactly once
|
||||||
|
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("session-drop recovery gives up (null) when the re-warm fails — never throws", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = makeWarm(driver);
|
||||||
|
|
||||||
|
vi.spyOn(any as never, "warmDecode").mockRejectedValue(
|
||||||
|
new VinpinSessionDroppedError("dropped") as never,
|
||||||
|
);
|
||||||
|
vi.spyOn(any as never, "_warmUp").mockResolvedValue(false as never);
|
||||||
|
|
||||||
|
const result = await driver.decode(VALID_FIAT_VIN);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(any.warm).toBe(false); // dropped seat marked not-warm → cold path next time
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rpartstore acquire-with-spinner-guard. A freshly-opened Rpartstore instance
|
||||||
|
* sometimes gets "born stuck" on an infinite spinner; the fix is to close the
|
||||||
|
* stuck instance and reopen a FRESH one, up to N times, inside the wall-clock
|
||||||
|
* budget, and only then fall back to Dialogys. These stub the browser-driving
|
||||||
|
* privates (real seat lives on prod) and drive the OCR-load verdict via a mocked
|
||||||
|
* pollRpartstoreState, which reads LOADED off the full frame and the HARD
|
||||||
|
* launch-error modal off an UPSCALED crop (spinner = {loaded:false,launchError:
|
||||||
|
* false}, loaded = {loaded:true}, DOWN = {launchError:true}).
|
||||||
|
*/
|
||||||
|
describe("VinpinDriverService — Rpartstore spinner guard", () => {
|
||||||
|
const savedEnv = { ...process.env };
|
||||||
|
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.VINPIN_ENABLED = "true";
|
||||||
|
process.env.VINPIN_USER = "user";
|
||||||
|
process.env.VINPIN_PASS = "pass";
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.env = { ...savedEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
function acquire(driver: VinpinDriverService, warm: boolean, deadline: number): Promise<boolean> {
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
any.acquireLoadedRpartstore as (
|
||||||
|
page: unknown,
|
||||||
|
cfg: unknown,
|
||||||
|
deadline: number,
|
||||||
|
warm: boolean,
|
||||||
|
) => Promise<boolean>
|
||||||
|
).call(driver, {}, {}, deadline, warm);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reuses Rpartstore when the FIRST open already loaded (no reopen, no close)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
||||||
|
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore");
|
||||||
|
const close = vi.spyOn(any as never, "closeRpartstoreTab");
|
||||||
|
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
|
||||||
|
loaded: true,
|
||||||
|
launchError: false,
|
||||||
|
text: "Şasi no ile arama",
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(await acquire(driver, true, FAR_DEADLINE())).toBe(true);
|
||||||
|
expect(reopen).not.toHaveBeenCalled();
|
||||||
|
expect(close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes a born-stuck spinner and reopens a FRESH instance that loads (warm)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
const raise = vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
||||||
|
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore").mockResolvedValue(true as never);
|
||||||
|
const close = vi
|
||||||
|
.spyOn(any as never, "closeRpartstoreTab")
|
||||||
|
.mockResolvedValue(undefined as never);
|
||||||
|
// open 1 → spinner (not loaded); open 2 (fresh) → loaded.
|
||||||
|
vi.spyOn(any as never, "pollRpartstoreState")
|
||||||
|
.mockResolvedValueOnce({ loaded: false, launchError: false, text: "spinner" } as never)
|
||||||
|
.mockResolvedValueOnce({ loaded: true, launchError: false, text: "Ne arıyorsunuz" } as never);
|
||||||
|
|
||||||
|
expect(await acquire(driver, true, FAR_DEADLINE())).toBe(true);
|
||||||
|
expect(raise).toHaveBeenCalledTimes(1); // only the first attempt raises
|
||||||
|
expect(close).toHaveBeenCalledTimes(1); // the stuck instance was closed
|
||||||
|
expect(reopen).toHaveBeenCalledTimes(1); // one fresh reopen, which loaded
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up (false) after maxOpens reopen attempts all spinner → caller falls to Dialogys", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
vi.spyOn(any as never, "ensureRpartstore").mockResolvedValue(true as never); // cold open path
|
||||||
|
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore").mockResolvedValue(true as never);
|
||||||
|
const close = vi
|
||||||
|
.spyOn(any as never, "closeRpartstoreTab")
|
||||||
|
.mockResolvedValue(undefined as never);
|
||||||
|
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
|
||||||
|
loaded: false,
|
||||||
|
launchError: false,
|
||||||
|
text: "spinner",
|
||||||
|
} as never); // never loads
|
||||||
|
|
||||||
|
expect(await acquire(driver, false, FAR_DEADLINE())).toBe(false);
|
||||||
|
// maxOpens = 3: open 1 (ensureRpartstore) + 2 reopens; a close after each miss.
|
||||||
|
expect(reopen).toHaveBeenCalledTimes(2);
|
||||||
|
expect(close).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bails immediately (false) without opening when the budget is already spent (no livelock)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
const raise = vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
||||||
|
const poll = vi.spyOn(any as never, "pollRpartstoreState");
|
||||||
|
|
||||||
|
expect(await acquire(driver, true, Date.now() - 1)).toBe(false);
|
||||||
|
expect(raise).not.toHaveBeenCalled();
|
||||||
|
expect(poll).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HARD launch error (Rpartstore DOWN) → returns false after ONE open, NO reopens, dismiss clicked → caller falls to Dialogys", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
const raise = vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
||||||
|
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore");
|
||||||
|
const close = vi.spyOn(any as never, "closeRpartstoreTab");
|
||||||
|
// Upscaled modal-crop OCR of "Ошибка запуска каталога" — the VERBATIM string
|
||||||
|
// captured live 2026-07-15 on seat trvinpin47828 (see rpartstoreLaunchError).
|
||||||
|
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
|
||||||
|
loaded: false,
|
||||||
|
launchError: true,
|
||||||
|
text: "Rpartstore X Owwu6ka 3anycka KaTanora",
|
||||||
|
} as never);
|
||||||
|
const page = {
|
||||||
|
mouse: { click: vi.fn(async () => undefined) },
|
||||||
|
waitForTimeout: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await (
|
||||||
|
any.acquireLoadedRpartstore as (
|
||||||
|
p: unknown,
|
||||||
|
c: unknown,
|
||||||
|
d: number,
|
||||||
|
w: boolean,
|
||||||
|
) => Promise<boolean>
|
||||||
|
).call(driver, page, {}, FAR_DEADLINE(), true);
|
||||||
|
|
||||||
|
expect(result).toBe(false); // → warmRenaultDecode/runRenaultFlow go straight to Dialogys
|
||||||
|
expect(raise).toHaveBeenCalledTimes(1); // opened exactly ONCE
|
||||||
|
expect(reopen).not.toHaveBeenCalled(); // NO reopens burned (~54s saved)
|
||||||
|
expect(close).not.toHaveBeenCalled(); // not treated as a spinner
|
||||||
|
// The launch-error modal's OK button was clicked to dismiss it.
|
||||||
|
expect(page.mouse.click).toHaveBeenCalledWith(
|
||||||
|
VINPIN_COORDS.rpartstoreLaunchErrorOk.x,
|
||||||
|
VINPIN_COORDS.rpartstoreLaunchErrorOk.y,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("genuine spinner (no launch-error string) STILL triggers the close+reopen loop (unchanged)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
||||||
|
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore").mockResolvedValue(true as never);
|
||||||
|
const close = vi
|
||||||
|
.spyOn(any as never, "closeRpartstoreTab")
|
||||||
|
.mockResolvedValue(undefined as never);
|
||||||
|
// Bare chrome, no content AND no launch-error → born-stuck spinner. Reopen loop.
|
||||||
|
vi.spyOn(any as never, "pollRpartstoreState")
|
||||||
|
.mockResolvedValueOnce({ loaded: false, launchError: false, text: "RPartStore" } as never)
|
||||||
|
.mockResolvedValueOnce({ loaded: true, launchError: false, text: "Ne arıyorsunuz" } as never);
|
||||||
|
|
||||||
|
expect(await acquire(driver, true, FAR_DEADLINE())).toBe(true);
|
||||||
|
expect(close).toHaveBeenCalledTimes(1); // stuck instance closed (spinner path intact)
|
||||||
|
expect(reopen).toHaveBeenCalledTimes(1); // one fresh reopen, which loaded
|
||||||
|
});
|
||||||
|
|
||||||
|
it("launch-error OCR pattern matches the real Cyrillic and its live-captured eng-OCR transliterations", () => {
|
||||||
|
expect(VINPIN_OCR.rpartstoreLaunchError.test("Ошибка запуска каталога")).toBe(true);
|
||||||
|
// VERBATIM upscaled-crop OCR captured live 2026-07-15 (seat trvinpin47828): the
|
||||||
|
// "Ошибка" head varies (Owwu6ka/OwwbKa) but "3anycka"+"KaTanora" are stable.
|
||||||
|
expect(VINPIN_OCR.rpartstoreLaunchError.test("Rpartstore X Owwu6ka 3anycka KaTanora")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(VINPIN_OCR.rpartstoreLaunchError.test("Volvo (X) OwwbKa 3anycka KaTanora VY")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(VINPIN_OCR.rpartstoreLaunchError.test("Owwnbka 3anycka KaTanora")).toBe(true);
|
||||||
|
// Must NOT fire on a healthy loaded Rpartstore home (no false short-circuit) —
|
||||||
|
// this is the exact full-frame text a LOADED home shows.
|
||||||
|
expect(VINPIN_OCR.rpartstoreLaunchError.test("Şasi no ile arama Güncel araçlar")).toBe(false);
|
||||||
|
// Nor on the brand grid alone (the tiles behind the modal, read on a 1× frame).
|
||||||
|
expect(
|
||||||
|
VINPIN_OCR.rpartstoreLaunchError.test("Audi Fiat Renault KIA SEAT Volkswagen Toyota"),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("launch error over the grid (ensureRpartstore couldn't confirm a window) → bail to Dialogys, NO reopens", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
// Cold path: ensureRpartstore returns false because the DOWN Rpartstore shows
|
||||||
|
// only the centered launch-error modal over the grid (no catalog chrome).
|
||||||
|
vi.spyOn(any as never, "ensureRpartstore").mockResolvedValue(false as never);
|
||||||
|
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore");
|
||||||
|
const close = vi.spyOn(any as never, "closeRpartstoreTab");
|
||||||
|
// The upscaled modal crop DOES read the launch error → dismiss + bail.
|
||||||
|
vi.spyOn(any as never, "rpartstoreLaunchErrorPresent").mockResolvedValue(true as never);
|
||||||
|
const page = {
|
||||||
|
mouse: { click: vi.fn(async () => undefined) },
|
||||||
|
waitForTimeout: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await (
|
||||||
|
any.acquireLoadedRpartstore as (
|
||||||
|
p: unknown,
|
||||||
|
c: unknown,
|
||||||
|
d: number,
|
||||||
|
w: boolean,
|
||||||
|
) => Promise<boolean>
|
||||||
|
).call(driver, page, {}, FAR_DEADLINE(), false);
|
||||||
|
|
||||||
|
expect(result).toBe(false); // → straight to Dialogys
|
||||||
|
expect(reopen).not.toHaveBeenCalled(); // no reopens burned
|
||||||
|
expect(close).not.toHaveBeenCalled();
|
||||||
|
expect(page.mouse.click).toHaveBeenCalledWith(
|
||||||
|
VINPIN_COORDS.rpartstoreLaunchErrorOk.x,
|
||||||
|
VINPIN_COORDS.rpartstoreLaunchErrorOk.y,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warm Renault decode: loaded Rpartstore is PRIMARY — Dialogys is not touched on a hit", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(true as never);
|
||||||
|
vi.spyOn(any as never, "runRpartstore").mockResolvedValue({
|
||||||
|
status: "found",
|
||||||
|
parsed: { brand: "RENAULT", model: "KADJAR", modelYear: null },
|
||||||
|
rawText: "RENAULT Kadjar",
|
||||||
|
via: "rpartstore",
|
||||||
|
} as never);
|
||||||
|
const dialogys = vi.spyOn(any as never, "ensureWarmWindow");
|
||||||
|
const page = { keyboard: { press: vi.fn(async () => undefined) } };
|
||||||
|
|
||||||
|
const result = await (
|
||||||
|
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
|
||||||
|
).call(driver, page, "VF1RFE00653633190", { rpartstoreEnabled: true }, FAR_DEADLINE());
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
brand: "RENAULT",
|
||||||
|
model: "KADJAR",
|
||||||
|
raw: { source: "vinpin-rpartstore" },
|
||||||
|
});
|
||||||
|
expect(dialogys).not.toHaveBeenCalled(); // Rpartstore hit → Dialogys never consulted
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warm Renault decode: exhausted Rpartstore → LAST-RESORT Dialogys fallback still decodes", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(false as never); // never loaded
|
||||||
|
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
|
||||||
|
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
|
||||||
|
vi.spyOn(any as never, "runDialogysSearch").mockResolvedValue({
|
||||||
|
status: "found",
|
||||||
|
parsed: { brand: "RENAULT", model: "MEGANE", modelYear: null },
|
||||||
|
rawText: "Megane II Classic",
|
||||||
|
via: "dialogys",
|
||||||
|
} as never);
|
||||||
|
const page = { keyboard: { press: vi.fn(async () => undefined) } };
|
||||||
|
|
||||||
|
const result = await (
|
||||||
|
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
|
||||||
|
).call(driver, page, "VF1LM1B0A37829019", { rpartstoreEnabled: true }, FAR_DEADLINE());
|
||||||
|
|
||||||
|
expect(runRpartstore).not.toHaveBeenCalled(); // never ran the search on a stuck Rpartstore
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
brand: "RENAULT",
|
||||||
|
model: "MEGANE",
|
||||||
|
raw: { source: "vinpin-dialogys" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("acquire give-up (budget-exhausted) DEFENSIVELY dismisses a leftover modal (Escape + launch-error OK) even when the OCR branch never fires", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
// Budget already spent → the inner acquire returns false at its first budget
|
||||||
|
// check WITHOUT ever hitting the OCR error-detect branch (raise/poll not called).
|
||||||
|
const raise = vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
||||||
|
const poll = vi.spyOn(any as never, "pollRpartstoreState");
|
||||||
|
const page = {
|
||||||
|
keyboard: { press: vi.fn(async () => undefined) },
|
||||||
|
mouse: { click: vi.fn(async () => undefined) },
|
||||||
|
waitForTimeout: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await (
|
||||||
|
any.acquireLoadedRpartstore as (
|
||||||
|
p: unknown,
|
||||||
|
c: unknown,
|
||||||
|
d: number,
|
||||||
|
w: boolean,
|
||||||
|
) => Promise<boolean>
|
||||||
|
).call(driver, page, {}, Date.now() - 1, true);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(raise).not.toHaveBeenCalled(); // budget already gone → never opened
|
||||||
|
expect(poll).not.toHaveBeenCalled();
|
||||||
|
// The wrapper's unconditional defensive clear ran on the false path: Escape +
|
||||||
|
// click the centered launch-error OK, so no modal is left to wedge the grid-return.
|
||||||
|
expect(page.keyboard.press).toHaveBeenCalledWith("Escape");
|
||||||
|
expect(page.mouse.click).toHaveBeenCalledWith(
|
||||||
|
VINPIN_COORDS.rpartstoreLaunchErrorOk.x,
|
||||||
|
VINPIN_COORDS.rpartstoreLaunchErrorOk.y,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grid-return dismisses a blocking modal BEFORE each tab-✕ close (can't wedge into a relogin thrash)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
const order: string[] = [];
|
||||||
|
// ocrRegion on a fake page fails-safe to "" (never the brand grid) → the loop runs.
|
||||||
|
const dismiss = vi.spyOn(any as never, "dismissBlockingModal").mockImplementation((async () => {
|
||||||
|
order.push("dismiss");
|
||||||
|
}) as never);
|
||||||
|
vi.spyOn(any as never, "ensureBrandGrid").mockResolvedValue(undefined as never);
|
||||||
|
const page = {
|
||||||
|
mouse: {
|
||||||
|
click: vi.fn(async () => {
|
||||||
|
order.push("click");
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
keyboard: { press: vi.fn(async () => undefined) },
|
||||||
|
waitForTimeout: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
await (any.returnToBrandGrid as (p: unknown, c: unknown) => Promise<void>).call(
|
||||||
|
driver,
|
||||||
|
page,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dismiss).toHaveBeenCalled();
|
||||||
|
// The modal-clear precedes the tab-✕ click on the very first iteration.
|
||||||
|
expect(order[0]).toBe("dismiss");
|
||||||
|
expect(order[1]).toBe("click");
|
||||||
|
expect(page.mouse.click).toHaveBeenCalledWith(
|
||||||
|
VINPIN_COORDS.catalogTabClose.x,
|
||||||
|
VINPIN_COORDS.catalogTabClose.y,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the resumed-desktop escalation (closeStrayRunningApps, NOT logout+relogin) to ONE attempt per decode (no thrash)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
// Fresh decode → escalation budget starts unused.
|
||||||
|
any.reloginUsedThisDecode = false;
|
||||||
|
vi.spyOn(any as never, "submitVinPowerLogin").mockResolvedValue(false as never);
|
||||||
|
vi.spyOn(any as never, "dismissDisconnected").mockResolvedValue(undefined as never);
|
||||||
|
// logout+relogin is retired — it must not exist as a method any more.
|
||||||
|
expect(any.logoutAndRelogin).toBeUndefined();
|
||||||
|
// The escalation itself fails — the point is it's only TRIED once across two returns.
|
||||||
|
const escalate = vi
|
||||||
|
.spyOn(any as never, "closeStrayRunningApps")
|
||||||
|
.mockResolvedValue(false as never);
|
||||||
|
const page = {
|
||||||
|
waitForTimeout: vi.fn(async () => undefined),
|
||||||
|
keyboard: { press: vi.fn(async () => undefined) },
|
||||||
|
mouse: { click: vi.fn(async () => undefined) },
|
||||||
|
};
|
||||||
|
const ensureGrid = (p: unknown, c: unknown, allow?: boolean) =>
|
||||||
|
(any.ensureBrandGrid as (p: unknown, c: unknown, a?: boolean) => Promise<void>).call(
|
||||||
|
driver,
|
||||||
|
p,
|
||||||
|
c,
|
||||||
|
allow,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Two grid-returns inside ONE decode (as returnToBrandGrid's loop would do). The
|
||||||
|
// 2nd must NOT re-escalate → no repeated recovery thrash.
|
||||||
|
await ensureGrid(page, {}, true);
|
||||||
|
await ensureGrid(page, {}, true);
|
||||||
|
|
||||||
|
expect(escalate).toHaveBeenCalledTimes(1); // one escalation only — the 2nd is capped
|
||||||
|
expect(any.reloginUsedThisDecode).toBe(true);
|
||||||
|
expect(any.sessionPoisoned).toBe(true); // capped path poisons for a clean cold restart
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warm-path Fiat fixes: the disambiguated foreground regex (Defect B) and the
|
||||||
|
* warmDecode Fiat branch that must never fail silently (Defect A) — genuine
|
||||||
|
* not-found → null, wrong/garbled window → cold fallback, no Fiat warm window →
|
||||||
|
* cold fallback.
|
||||||
|
*/
|
||||||
|
describe("VinpinDriverService — warm Fiat not-found / wrong-window hardening", () => {
|
||||||
|
const savedEnv = { ...process.env };
|
||||||
|
const VALID_FIAT_VIN = "NM435600006H43436"; // 17 alphanumerics → fiat warm window
|
||||||
|
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.VINPIN_ENABLED = "true";
|
||||||
|
process.env.VINPIN_USER = "user";
|
||||||
|
process.env.VINPIN_PASS = "pass";
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.env = { ...savedEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakePage() {
|
||||||
|
return { isClosed: () => false, keyboard: { press: vi.fn(async () => undefined) } };
|
||||||
|
}
|
||||||
|
function callWarmDecode(driver: VinpinDriverService, vin: string, deadline: number) {
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
return (any.warmDecode as (v: string, c: unknown, d: number) => Promise<unknown>).call(
|
||||||
|
driver,
|
||||||
|
vin,
|
||||||
|
{},
|
||||||
|
deadline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("FIX 1: VINPIN_WINDOW_FOREGROUND.fiat does NOT match a Dialogys 'ПОИСК' string but DOES match Fiat 'Dealer'/'ePER'/'TIPO-EGEA'", () => {
|
||||||
|
const fg = VINPIN_WINDOW_FOREGROUND.fiat;
|
||||||
|
// The shared-token bug: the Dialogys ПОИСК search button must NOT read as the
|
||||||
|
// Fiat foreground (this false-positive typed the VIN into Dialogys — Defect B).
|
||||||
|
expect(fg.test("ПОИСК Dialogys ИЗМЕНИТЬ")).toBe(false);
|
||||||
|
expect(fg.test("ПОИСК")).toBe(false);
|
||||||
|
// Fiat-window-only chrome + VIN-panel model tokens still match.
|
||||||
|
expect(fg.test("Fiat Dealer")).toBe(true);
|
||||||
|
expect(fg.test("ePER window")).toBe(true);
|
||||||
|
expect(fg.test("6J - TIPO - EGEA (2015-2021)")).toBe(true);
|
||||||
|
expect(fg.test("Spare Parts Catalogue")).toBe(true);
|
||||||
|
// Sanity: the Dialogys foreground regex STILL recognises ПОИСК (its own detection
|
||||||
|
// is unaffected — only the Fiat regex dropped the shared token).
|
||||||
|
expect(VINPIN_WINDOW_FOREGROUND.dialogys.test("ПОИСК Dialogys ИЗМЕНИТЬ")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FIX 2: Fiat unusable-parse NOT-on-panel (wrong/garbled window) → falls back to decodeFiatLocked, not a silent null", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
any.warm = true;
|
||||||
|
any.page = fakePage();
|
||||||
|
any.warmWindows = { fiat: true }; // has a Fiat window → not the FIX-4 route
|
||||||
|
vi.spyOn(any as never, "assertSessionAlive").mockResolvedValue(undefined as never);
|
||||||
|
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
|
||||||
|
vi.spyOn(any as never, "runVinFlow").mockResolvedValue(null as never); // no usable parse
|
||||||
|
// Frame shows the DIALOGYS window (Defect B), not the Fiat VIN panel.
|
||||||
|
vi.spyOn(any as never, "ocrFrame").mockResolvedValue("ПОИСК Dialogys ИЗМЕНИТЬ" as never);
|
||||||
|
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
|
||||||
|
const cold = vi
|
||||||
|
.spyOn(any as never, "decodeFiatLocked")
|
||||||
|
.mockResolvedValue({ brand: "Fiat", model: "TIPO" } as never);
|
||||||
|
|
||||||
|
const result = await callWarmDecode(driver, VALID_FIAT_VIN, FAR_DEADLINE());
|
||||||
|
|
||||||
|
expect(cold).toHaveBeenCalledTimes(1); // cold fallback, NOT a silent null
|
||||||
|
expect(result).toMatchObject({ brand: "Fiat", model: "TIPO" });
|
||||||
|
expect(any.warm).toBe(false); // warm dropped (untrusted Fiat window)
|
||||||
|
expect(close).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FIX 2: Fiat genuine not-found (on the VIN panel + not-found string) → returns null, no cold fallback", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
any.warm = true;
|
||||||
|
any.page = fakePage();
|
||||||
|
any.warmWindows = { fiat: true };
|
||||||
|
vi.spyOn(any as never, "assertSessionAlive").mockResolvedValue(undefined as never);
|
||||||
|
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
|
||||||
|
vi.spyOn(any as never, "runVinFlow").mockResolvedValue(null as never);
|
||||||
|
// Positively on the Fiat Spare-Parts catalogue AND a genuine "не найден" miss.
|
||||||
|
vi.spyOn(any as never, "ocrFrame").mockResolvedValue(
|
||||||
|
"Spare Parts Catalogue TIPO не найден" as never,
|
||||||
|
);
|
||||||
|
const cold = vi.spyOn(any as never, "decodeFiatLocked");
|
||||||
|
|
||||||
|
const result = await callWarmDecode(driver, VALID_FIAT_VIN, FAR_DEADLINE());
|
||||||
|
|
||||||
|
expect(result).toBeNull(); // a real miss is correctly null
|
||||||
|
expect(cold).not.toHaveBeenCalled(); // NOT re-driven cold
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FIX 4: routes a Fiat VIN straight to the cold path when warmWindows.fiat is false (no Fiat warm window)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as Record<string, unknown>;
|
||||||
|
any.warm = true;
|
||||||
|
any.page = fakePage();
|
||||||
|
any.warmWindows = { fiat: false, dialogys: true }; // Renault-only partial warm
|
||||||
|
vi.spyOn(any as never, "assertSessionAlive").mockResolvedValue(undefined as never);
|
||||||
|
const ensure = vi.spyOn(any as never, "ensureWarmWindow");
|
||||||
|
const runVinFlow = vi.spyOn(any as never, "runVinFlow");
|
||||||
|
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
|
||||||
|
const cold = vi
|
||||||
|
.spyOn(any as never, "decodeFiatLocked")
|
||||||
|
.mockResolvedValue({ brand: "Fiat", model: "EGEA" } as never);
|
||||||
|
|
||||||
|
const result = await callWarmDecode(driver, VALID_FIAT_VIN, FAR_DEADLINE());
|
||||||
|
|
||||||
|
expect(cold).toHaveBeenCalledTimes(1); // straight to cold — never touched a warm window
|
||||||
|
expect(ensure).not.toHaveBeenCalled();
|
||||||
|
expect(runVinFlow).not.toHaveBeenCalled();
|
||||||
|
expect(result).toMatchObject({ brand: "Fiat", model: "EGEA" });
|
||||||
|
expect(any.warm).toBe(false);
|
||||||
|
expect(close).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
328
apps/api/src/integrations/vinpin/vinpin-driver.teardown.spec.ts
Normal file
328
apps/api/src/integrations/vinpin/vinpin-driver.teardown.spec.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean-teardown + grid-return reliability tests for the Vinpin driver. These
|
||||||
|
* exercise the warm-establish fix: the clean-teardown routine (close resumed
|
||||||
|
* catalog windows via the corrected tab-✕, then log out of the RDS session before
|
||||||
|
* dropping the browser) and ensureBrandGrid's escalation to logout+relogin. OCR is
|
||||||
|
* mocked so the screen-state sequence is fully controllable — the real seat lives
|
||||||
|
* on prod and can't be driven from a test.
|
||||||
|
*/
|
||||||
|
vi.mock("./vinpin.ocr", () => ({
|
||||||
|
ocrRegion: vi.fn(async () => ""),
|
||||||
|
pollForText: vi.fn(async () => ({ matched: false, text: "" })),
|
||||||
|
terminateOcr: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { VinpinDriverService } from "./vinpin-driver.service";
|
||||||
|
import { ocrRegion } from "./vinpin.ocr";
|
||||||
|
|
||||||
|
const mockOcr = vi.mocked(ocrRegion);
|
||||||
|
|
||||||
|
type AnyDriver = Record<string, unknown>;
|
||||||
|
|
||||||
|
function fakePage(sink: (x: number, y: number) => void) {
|
||||||
|
return {
|
||||||
|
isClosed: () => false,
|
||||||
|
frames: () => [] as unknown[],
|
||||||
|
mouse: { click: vi.fn(async (x: number, y: number) => sink(x, y)) },
|
||||||
|
keyboard: { press: vi.fn(async () => undefined) },
|
||||||
|
waitForTimeout: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VinpinDriverService — clean teardown", () => {
|
||||||
|
const savedEnv = { ...process.env };
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.VINPIN_ENABLED = "true";
|
||||||
|
process.env.VINPIN_USER = "user";
|
||||||
|
process.env.VINPIN_PASS = "pass";
|
||||||
|
process.env.VINPIN_WARM_DAEMON = "true";
|
||||||
|
mockOcr.mockReset();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.env = { ...savedEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
it("teardownWarm closes each resumed catalog window (tab-✕) then logs out BEFORE closing the browser", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
const events: string[] = [];
|
||||||
|
vi.spyOn(any as never, "close").mockImplementation((async () => {
|
||||||
|
events.push("close");
|
||||||
|
}) as never);
|
||||||
|
any.browser = { isConnected: () => true };
|
||||||
|
any.context = {};
|
||||||
|
any.warm = true;
|
||||||
|
any.page = fakePage((x, y) => {
|
||||||
|
if (x === 93 && y === 45) events.push("tabClose");
|
||||||
|
if (x === 1543 && y === 877) events.push("logout");
|
||||||
|
});
|
||||||
|
// Two catalog windows open, then a clear desktop.
|
||||||
|
mockOcr
|
||||||
|
.mockResolvedValueOnce("RPartStore")
|
||||||
|
.mockResolvedValueOnce("ePER Dealer")
|
||||||
|
.mockResolvedValue("");
|
||||||
|
|
||||||
|
await driver.teardownWarm();
|
||||||
|
|
||||||
|
// Both windows closed via the corrected tab-✕, then logout, then browser close.
|
||||||
|
expect(events).toEqual(["tabClose", "tabClose", "logout", "close"]);
|
||||||
|
expect(any.warm).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clean-teardown still logs out when no catalog window is open (bounded, no-op close loop)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
const clicks: Array<[number, number]> = [];
|
||||||
|
any.browser = { isConnected: () => true };
|
||||||
|
any.context = {};
|
||||||
|
any.page = fakePage((x, y) => clicks.push([x, y]));
|
||||||
|
mockOcr.mockResolvedValue(""); // desktop already clear
|
||||||
|
|
||||||
|
await (any.cleanTeardown as () => Promise<void>).call(driver);
|
||||||
|
|
||||||
|
expect(clicks.filter(([x, y]) => x === 93 && y === 45)).toHaveLength(0); // nothing to close
|
||||||
|
expect(clicks).toContainEqual([1543, 877]); // still logs out to end the RDS session
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logout dismisses any blocking modal FIRST, and recovers a Disconnected drop by closing it + relaunching VINPIN for a fresh grid", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
const events: string[] = [];
|
||||||
|
any.browser = { isConnected: () => true };
|
||||||
|
any.context = {};
|
||||||
|
any.page = fakePage((x, y) => {
|
||||||
|
if (x === 868 && y === 530) events.push("dismissModal"); // rpartstoreLaunchErrorOk
|
||||||
|
if (x === 1543 && y === 877) events.push("logout"); // Çıkış yap
|
||||||
|
if (x === 953 && y === 505) events.push("disconnectedClose");
|
||||||
|
if (x === 40 && y === 256) events.push("relaunchVinpin"); // vinpinApp
|
||||||
|
});
|
||||||
|
// (1) close-loop: desktop already clear → break. (2) after logout: a Disconnected
|
||||||
|
// dialog surfaced (dirty channel drop). (3) closing it lands on the launcher.
|
||||||
|
mockOcr
|
||||||
|
.mockResolvedValueOnce("") // close-loop: nothing open
|
||||||
|
.mockResolvedValueOnce("You have been disconnected") // after Çıkış yap
|
||||||
|
.mockResolvedValueOnce("No Running Items Available"); // launcher after Close
|
||||||
|
|
||||||
|
await (any.cleanTeardown as () => Promise<void>).call(driver);
|
||||||
|
|
||||||
|
// Modal dismissed BEFORE logout; then the disconnect is closed and VINPIN relaunched.
|
||||||
|
expect(events).toEqual(["dismissModal", "logout", "disconnectedClose", "relaunchVinpin"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logout does NOT relaunch VINPIN when logout was a clean log-off (no Disconnected dialog)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
const clicks: Array<[number, number]> = [];
|
||||||
|
any.browser = { isConnected: () => true };
|
||||||
|
any.context = {};
|
||||||
|
any.page = fakePage((x, y) => clicks.push([x, y]));
|
||||||
|
mockOcr.mockResolvedValue(""); // clear desktop, and no Disconnected dialog after logout
|
||||||
|
|
||||||
|
await (any.cleanTeardown as () => Promise<void>).call(driver);
|
||||||
|
|
||||||
|
expect(clicks).toContainEqual([1543, 877]); // logged out
|
||||||
|
expect(clicks).not.toContainEqual([953, 505]); // no disconnect to close
|
||||||
|
expect(clicks).not.toContainEqual([40, 256]); // VINPIN not relaunched
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT relaunch VINPIN when logout lands on the HTML-Access launcher (clean log-off, not a drop)", async () => {
|
||||||
|
// The clean-logout launcher OCRs its "HTML Access" branding, which the broad
|
||||||
|
// sessionDropped token matches — but the launcher tokens must veto the recovery
|
||||||
|
// so a clean log-off is never mistaken for a Disconnected drop (would spuriously
|
||||||
|
// relaunch VinPower and leave a dirty resumed session).
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
const clicks: Array<[number, number]> = [];
|
||||||
|
any.browser = { isConnected: () => true };
|
||||||
|
any.context = {};
|
||||||
|
any.page = fakePage((x, y) => clicks.push([x, y]));
|
||||||
|
mockOcr
|
||||||
|
.mockResolvedValueOnce("") // close-loop: nothing open
|
||||||
|
.mockResolvedValueOnce("VMware Horizon HTML Access — No Running Items Available"); // clean launcher after logout
|
||||||
|
|
||||||
|
await (any.cleanTeardown as () => Promise<void>).call(driver);
|
||||||
|
|
||||||
|
expect(clicks).toContainEqual([1543, 877]); // logged out
|
||||||
|
expect(clicks).not.toContainEqual([953, 505]); // NOT treated as a disconnect
|
||||||
|
expect(clicks).not.toContainEqual([40, 256]); // VINPIN NOT relaunched
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clean-teardown is a no-op when the browser/page is already gone", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
any.browser = null;
|
||||||
|
any.context = null;
|
||||||
|
any.page = null;
|
||||||
|
// Must not throw and must not touch OCR.
|
||||||
|
await expect((any.cleanTeardown as () => Promise<void>).call(driver)).resolves.toBeUndefined();
|
||||||
|
expect(mockOcr).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A running-app row handle for the Horizon "Running" panel. `$` resolves the
|
||||||
|
* name element (textContent) and the per-app close ✕; clicking the ✕ removes the
|
||||||
|
* app from the shared running list (models a real window teardown).
|
||||||
|
*/
|
||||||
|
function fakeRunningApp(name: string, onClose: (n: string) => void) {
|
||||||
|
return {
|
||||||
|
$: async (sel: string) => {
|
||||||
|
if (/running-app-name|focused-app-name/.test(sel)) {
|
||||||
|
return { textContent: async () => name };
|
||||||
|
}
|
||||||
|
if (/icon-close-app-image/.test(sel)) {
|
||||||
|
return { click: async () => onClose(name) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fake page that also exposes the Horizon client-chrome DOM (`$`, `$$`,
|
||||||
|
* `page.click`) used by closeStrayRunningApps. `apps` is the mutable running list;
|
||||||
|
* closing a stray removes it. `closed` records the order strays were terminated.
|
||||||
|
*/
|
||||||
|
function fakeDomPage(apps: string[], sink?: (x: number, y: number) => void) {
|
||||||
|
const running = [...apps];
|
||||||
|
const closed: string[] = [];
|
||||||
|
const domClicks: string[] = [];
|
||||||
|
const page = {
|
||||||
|
isClosed: () => false,
|
||||||
|
frames: () => [] as unknown[],
|
||||||
|
mouse: { click: vi.fn(async (x: number, y: number) => sink?.(x, y)) },
|
||||||
|
keyboard: { press: vi.fn(async () => undefined) },
|
||||||
|
waitForTimeout: vi.fn(async () => undefined),
|
||||||
|
$: async (sel: string) =>
|
||||||
|
sel === "#sidebar-toggler" || sel === "#available-VINPIN" ? {} : null,
|
||||||
|
$$: async (sel: string) =>
|
||||||
|
sel === "ul.running-app"
|
||||||
|
? running.map((n) =>
|
||||||
|
fakeRunningApp(n, (name) => {
|
||||||
|
closed.push(name);
|
||||||
|
const idx = running.indexOf(name);
|
||||||
|
if (idx >= 0) running.splice(idx, 1);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
click: vi.fn(async (sel: string) => {
|
||||||
|
domClicks.push(sel);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return { page, running, closed, domClicks };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VinpinDriverService — ensureBrandGrid stray-app (Running-panel) recovery", () => {
|
||||||
|
const savedEnv = { ...process.env };
|
||||||
|
const cfg = { url: "https://vinpin.test", user: "user", pass: "pass" };
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.VINPIN_ENABLED = "true";
|
||||||
|
mockOcr.mockReset();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.env = { ...savedEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closeStrayRunningApps closes every non-VinPower app via the DOM ✕ and leaves only VinPower", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
const { page, running, closed } = fakeDomPage([
|
||||||
|
"VinPower",
|
||||||
|
"Renault Rpartstore",
|
||||||
|
"Loading application...",
|
||||||
|
]);
|
||||||
|
// After the strays close + the sidebar collapses, the canvas is on the brand grid.
|
||||||
|
mockOcr.mockResolvedValue("Volkswagen SsangYong TecDoc");
|
||||||
|
|
||||||
|
const ok = await (
|
||||||
|
any.closeStrayRunningApps as (p: unknown, c: unknown) => Promise<boolean>
|
||||||
|
).call(driver, page, cfg);
|
||||||
|
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
// Both non-VinPower apps terminated via their per-app ✕, in row order.
|
||||||
|
expect(closed).toEqual(["Renault Rpartstore", "Loading application..."]);
|
||||||
|
expect(running).toEqual(["VinPower"]); // VinPower kept
|
||||||
|
expect(any.sessionPoisoned).toBe(false); // reached a confirmed-clean grid
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closeStrayRunningApps degrades gracefully (false, no throw) when the sidebar DOM is absent", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
const page = fakePage(() => undefined); // no $ / $$ / page.click — canvas-only render
|
||||||
|
mockOcr.mockResolvedValue("");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
(any.closeStrayRunningApps as (p: unknown, c: unknown) => Promise<boolean>).call(
|
||||||
|
driver,
|
||||||
|
page,
|
||||||
|
cfg,
|
||||||
|
),
|
||||||
|
).resolves.toBe(false); // → caller falls back to the canvas tab-✕ / OCR path
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ensureBrandGrid escalates via closeStrayRunningApps (NOT logout+relogin) + falls back to the tab-✕ (93,45), never the language-selector (1298,14)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
const clicks: Array<[number, number]> = [];
|
||||||
|
const page = fakePage((x, y) => clicks.push([x, y]));
|
||||||
|
// Always a catalog window open → grid never reached → in-branch recovery + escalation.
|
||||||
|
mockOcr.mockResolvedValue("RPartStore catalog open");
|
||||||
|
// logout+relogin is retired — the method must not exist any more.
|
||||||
|
expect((any as Record<string, unknown>).logoutAndRelogin).toBeUndefined();
|
||||||
|
// DOM absent → the primary Running-panel recovery returns false → tab-✕ fallback.
|
||||||
|
const escalate = vi.spyOn(any as never, "closeStrayRunningApps");
|
||||||
|
|
||||||
|
await (any.ensureBrandGrid as (p: unknown, c: unknown, allow?: boolean) => Promise<void>).call(
|
||||||
|
driver,
|
||||||
|
page,
|
||||||
|
cfg,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(escalate).toHaveBeenCalled(); // primary + escalation is the Running-panel recovery
|
||||||
|
expect(clicks).toContainEqual([93, 45]); // tab-✕ fallback still used
|
||||||
|
expect(clicks).not.toContainEqual([1298, 14]); // language-selector coord NEVER clicked
|
||||||
|
expect(any.sessionPoisoned).toBe(true); // recovery failed (DOM absent) → poison, no relogin
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ensureBrandGrid clears the poison when closeStrayRunningApps reaches a clean grid", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
const page = fakePage(() => undefined);
|
||||||
|
mockOcr.mockResolvedValue("RPartStore catalog open"); // never reaches grid by OCR
|
||||||
|
// The escalation succeeds (Running-panel recovery reached a fresh grid).
|
||||||
|
const escalate = vi
|
||||||
|
.spyOn(any as never, "closeStrayRunningApps")
|
||||||
|
.mockResolvedValue(true as never);
|
||||||
|
|
||||||
|
await (any.ensureBrandGrid as (p: unknown, c: unknown, allow?: boolean) => Promise<void>).call(
|
||||||
|
driver,
|
||||||
|
page,
|
||||||
|
cfg,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(escalate).toHaveBeenCalled();
|
||||||
|
expect(any.sessionPoisoned).toBe(false); // recovery reached a confirmed-clean grid
|
||||||
|
});
|
||||||
|
|
||||||
|
it("with allowRelogin=false it does NOT run the end-of-loop escalation and poisons the seat (one-shot, no livelock)", async () => {
|
||||||
|
const driver = new VinpinDriverService();
|
||||||
|
const any = driver as unknown as AnyDriver;
|
||||||
|
const page = fakePage(() => undefined);
|
||||||
|
mockOcr.mockResolvedValue("RPartStore catalog open"); // never reaches grid
|
||||||
|
|
||||||
|
await (any.ensureBrandGrid as (p: unknown, c: unknown, allow?: boolean) => Promise<void>).call(
|
||||||
|
driver,
|
||||||
|
page,
|
||||||
|
cfg,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(any.sessionPoisoned).toBe(true); // poisoned → next decode cold-restarts
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,11 +12,23 @@
|
|||||||
* coordinate if the brand-grid layout ever changes.
|
* coordinate if the brand-grid layout ever changes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { getBrandFromWmi } from "@sase/shared";
|
||||||
|
|
||||||
/** Viewport the coordinates are calibrated for. */
|
/** Viewport the coordinates are calibrated for. */
|
||||||
export const VINPIN_VIEWPORT = { width: 1600, height: 900 } as const;
|
export const VINPIN_VIEWPORT = { width: 1600, height: 900 } as const;
|
||||||
|
|
||||||
/** Coordinate map (x, y) for the ePER canvas. Verified live @ 1600x900. */
|
/** Coordinate map (x, y) for the ePER canvas. Verified live @ 1600x900. */
|
||||||
export const VINPIN_COORDS = {
|
export const VINPIN_COORDS = {
|
||||||
|
/** "VINPIN" app tile on the Horizon HTML-Access launcher ("Available" list).
|
||||||
|
* The PERMANENT (paid) seat lands on this launcher after login instead of
|
||||||
|
* auto-launching VinPower — clicking this tile launches VinPower.
|
||||||
|
* Tuned for the permanent seat @ 1600x900 (2026-07-01). */
|
||||||
|
vinpinApp: { x: 40, y: 256 },
|
||||||
|
/** VinPower's own login dialog (shown after launching the app from the
|
||||||
|
* launcher): password field + "Oturum aç". DOM-filled when reachable; these
|
||||||
|
* are the coordinate fallback. Tuned for the permanent seat @ 1600x900. */
|
||||||
|
vinpowerPassField: { x: 800, y: 465 },
|
||||||
|
vinpowerSubmit: { x: 800, y: 629 },
|
||||||
/** Fiat brand tile on the post-login brand grid (row 2, col 2). */
|
/** Fiat brand tile on the post-login brand grid (row 2, col 2). */
|
||||||
fiatBrand: { x: 230, y: 104 },
|
fiatBrand: { x: 230, y: 104 },
|
||||||
/** "OK" button of the spurious Russian-language dialog shown on ePER open. */
|
/** "OK" button of the spurious Russian-language dialog shown on ePER open. */
|
||||||
@@ -32,15 +44,117 @@ export const VINPIN_COORDS = {
|
|||||||
vinSubmitArrow: { x: 1283, y: 189 },
|
vinSubmitArrow: { x: 1283, y: 189 },
|
||||||
/** "OK" button of the "vehicles not found" alert (genuine not-found result). */
|
/** "OK" button of the "vehicles not found" alert (genuine not-found result). */
|
||||||
notFoundOk: { x: 816, y: 448 },
|
notFoundOk: { x: 816, y: 448 },
|
||||||
|
|
||||||
|
// ─── Renault flow (Rpartstore primary + Dialogys fallback) ───
|
||||||
|
// Coordinates below are 1600x900 STARTING POINTS from the live benchmark on the
|
||||||
|
// permanent seat (trvinpin41080). They are OCR-verified+retried at runtime like
|
||||||
|
// the Fiat flow, so minor drift self-heals — but re-verify if layout changes.
|
||||||
|
// TUNE.
|
||||||
|
/** Window-close (X) button of an open catalog window. ⚠️ DO NOT USE on the
|
||||||
|
* HTML-Access desktop: this coord actually hits the LANGUAGE SELECTOR (it opens a
|
||||||
|
* dropdown that makes the brand grid unrecognizable and wedges the ensureBrandGrid
|
||||||
|
* thrash). It is NO LONGER clicked by ANY path — the browser-tab ✕ below is the
|
||||||
|
* only close action. Kept documented so the bad coordinate isn't re-introduced. */
|
||||||
|
windowClose: { x: 1298, y: 14 },
|
||||||
|
/** Browser-tab ✕ of an open catalog app — the PRIMARY window-close action. The
|
||||||
|
* tab sits just under the window title bar, e.g. "Renault Rpartstore ✕".
|
||||||
|
* Validated live: clicking (93,45) closed the catalog and returned to the clean
|
||||||
|
* brand grid (the old {135,45} missed the ✕). TUNE. */
|
||||||
|
catalogTabClose: { x: 93, y: 45 },
|
||||||
|
/** "Çıkış yap" logout button (bottom-right of the RDS/Horizon desktop). Ends the
|
||||||
|
* remote session so the NEXT warm-up starts from a fresh login/grid instead of
|
||||||
|
* resuming into the last-open dirty catalog desktop. Used by the clean-teardown
|
||||||
|
* routine. ⚠️ With a blocking modal up (e.g. the Rpartstore launch-error dialog)
|
||||||
|
* this becomes a channel DISCONNECT rather than a clean RDS log-off — so the
|
||||||
|
* teardown dismisses any modal BEFORE clicking it. TUNE. */
|
||||||
|
logoutButton: { x: 1543, y: 877 },
|
||||||
|
/** "Close" button of the Horizon "You have been disconnected" dialog. If "Çıkış
|
||||||
|
* yap" turned into a channel disconnect (dirty RDS resume), clicking this lands
|
||||||
|
* back on the HTML-Access launcher ("No Running Items") from which the VINPIN app
|
||||||
|
* is relaunched for a fresh VinPower grid. Verified @ 1600x900. TUNE. */
|
||||||
|
disconnectedClose: { x: 953, y: 505 },
|
||||||
|
/** Renault brand tile on the VinPower grid (opens the Rpartstore/Dialogys
|
||||||
|
* submenu). mousedown/up like the Fiat tile. TUNE. */
|
||||||
|
renaultBrand: { x: 450, y: 707 },
|
||||||
|
/** Rpartstore entry in the Renault submenu (PRIMARY catalog). TUNE. */
|
||||||
|
renaultRpartstore: { x: 584, y: 779 },
|
||||||
|
/** Dialogys entry in the Renault submenu (FALLBACK catalog). TUNE. */
|
||||||
|
renaultDialogys: { x: 584, y: 733 },
|
||||||
|
/** Both Renault catalogs pop a Russian-language dialog on open → its OK. TUNE. */
|
||||||
|
renaultLangOk: { x: 746, y: 454 },
|
||||||
|
/** Rpartstore "Şasi no ile arama" VIN field. Submit with Enter (NOT the yellow
|
||||||
|
* button — it moves). TUNE. */
|
||||||
|
rpartstoreVinField: { x: 735, y: 194 },
|
||||||
|
/** Rpartstore "RPartStore" home/logo (top-left) — resets to the search home
|
||||||
|
* between VINs (more reliable than close+reopen). TUNE. */
|
||||||
|
rpartstoreHome: { x: 110, y: 92 },
|
||||||
|
/** Rpartstore error-card close (✕) — dismisses the "Şasi seçilmedi" /
|
||||||
|
* "bulunamadı" overlay that otherwise covers the VIN field. TUNE. */
|
||||||
|
rpartstoreErrorClose: { x: 928, y: 167 },
|
||||||
|
/** Rpartstore yellow search button (fallback to Enter submit). TUNE. */
|
||||||
|
rpartstoreSearchBtn: { x: 975, y: 195 },
|
||||||
|
/** "OK" button of the Rpartstore hard launch-error modal ("Ошибка запуска
|
||||||
|
* каталога" / title "Renault Rpartstore") that fires when Rpartstore is DOWN
|
||||||
|
* server-side. Verified live @ 1600x900 (viewport frame) — dismisses the modal
|
||||||
|
* so the driver can fall straight back to Dialogys. TUNE. */
|
||||||
|
rpartstoreLaunchErrorOk: { x: 868, y: 530 },
|
||||||
|
/** Dialogys VIN field. TUNE. */
|
||||||
|
dialogysVinField: { x: 457, y: 258 },
|
||||||
|
/** Dialogys ПОИСК (search) button. TUNE. */
|
||||||
|
dialogysSearch: { x: 590, y: 382 },
|
||||||
|
/** Dialogys Home button (reset step 1). TUNE. */
|
||||||
|
dialogysHome: { x: 78, y: 102 },
|
||||||
|
/** Dialogys ИЗМЕНИТЬ А/М — change-vehicle (reset step 2). TUNE. */
|
||||||
|
dialogysChangeVehicle: { x: 1217, y: 160 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Screenshot clip (px) of the Renault Rpartstore/Dialogys vehicle-page HEADER
|
||||||
|
* region, fed to OCR. Rpartstore renders "RENAULT <model> (<code>)" /
|
||||||
|
* "DACIA <model>" + "Şasi: <VIN>" in x280–960,y182–258; Dialogys renders
|
||||||
|
* "<Model> (<platform>), <engine>" slightly higher. Captured a bit wider/taller
|
||||||
|
* than the benchmark box to tolerate drift. TUNE. */
|
||||||
|
export const VINPIN_RENAULT_HEADER_REGION = {
|
||||||
|
x: 260,
|
||||||
|
y: 150,
|
||||||
|
width: 720,
|
||||||
|
height: 140,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Screenshot clip (px) of the decode-result modal body, fed to OCR. Captures
|
/** Screenshot clip (px) of the decode-result modal body, fed to OCR. Captures
|
||||||
* the model line ("6J - TIPO - EGEA (2015-2021)"), SINCOM, trim, prod-date and
|
* the model line ("6J - TIPO - EGEA (2015-2021)"), SINCOM, trim, prod-date and
|
||||||
* the echoed VIN. Verified live. */
|
* the echoed VIN. Verified live. Widened to ~900px for the warm-session daemon
|
||||||
export const VINPIN_MODAL_REGION = { x: 250, y: 120, width: 820, height: 480 } as const;
|
* (some decode modals render wider on the permanent seat). */
|
||||||
|
export const VINPIN_MODAL_REGION = { x: 250, y: 120, width: 900, height: 500 } as const;
|
||||||
|
|
||||||
|
/** Screenshot clip (px) of the CENTERED Rpartstore hard launch-error modal
|
||||||
|
* ("Renault Rpartstore" / "Ошибка запуска каталога", OK ~868,530), fed to OCR
|
||||||
|
* UPSCALED (ocrRegion's default 3×). ⚠️ ROOT-CAUSE NOTE (verified live 2026-07-15
|
||||||
|
* on seat trvinpin47828): the modal is a small (~220×140px) centered Cyrillic
|
||||||
|
* dialog. OCR'ing the FULL 1600×900 frame at 1× (what the old load-poll did)
|
||||||
|
* CANNOT read it — tesseract returns the surrounding brand-grid tiles and drops
|
||||||
|
* the tiny modal text, so `rpartstoreLaunchError` never matched → the DOWN
|
||||||
|
* Rpartstore was misclassified as a "born-stuck spinner" and 3 reopens (~54s) were
|
||||||
|
* burned before Dialogys. Cropping to THIS region + 3× upscale makes the string
|
||||||
|
* legible ("Owwu6ka 3anycka KaTanora"), so the launch error is detected on the
|
||||||
|
* FIRST open and the flow bails straight to Dialogys. Sized generously around the
|
||||||
|
* screen-centered dialog to tolerate drift while excluding the right sidebar. */
|
||||||
|
export const VINPIN_RPARTSTORE_ERROR_REGION = {
|
||||||
|
x: 540,
|
||||||
|
y: 370,
|
||||||
|
width: 560,
|
||||||
|
height: 230,
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** OCR keyword sets for state detection (case-insensitive). */
|
/** OCR keyword sets for state detection (case-insensitive). */
|
||||||
export const VINPIN_OCR = {
|
export const VINPIN_OCR = {
|
||||||
|
/** Horizon HTML-Access app launcher (permanent seat, no auto-launch). Its
|
||||||
|
* "Available / Running / No Running Items" chrome is absent from every
|
||||||
|
* VinPower screen, so these tokens uniquely identify the launcher. */
|
||||||
|
launcher: /No Running Items|Available|Running/i,
|
||||||
|
/** VinPower brand grid (many brand tiles). These multi-brand tokens appear
|
||||||
|
* ONLY on the grid — not on the launcher, the ePER catalogue, or the
|
||||||
|
* connecting/blank screens — so they confirm VinPower actually launched. */
|
||||||
|
brandGrid: /SsangYong|General Motors|Volkswagen|TecDoc|Mitsubishi|Dongfeng/i,
|
||||||
/** ePER (Fiat Dealer) window is open. */
|
/** ePER (Fiat Dealer) window is open. */
|
||||||
eperOpen: /Dealer|Pycc|Русск|Spare|Поиск|Запчаст|EPER/i,
|
eperOpen: /Dealer|Pycc|Русск|Spare|Поиск|Запчаст|EPER/i,
|
||||||
/** We're on the Spare Parts CATALOGUE (left model tree visible) — the page
|
/** We're on the Spare Parts CATALOGUE (left model tree visible) — the page
|
||||||
@@ -52,21 +166,330 @@ export const VINPIN_OCR = {
|
|||||||
/** The decode modal actually carries a result (a model / prod-date / MVS). */
|
/** The decode modal actually carries a result (a model / prod-date / MVS). */
|
||||||
modalHit:
|
modalHit:
|
||||||
/MVS|найден|Prod\.?\s*date|TIPO|EGEA|DOBLO|PALIO|PANDA|PUNTO|LINEA|DUCATO|QUBO|FIORINO|ULYSSE/i,
|
/MVS|найден|Prod\.?\s*date|TIPO|EGEA|DOBLO|PALIO|PANDA|PUNTO|LINEA|DUCATO|QUBO|FIORINO|ULYSSE/i,
|
||||||
/** Genuine "no vehicle found" outcome (the catalog has no record). */
|
/** Genuine "no vehicle found" outcome (the catalog has no record). Used against
|
||||||
|
* the cropped decode-modal region in runVinFlow, where the loose `Catalogue`
|
||||||
|
* token is a useful settle signal. */
|
||||||
notFound: /не\s*найден|not\s*found|Catalogue/i,
|
notFound: /не\s*найден|not\s*found|Catalogue/i,
|
||||||
|
/** STRICT genuine not-found — requires an actual "не найден"/"not found" string,
|
||||||
|
* WITHOUT the loose `Catalogue` token. Use this against the FULL frame (where
|
||||||
|
* the "Spare Parts Catalogue" header is always present, so `notFound` would
|
||||||
|
* false-match), e.g. the warm-Fiat genuine-miss-vs-garble decision — so an
|
||||||
|
* on-panel transient garble falls through to the cold retry instead of null. */
|
||||||
|
notFoundStrict: /не\s*найден|not\s*found/i,
|
||||||
|
|
||||||
|
// ─── Renault flow OCR sets ───
|
||||||
|
/** A catalog app window (Rpartstore / Dialogys / Fiat ePER) is open OVER the
|
||||||
|
* brand grid — its chrome must be closed to fall back to the grid. Covers the
|
||||||
|
* Rpartstore/Dialogys titles + the ePER "Spare Parts" / "Son araca geri dön"
|
||||||
|
* navigation that only appear inside an open catalog. */
|
||||||
|
catalogWindowOpen:
|
||||||
|
/RPartStore|Rpartstore|Dialogys|Son\s*araca|Spare\s*Parts|Yedek\s*par|ePER|Üniversal\s*ürün/i,
|
||||||
|
/** Renault submenu is open (Rpartstore + Dialogys entries visible). */
|
||||||
|
renaultSubmenu: /Rpartstore|Rpart|Dialogys|Dialog/i,
|
||||||
|
/** Rpartstore app/window is open (home OR a vehicle page) — its chrome tokens
|
||||||
|
* ("RPartStore", "Güncel araçlar", "Ne arıyorsunuz", "Grup siparişi",
|
||||||
|
* "Şasi") appear only inside Rpartstore. */
|
||||||
|
rpartstoreOpen: /RPartStore|Rpartstore|Güncel\s*ara|Grup\s*sipariş|arıyor|Şasi|Sasi/i,
|
||||||
|
/** Rpartstore home/search page (the VIN search field is up). */
|
||||||
|
rpartstoreReady: /Güncel\s*ara|arıyor|Grup\s*sipariş|Şasi\s*no|Sasi\s*no|Ara\b/i,
|
||||||
|
/** Rpartstore search-home actually RENDERED its landing markers ("Şasi no ile
|
||||||
|
* arama" / "Ne arıyorsunuz" / "Güncel araçlar" / "Grup siparişi"). This is the
|
||||||
|
* spinner-guard signal: it distinguishes a LOADED home from the "born-stuck"
|
||||||
|
* infinite spinner, which shows only the window CHROME ("RPartStore" title —
|
||||||
|
* matched by rpartstoreOpen) with no search-home content. Kept strictly to
|
||||||
|
* content-only tokens so a spinning-but-titled window never false-passes. */
|
||||||
|
rpartstoreLoaded:
|
||||||
|
/Şasi\s*no\s*ile|Sasi\s*no\s*ile|Ne\s*arıyor|Ne\s*ariyor|Güncel\s*ara(ç|c)|Guncel\s*ara(ç|c)|Grup\s*sipariş|arıyorsunuz/i,
|
||||||
|
/** Rpartstore HARD launch-error modal — the Russian "Ошибка запуска каталога"
|
||||||
|
* (Catalog launch error) dialog that fires within ~8-10s of opening when
|
||||||
|
* Rpartstore is DOWN server-side (entitlement/backend failure). Reopening never
|
||||||
|
* helps, so detecting this must SHORT-CIRCUIT straight to Dialogys instead of
|
||||||
|
* burning the reopen budget. ⚠️ This must be read from an UPSCALED crop of
|
||||||
|
* `VINPIN_RPARTSTORE_ERROR_REGION` — on a full 1×frame the modal is illegible
|
||||||
|
* (see that region's note). The eng-OCR of the Cyrillic "Ошибка запуска
|
||||||
|
* каталога" transliterates to "Owwu6ka 3anycka KaTanora" / "OwwbKa 3anycka
|
||||||
|
* KaTanora" (captured verbatim live 2026-07-15). The `3anycka`+`KaTanora` tokens
|
||||||
|
* are the stable anchors; the "Ошибка" head transliterates variably
|
||||||
|
* (Owwu6ka/OwwbKa/Owwnbka) so all seen forms are listed. Its title alone
|
||||||
|
* ("Renault Rpartstore") false-matches rpartstoreOpen — this content string is
|
||||||
|
* what disambiguates the error. */
|
||||||
|
rpartstoreLaunchError: /запуска\s*катал|Ошибка\s*запуск|3anycka|KaTanora|Owwu6ka|OwwbKa|Owwnbka/i,
|
||||||
|
/** Rpartstore decoded a vehicle — header shows RENAULT/DACIA <model> + Şasi. */
|
||||||
|
rpartstoreHit: /RENAULT|DACIA|Şasi\s*:|Sasi\s*:/i,
|
||||||
|
/** Rpartstore genuine not-found — the error card ("İlişikli araç bulunamadı" /
|
||||||
|
* "Şasi ... bulunamadı" / "Şasi seçilmedi"). */
|
||||||
|
rpartstoreNotFound: /bulunamad|İlişikli|Ilisikli|araç\s*bulun|seçilmedi|secilmedi/i,
|
||||||
|
/** Dialogys form/vehicle page reached (Cyrillic UI, ПОИСК / ИЗМЕНИТЬ). */
|
||||||
|
dialogysReady: /ПОИСК|ПОИC|Dialogys|VIN|ИЗМЕНИТЬ/i,
|
||||||
|
|
||||||
|
// ─── Warm-session daemon OCR sets ───
|
||||||
|
/** The RDS/Horizon session is still ALIVE — the desktop status panel shows the
|
||||||
|
* host/seat identifiers ("HORIZON-RDST01"/"HORIZON-RDST02" / "trvinpin41080").
|
||||||
|
* The seat now LOAD-BALANCES across RDST01 and RDST02, so both host tags count.
|
||||||
|
* Their ABSENCE combined with a `sessionDropped` marker means the session dropped. */
|
||||||
|
sessionAlive: /HORIZON[- ]?RDST0[12]|RDST0[12]|trvinpin\d*/i,
|
||||||
|
/** The RDS/Horizon session DROPPED — a "Disconnected"/reconnect dialog or the
|
||||||
|
* "no free sessions" gate. Triggers a teardown + re-warm + single retry. */
|
||||||
|
sessionDropped:
|
||||||
|
/Disconnected|nofreesession|no\s*free\s*session|reconnect|Session\s*(has\s*)?(been\s*)?(disconnected|ended|expired|timed\s*out)|HTML\s*Access/i,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which taskbar button belongs to which catalog window. The warm daemon OCRs the
|
||||||
|
* bottom taskbar and matches these labels to bind brand→coordinate at runtime
|
||||||
|
* (the button ORDER depends on the open sequence, so we never hardcode the
|
||||||
|
* mapping — see `orderTaskbarWindows`). `grid` matches the VinPower brand-grid
|
||||||
|
* button used to open the next catalog without closing the current one.
|
||||||
|
*/
|
||||||
|
export const VINPIN_WINDOW_LABELS = {
|
||||||
|
fiat: /ePER|Dealer|Fiat/i,
|
||||||
|
rpartstore: /RPartStore|Rpart/i,
|
||||||
|
dialogys: /Dialogys|Dialog/i,
|
||||||
|
grid: /VinPower|VINPIN|Marka|Brand/i,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type VinpinWindowKey = keyof typeof VINPIN_WINDOW_LABELS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OCR keyword that confirms a given catalog window is now in the FOREGROUND after
|
||||||
|
* a taskbar raise (reuses the existing state sets). Used to verify a raise landed
|
||||||
|
* on the right window before running a decode on it.
|
||||||
|
*/
|
||||||
|
export const VINPIN_WINDOW_FOREGROUND: Record<"fiat" | "rpartstore" | "dialogys", RegExp> = {
|
||||||
|
// ⚠️ Fiat-window-ONLY tokens. The old `Поиск` token was SHARED with the Dialogys
|
||||||
|
// "ПОИСК" search button (`/Поиск/i.test("ПОИСК") === true`), so in a partial-warm
|
||||||
|
// session where Dialogys ends up foreground the Fiat raise falsely reported success
|
||||||
|
// and the VIN was typed into Dialogys → silent garbage. `Dealer`/`ePER` are Fiat
|
||||||
|
// ePER chrome (never on Dialogys/Rpartstore); the rest are VIN-panel model tokens.
|
||||||
|
fiat: /Spare\s*Parts\s*Catalogue|GRANDE\s*PANDA|TIPO|EGEA|DOBLO|Dealer|ePER/i,
|
||||||
|
rpartstore: /RPartStore|Rpartstore|Güncel\s*ara|Grup\s*sipariş|Şasi|Sasi/i,
|
||||||
|
dialogys: /ПОИСК|ПОИC|Dialogys|ИЗМЕНИТЬ/i,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizon HTML-Access client-chrome DOM selectors (REAL DOM, outside the Blast
|
||||||
|
* canvas — hidden until the sidebar is opened). Used by the state-agnostic
|
||||||
|
* stray-app recovery (`closeStrayRunningApps`): the sidebar "Running" panel lists
|
||||||
|
* each RDS app with a per-app close ✕ that TERMINATES that specific app window
|
||||||
|
* regardless of its internal modal/spinner/loading state. Proven live to cleanly
|
||||||
|
* close both a Fiat "Fiat Dealer" window and a Renault Rpartstore launch-error
|
||||||
|
* resume, each returning to a pristine brand grid — the root fix for the
|
||||||
|
* dirty-resume failure the canvas tab-✕ (only hits a TABBED window's ✕) could not
|
||||||
|
* recover. Treated as GIVEN from the live probe; every use is guarded (try/catch +
|
||||||
|
* presence check) so a wrong/absent selector degrades to the canvas/OCR fallback
|
||||||
|
* rather than throwing. ⚠️ A Connection-Server logout+relogin is NOT a recovery
|
||||||
|
* here: the seat publishes apps-only (no desktop → no Start-menu Log Off), so the
|
||||||
|
* RDS session ends ONLY on server-side idle timeout — logout+relogin provably
|
||||||
|
* RESUMES the same dirty window. This DOM path is the recovery instead. */
|
||||||
|
export const VINPIN_DOM = {
|
||||||
|
/** Left-edge grip (~10,450) that reveals/collapses the Horizon sidebar. */
|
||||||
|
sidebarToggler: "#sidebar-toggler",
|
||||||
|
/** One <ul> per running RDS app in the sidebar "Running" panel. */
|
||||||
|
runningApp: "ul.running-app",
|
||||||
|
/** The app's display name inside a running-app row ("VinPower", "Fiat Dealer",
|
||||||
|
* "Renault Rpartstore", "Dialogys", "Loading application..."). */
|
||||||
|
runningAppName: "li.running-app-name, li.focused-app-name",
|
||||||
|
/** Per-app close ✕ inside a running-app row — terminates THAT app window. */
|
||||||
|
appCloseImage: "li.icon-close-app-image",
|
||||||
|
/** "Available → VINPIN" launch tile (relaunch VinPower if it was closed). */
|
||||||
|
availableVinpin: "#available-VINPIN",
|
||||||
|
/** App name to KEEP — every OTHER running app is a stray to terminate. */
|
||||||
|
vinPowerAppName: /VinPower/i,
|
||||||
|
/** Bounded passes over the Running panel (rows shift as apps close). */
|
||||||
|
maxClosePasses: 6,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Bottom Horizon/RDS taskbar clip (px), fed to OCR to read the open windows'
|
||||||
|
* button labels left→right. TUNE against the permanent seat @ 1600x900. */
|
||||||
|
export const VINPIN_TASKBAR_REGION = { x: 0, y: 866, width: 1600, height: 34 } as const;
|
||||||
|
|
||||||
|
/** Candidate taskbar button CENTERS along the bottom bar, left→right. The daemon
|
||||||
|
* binds each open window to a slot by the OCR'd label order. Extra slots give
|
||||||
|
* headroom for drift; a raise is OCR-verified and re-tried on the next slot.
|
||||||
|
* TUNE against the permanent seat @ 1600x900. */
|
||||||
|
export const VINPIN_TASKBAR_SLOTS = [
|
||||||
|
{ x: 220, y: 884 },
|
||||||
|
{ x: 360, y: 884 },
|
||||||
|
{ x: 500, y: 884 },
|
||||||
|
{ x: 640, y: 884 },
|
||||||
|
{ x: 780, y: 884 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Global "VIN veya katalog ara" router box on the VinPower grid (~1455,96). Only
|
||||||
|
* used to OPEN a brand's window the first time (it routes, it does NOT decode and
|
||||||
|
* the VIN does not carry into the catalog). TUNE. */
|
||||||
|
export const VINPIN_ROUTER_BOX = { x: 1455, y: 96 } as const;
|
||||||
|
|
||||||
|
/** Warm-session daemon tunables. */
|
||||||
|
export const VINPIN_WARM = {
|
||||||
|
/** Business-hours window (Europe/Istanbul) the seat is held warm. */
|
||||||
|
businessStartHour: 8,
|
||||||
|
businessEndHour: 21,
|
||||||
|
/** Idle keepalive cadence — a `mouse.move` every ~75s held the RDS session
|
||||||
|
* 12.6 min with zero disconnect in the live proof. */
|
||||||
|
keepaliveIntervalMs: 75_000,
|
||||||
|
/** Harmless in-window point the keepalive nudges the cursor to. TUNE. */
|
||||||
|
keepalivePoint: { x: 800, y: 500 },
|
||||||
|
/** How often the scheduler reconciles warm-vs-hours. */
|
||||||
|
schedulerIntervalMs: 60_000,
|
||||||
|
/** Retries when verifying/re-trying a taskbar raise across slots. */
|
||||||
|
raiseVerifyRetries: 3,
|
||||||
|
/** After a taskbar-raise click, wait for the window to come forward. */
|
||||||
|
afterRaiseMs: 1_200,
|
||||||
|
/** Backoff after a FAILED warmUp: reconcile() and decode()'s warm-on-demand both
|
||||||
|
* skip re-warming until the cooldown elapses, so a failing seat is not hammered
|
||||||
|
* every ~60s (which leaves a fresh dirty window each attempt). Starts at the base
|
||||||
|
* and doubles per consecutive failure up to the max; a successful warm resets it. */
|
||||||
|
warmBackoffBaseMs: 60_000,
|
||||||
|
warmBackoffMaxMs: 300_000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given the OCR'd taskbar text and the window labels, return the windows in the
|
||||||
|
* left→right order they appear in the bar. Pure + injectable so the brand→slot
|
||||||
|
* binding is unit-testable without a live seat. Windows whose label isn't found
|
||||||
|
* are omitted (the daemon then falls back to open-order for the missing ones).
|
||||||
|
*/
|
||||||
|
export function orderTaskbarWindows(
|
||||||
|
taskbarText: string,
|
||||||
|
keys: readonly ("fiat" | "rpartstore" | "dialogys")[] = ["fiat", "rpartstore", "dialogys"],
|
||||||
|
): ("fiat" | "rpartstore" | "dialogys")[] {
|
||||||
|
const found: { key: "fiat" | "rpartstore" | "dialogys"; idx: number }[] = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
const label = VINPIN_WINDOW_LABELS[key];
|
||||||
|
const m = label.exec(taskbarText);
|
||||||
|
if (m) found.push({ key, idx: m.index });
|
||||||
|
}
|
||||||
|
return found.sort((a, b) => a.idx - b.idx).map((f) => f.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick which warm catalog WINDOW a VIN should be decoded on. Mirrors
|
||||||
|
* `selectVinpinBrandFlow`: Renault/Dacia → the Rpartstore window (Dialogys is the
|
||||||
|
* in-flow fallback), everything else (incl. Fiat) → the ePER window. Pure/testable.
|
||||||
|
*/
|
||||||
|
export function selectVinpinWarmWindow(vin: string): "fiat" | "rpartstore" {
|
||||||
|
return selectVinpinBrandFlow(vin) === "renault" ? "rpartstore" : "fiat";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current hour (0–23) in Europe/Istanbul. Injectable clock for tests. */
|
||||||
|
export function vinpinIstanbulHour(now: Date = new Date()): number {
|
||||||
|
const hourStr = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: "Europe/Istanbul",
|
||||||
|
hour: "numeric",
|
||||||
|
hour12: false,
|
||||||
|
}).format(now);
|
||||||
|
// Some runtimes emit "24" for midnight with hour12:false — normalise to 0–23.
|
||||||
|
return Number.parseInt(hourStr, 10) % 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the seat should be held warm right now (08:00–21:00 Europe/Istanbul). */
|
||||||
|
export function isVinpinBusinessHours(now: Date = new Date()): boolean {
|
||||||
|
const h = vinpinIstanbulHour(now);
|
||||||
|
return h >= VINPIN_WARM.businessStartHour && h < VINPIN_WARM.businessEndHour;
|
||||||
|
}
|
||||||
|
|
||||||
/** Wait budgets (ms) for each step. Verified live @ 1600x900. */
|
/** Wait budgets (ms) for each step. Verified live @ 1600x900. */
|
||||||
export const VINPIN_WAITS = {
|
export const VINPIN_WAITS = {
|
||||||
afterGoto: 7_000,
|
afterGoto: 7_000,
|
||||||
afterLogin: 20_000,
|
/** After submitting the web login: wait for the post-login screen (Horizon
|
||||||
|
* launcher or VinPower brand grid) to render. The real grid render measured
|
||||||
|
* ~32–46s on the permanent seat, so the old 20s always timed out (noise). */
|
||||||
|
afterLogin: 45_000,
|
||||||
|
/** After clicking the launcher's VINPIN app: wait for VinPower to connect and
|
||||||
|
* raise its login dialog (permanent seat only). */
|
||||||
|
afterVinpinLaunch: 14_000,
|
||||||
|
/** After submitting VinPower's login: wait for the brand grid to render. */
|
||||||
|
afterVinPowerLogin: 18_000,
|
||||||
afterFiatOpen: 12_000,
|
afterFiatOpen: 12_000,
|
||||||
afterLangDismiss: 2_500,
|
afterLangDismiss: 2_500,
|
||||||
afterSearchOpen: 5_000,
|
afterSearchOpen: 5_000,
|
||||||
afterVinSubmit: 7_000,
|
afterVinSubmit: 7_000,
|
||||||
afterAlertDismiss: 700,
|
afterAlertDismiss: 250,
|
||||||
|
/** After toggling the Horizon sidebar open/closed (real DOM chrome reveal). */
|
||||||
|
afterSidebarToggle: 1_000,
|
||||||
|
/** Between per-app closes in the Horizon "Running" panel (window teardown settles). */
|
||||||
|
afterRunningAppClose: 2_000,
|
||||||
|
/** OCR poll cadence when waiting for a detectable end-state. Each poll is
|
||||||
|
* CAPPED at the corresponding fixed-wait budget above (kept as a fallback), so
|
||||||
|
* if OCR never catches the transition the total wait == the old fixed sleep and
|
||||||
|
* worst-case behaviour is unchanged. */
|
||||||
|
pollIntervalMs: 700,
|
||||||
|
// ─── Renault flow waits ───
|
||||||
|
/** After clicking the window-close (X) of an open catalog window. */
|
||||||
|
afterWindowClose: 3_500,
|
||||||
|
/** After clicking the Renault brand tile (submenu animates in). */
|
||||||
|
afterRenaultTile: 2_500,
|
||||||
|
/** After picking Rpartstore/Dialogys from the submenu (catalog window opens). */
|
||||||
|
afterRenaultCatalogOpen: 12_000,
|
||||||
|
/** After a Rpartstore (re)open — poll for the search-home landing markers to
|
||||||
|
* confirm it actually LOADED (vs the "born-stuck" infinite spinner). Short so
|
||||||
|
* a stuck instance is detected fast and reopened fresh within the budget. */
|
||||||
|
afterRpartstoreLoad: 12_000,
|
||||||
|
/** After submitting the Rpartstore VIN (Enter) — wait for the vehicle page. */
|
||||||
|
afterRpartstoreSubmit: 8_000,
|
||||||
|
/** After submitting the Dialogys VIN (ПОИСК) — wait for the vehicle page. */
|
||||||
|
afterDialogysSubmit: 8_000,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard wall-clock budget (ms) for a SINGLE decode(vin) call, spanning all of its
|
||||||
|
* internal attempts. A single Vinpin seat is shared by every decode, so one VIN
|
||||||
|
* that gets stuck in a re-establish/relaunch livelock must never grind for
|
||||||
|
* minutes and starve real decodes. When the budget is exceeded the driver aborts,
|
||||||
|
* tears the browser down, poisons the seat (forcing the next decode to cold
|
||||||
|
* re-establish) and returns null. Kept well above the healthy p90 (a warm decode
|
||||||
|
* is seconds; a full cold re-establish is ~60-80s) so it only ever fires on a
|
||||||
|
* genuine stall. Bumped 150s→180s to leave a cheap safety margin for a slow cold
|
||||||
|
* establish stacking on the Rpartstore launch-error probe + the Dialogys fallback
|
||||||
|
* decode (~25s) — the Rpartstore launch-error short-circuit means we no longer
|
||||||
|
* waste ~54s of reopens, but the extra headroom covers a cold-establish edge.
|
||||||
|
* Override with VINPIN_DECODE_BUDGET_MS. */
|
||||||
|
export const VINPIN_DECODE_BUDGET_MS = 180_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rpartstore acquire-with-spinner-guard tunables. A freshly-opened Rpartstore
|
||||||
|
* instance sometimes gets "born stuck" on an infinite spinner (survives
|
||||||
|
* raise/maximize); the only reliable fix is to CLOSE the stuck instance and
|
||||||
|
* reopen a FRESH one. `maxOpens` bounds the initial-open-plus-reopen attempts;
|
||||||
|
* `acquireBudgetMs` sub-caps the whole acquire loop so a hopeless Rpartstore
|
||||||
|
* still leaves wall-clock headroom (inside VINPIN_DECODE_BUDGET_MS) to fall back
|
||||||
|
* to Dialogys rather than burning the entire decode budget on reopens.
|
||||||
|
*/
|
||||||
|
export const VINPIN_RPARTSTORE = {
|
||||||
|
// Rpartstore has two failure modes: a genuine born-stuck spinner (reopen can
|
||||||
|
// recover it) and a hard server-side launch error "Ошибка запуска каталога"
|
||||||
|
// (reopen NEVER helps — Rpartstore is DOWN). Detecting the tiny centered error
|
||||||
|
// modal by OCR proved unreliable across renderings, and burning reopens on a
|
||||||
|
// DOWN Rpartstore (a) wastes ~54s and (b) dirties the RDS desktop so the
|
||||||
|
// Dialogys fallback then can't finish inside the budget.
|
||||||
|
// `maxOpens` still bounds reopen attempts (a real transient spinner can recover
|
||||||
|
// on a reopen), but `acquireBudgetMs` is the hard lever: one open+load-poll is
|
||||||
|
// ~22s, so a 14s wall-clock sub-cap makes the loop bail after the FIRST open if
|
||||||
|
// it hasn't loaded — straight to Dialogys on a still-clean seat (~25–52s decode)
|
||||||
|
// — instead of grinding 3 reopens. A healthy Rpartstore loads within the first
|
||||||
|
// open's poll and is used as before; the cap only prevents wasted reopens.
|
||||||
|
maxOpens: 3,
|
||||||
|
acquireBudgetMs: 14_000,
|
||||||
|
/** In-memory cooldown after a Rpartstore HARD launch-error (or repeated
|
||||||
|
* load-failure across all reopens): Rpartstore-down is server-side and
|
||||||
|
* PERSISTENT, so reopening it on every Renault VIN just re-dirties the RDS
|
||||||
|
* desktop (each failed open leaves a resumed window / launch-error modal). While
|
||||||
|
* the cooldown is active, Renault decodes route STRAIGHT to the proven ~30s
|
||||||
|
* Dialogys path (Rpartstore is never opened). A successful Rpartstore load clears
|
||||||
|
* it immediately, so a transient blip self-heals on the next decode. */
|
||||||
|
launchErrorCooldownMs: 10 * 60_000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Per-key delay (ms) when typing a VIN into a focused canvas field. A focused
|
||||||
|
* field keeps up at ~20ms; the old 45-50ms was conservative padding (17-char VIN
|
||||||
|
* ≈ 340ms vs ≈ 850ms). */
|
||||||
|
export const VINPIN_TYPE_DELAY_MS = 20;
|
||||||
|
|
||||||
|
/** Backspaces used to clear a VIN field after Ctrl+A + Delete. A few for
|
||||||
|
* insurance on a focused field — the old 40 was overkill. */
|
||||||
|
export const VINPIN_FIELD_CLEAR_BACKSPACES = 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Known Fiat model tokens used to extract the model name from the decode modal
|
* Known Fiat model tokens used to extract the model name from the decode modal
|
||||||
* text. The modal interleaves Cyrillic boilerplate, a platform code, the model
|
* text. The modal interleaves Cyrillic boilerplate, a platform code, the model
|
||||||
@@ -102,11 +525,92 @@ export const FIAT_MODEL_TOKENS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brands for which the Vinpin fallback is enabled. Fiat-only for now (the
|
* Known Renault/Dacia model tokens used to extract the model name from the
|
||||||
* Egea/NM4356 no-catalog cluster); a Set so it's trivially extensible later.
|
* Rpartstore/Dialogys vehicle-page header. Same token-matching strategy as the
|
||||||
* Compared case-insensitively against the canonical browse brand.
|
* Fiat parser (far more robust than positional parsing against OCR noise).
|
||||||
|
* Generation numbers / roman numerals in the header are ignored — PL24 catalog
|
||||||
|
* rows carry their own generation suffixes and matching is best-effort.
|
||||||
*/
|
*/
|
||||||
export const VINPIN_BRAND_ALLOWLIST = new Set<string>(["fiat"]);
|
export const RENAULT_MODEL_TOKENS = [
|
||||||
|
"CLIO",
|
||||||
|
"LUTECIA", // Clio's JP/TR export name — appears in PL24 rows ("CLIO 4 / LUTECIA 4")
|
||||||
|
"MEGANE",
|
||||||
|
"SCENIC",
|
||||||
|
"KANGOO",
|
||||||
|
"LAGUNA",
|
||||||
|
"LATITUDE",
|
||||||
|
"SAFRANE",
|
||||||
|
"FLUENCE",
|
||||||
|
"SYMBOL",
|
||||||
|
"THALIA",
|
||||||
|
"KADJAR",
|
||||||
|
"CAPTUR",
|
||||||
|
"DUSTER",
|
||||||
|
"SANDERO",
|
||||||
|
"LOGAN",
|
||||||
|
"TALISMAN",
|
||||||
|
"ESPACE",
|
||||||
|
"TWINGO",
|
||||||
|
"MODUS",
|
||||||
|
"KOLEOS",
|
||||||
|
"TRAFIC",
|
||||||
|
"MASTER",
|
||||||
|
"TALIANT",
|
||||||
|
"ARKANA",
|
||||||
|
"AUSTRAL",
|
||||||
|
"VELSATIS",
|
||||||
|
"TWIZY",
|
||||||
|
"ZOE",
|
||||||
|
"EXPRESS",
|
||||||
|
// Dacia
|
||||||
|
"DOKKER",
|
||||||
|
"LODGY",
|
||||||
|
"SPRING",
|
||||||
|
// Old R-number platforms + TR-market badges (pre-2000). These genuinely need
|
||||||
|
// Rpartstore to VIN-resolve, so this mostly future-proofs the header parse for
|
||||||
|
// an old Renault (e.g. VF1553… R19). The parser tokenises on non-alphanumerics,
|
||||||
|
// so a BARE "19" would collide with year/engine digits — the R-prefixed form
|
||||||
|
// ("R19") is required so it only matches the actual model badge, never a number.
|
||||||
|
"R5",
|
||||||
|
"R9",
|
||||||
|
"R11",
|
||||||
|
"R12",
|
||||||
|
"R19",
|
||||||
|
"R21",
|
||||||
|
"R25",
|
||||||
|
"EUROPA", // R19 Europa (TR)
|
||||||
|
"BROADWAY", // R9 Broadway (TR)
|
||||||
|
"TOROS", // R12 Toros (TR)
|
||||||
|
"FLASH", // R11 Flash (TR)
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** WMIs that route to the Renault (Rpartstore/Dialogys) flow. VF1/VF2 are the
|
||||||
|
* common Renault WMIs; VF6/VF7 are included per the benchmark (some Renault/
|
||||||
|
* Dacia LCVs and older platforms). Dacia-badged cars often carry a Renault WMI
|
||||||
|
* (e.g. VF1 Duster) — the RENAULT-vs-DACIA distinction is read from the decode
|
||||||
|
* header, not the WMI. UU1 is the dedicated Dacia WMI. */
|
||||||
|
const RENAULT_FLOW_WMIS = new Set<string>(["VF1", "VF2", "VF6", "VF7", "UU1"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the Vinpin catalog flow for a VIN by brand. Renault/Dacia → the new
|
||||||
|
* Rpartstore/Dialogys flow; everything else (including Fiat) → the existing ePER
|
||||||
|
* flow, so Fiat behaviour is byte-identical. Derived from the WMI: trusts
|
||||||
|
* `getBrandFromWmi` first (Renault/Dacia), then a Renault-WMI allowlist.
|
||||||
|
*/
|
||||||
|
export function selectVinpinBrandFlow(vin: string): "renault" | "fiat" {
|
||||||
|
const wmi = (vin || "").toUpperCase().slice(0, 3);
|
||||||
|
const brand = getBrandFromWmi(wmi);
|
||||||
|
if (brand === "Renault" || brand === "Dacia") return "renault";
|
||||||
|
if (RENAULT_FLOW_WMIS.has(wmi)) return "renault";
|
||||||
|
return "fiat";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brands for which the Vinpin fallback is enabled. Fiat (Egea/NM4356 no-catalog
|
||||||
|
* cluster) + Renault/Dacia (Rpartstore/Dialogys flow). A Set so it's trivially
|
||||||
|
* extensible. Compared case-insensitively against the canonical browse brand.
|
||||||
|
*/
|
||||||
|
export const VINPIN_BRAND_ALLOWLIST = new Set<string>(["fiat", "renault", "dacia"]);
|
||||||
|
|
||||||
export function isVinpinBrandAllowed(brand: string | null | undefined): boolean {
|
export function isVinpinBrandAllowed(brand: string | null | undefined): boolean {
|
||||||
if (!brand) return false;
|
if (!brand) return false;
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ describe("modelTokens", () => {
|
|||||||
expect(modelTokens("Tipo-Egea MCA (2020-....)")).toEqual(["TIPO", "EGEA", "MCA"]);
|
expect(modelTokens("Tipo-Egea MCA (2020-....)")).toEqual(["TIPO", "EGEA", "MCA"]);
|
||||||
expect(modelTokens("DOBLO REST. 2005 (2005-2016)")).toEqual(["DOBLO", "REST"]);
|
expect(modelTokens("DOBLO REST. 2005 (2005-2016)")).toEqual(["DOBLO", "REST"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps engine displacements as tokens (only 1950-2039 count as years)", () => {
|
||||||
|
// The 4-digit engine sizes must survive — dropping them made the ancient
|
||||||
|
// "TIPO 1100-1370-1580" collapse to just ["TIPO"] and win the tiebreak.
|
||||||
|
expect(modelTokens("TIPO 1100-1370-1580 (1987-1993)")).toEqual([
|
||||||
|
"TIPO",
|
||||||
|
"1100",
|
||||||
|
"1370",
|
||||||
|
"1580",
|
||||||
|
]);
|
||||||
|
expect(modelTokens("TIPO 1750-2000 (1990-1993)")).toEqual(["TIPO", "1750"]); // 2000 = year-range
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseYearRange", () => {
|
describe("parseYearRange", () => {
|
||||||
@@ -52,4 +64,124 @@ describe("pickBestCatalogMatch", () => {
|
|||||||
it("returns null for an empty model", () => {
|
it("returns null for an empty model", () => {
|
||||||
expect(pickBestCatalogMatch({ model: null, modelYear: "2018" }, candidates)).toBeNull();
|
expect(pickBestCatalogMatch({ model: null, modelYear: "2018" }, candidates)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("never falls back onto an ancient catalog for a bare, year-less model", () => {
|
||||||
|
const withAncient: CatalogCandidate[] = [
|
||||||
|
...candidates,
|
||||||
|
{ id: "tipo-1987", model: "TIPO 1100-1370-1580 (1987-1993)", year: "1987-1993" },
|
||||||
|
];
|
||||||
|
// Bare "TIPO", no year (the exact shape of the NM4131 false positive that
|
||||||
|
// used to land on the 1987 Tipo). Must resolve to a modern Egea instead.
|
||||||
|
const id = pickBestCatalogMatch({ model: "TIPO", modelYear: null }, withAncient);
|
||||||
|
expect(id).not.toBe("tipo-1987");
|
||||||
|
expect(["egea", "egea-mca"]).toContain(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("breaks a score tie toward the newer catalog", () => {
|
||||||
|
const puntos: CatalogCandidate[] = [
|
||||||
|
{ id: "punto-old", model: "PUNTO (1993-1999)", year: "1993-1999" },
|
||||||
|
{ id: "punto-new", model: "PUNTO (2012-2018)", year: "2012-2018" },
|
||||||
|
];
|
||||||
|
// Identical tokens + no year → equal score → recency decides.
|
||||||
|
expect(pickBestCatalogMatch({ model: "PUNTO", modelYear: null }, puntos)).toBe("punto-new");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("breaks a score tie toward the richer catalog (more categories)", () => {
|
||||||
|
const koleos: CatalogCandidate[] = [
|
||||||
|
{ id: "koleos-thin", model: "KOLEOS", year: null, categoryCount: 12 },
|
||||||
|
{ id: "koleos-full", model: "KOLEOS", year: null, categoryCount: 44 },
|
||||||
|
];
|
||||||
|
// Identical tokens + no year → equal score → category count decides.
|
||||||
|
expect(pickBestCatalogMatch({ model: "KOLEOS", modelYear: null }, koleos)).toBe("koleos-full");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pickBestCatalogMatch — market-qualifier precision (Renault/Dacia)", () => {
|
||||||
|
it("prefers the EXACT model over a wrong-market superset (KADJAR beats KADJAR ÇİN)", () => {
|
||||||
|
// The confirmed prod bug: a European VF1 Renault decoded as "KADJAR" landed
|
||||||
|
// on the China-market "KADJAR ÇİN" catalog (fewer categories, wrong parts).
|
||||||
|
const candidates: CatalogCandidate[] = [
|
||||||
|
{ id: "kadjar-cn", model: "KADJAR ÇİN", year: null, categoryCount: 22 },
|
||||||
|
{ id: "kadjar-eu", model: "KADJAR", year: null, categoryCount: 44 },
|
||||||
|
];
|
||||||
|
expect(pickBestCatalogMatch({ model: "KADJAR", modelYear: "2018" }, candidates)).toBe(
|
||||||
|
"kadjar-eu",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still picks the exact model even when the market candidate is richer", () => {
|
||||||
|
// Market penalty must dominate — an exact match wins regardless of category
|
||||||
|
// count or ordering.
|
||||||
|
const candidates: CatalogCandidate[] = [
|
||||||
|
{ id: "kadjar-cn", model: "KADJAR ÇİN", year: null, categoryCount: 999 },
|
||||||
|
{ id: "kadjar-eu", model: "KADJAR", year: null, categoryCount: 1 },
|
||||||
|
];
|
||||||
|
expect(pickBestCatalogMatch({ model: "KADJAR", modelYear: null }, candidates)).toBe(
|
||||||
|
"kadjar-eu",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("penalizes a market qualifier but NOT a generation token (CLIO)", () => {
|
||||||
|
const candidates: CatalogCandidate[] = [
|
||||||
|
{ id: "clio-cn", model: "CLIO ÇİN", year: null },
|
||||||
|
{ id: "clio", model: "CLIO", year: null },
|
||||||
|
];
|
||||||
|
// Exact "CLIO" beats the China-market superset.
|
||||||
|
expect(pickBestCatalogMatch({ model: "CLIO", modelYear: null }, candidates)).toBe("clio");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps generations matchable — a generation extra token is not a market penalty", () => {
|
||||||
|
// Decoded "CLIO" with only generation-qualified catalogs (no exact bare CLIO)
|
||||||
|
// and one China catalog. The generation candidate must win over the market one,
|
||||||
|
// proving the market penalty doesn't collateral-damage generations.
|
||||||
|
const candidates: CatalogCandidate[] = [
|
||||||
|
{ id: "clio-cn", model: "CLIO ÇİN", year: null },
|
||||||
|
{
|
||||||
|
id: "clio-4",
|
||||||
|
model: "CLIO 4 / LUTECIA 4 (2012-2019)",
|
||||||
|
year: "2012-2019",
|
||||||
|
categoryCount: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "clio-3",
|
||||||
|
model: "CLIO 3 / LUTECIA 3 (2005-2014)",
|
||||||
|
year: "2005-2014",
|
||||||
|
categoryCount: 30,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const id = pickBestCatalogMatch({ model: "CLIO", modelYear: "2015" }, candidates);
|
||||||
|
// Year-overlap picks the 2012-2019 gen-4; the China catalog is penalized out.
|
||||||
|
expect(id).toBe("clio-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches an exact generation model and penalizes only the market superset (DUSTER II)", () => {
|
||||||
|
// Roman-numeral generations survive tokenization (single digits are dropped by
|
||||||
|
// the length>=2 filter, an existing behavior). Decoded "DUSTER II" is exact on
|
||||||
|
// duster-2; "DUSTER II ÇİN" carries an extra market token and is penalized out.
|
||||||
|
const candidates: CatalogCandidate[] = [
|
||||||
|
{ id: "duster-2", model: "DUSTER II", year: null, categoryCount: 30 },
|
||||||
|
{ id: "duster-cn", model: "DUSTER II ÇİN", year: null, categoryCount: 30 },
|
||||||
|
];
|
||||||
|
expect(pickBestCatalogMatch({ model: "DUSTER II", modelYear: null }, candidates)).toBe(
|
||||||
|
"duster-2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a market catalog only when it is the sole candidate", () => {
|
||||||
|
// If the ONLY catalog for a decoded model is a regional one, still match it —
|
||||||
|
// the penalty lowers the score but does not disqualify.
|
||||||
|
const candidates: CatalogCandidate[] = [{ id: "x62-cn", model: "X62 CHINE", year: null }];
|
||||||
|
expect(pickBestCatalogMatch({ model: "X62", modelYear: null }, candidates)).toBe("x62-cn");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("modelTokens — diacritic folding", () => {
|
||||||
|
it("folds Turkish diacritics so market qualifiers survive as ASCII tokens", () => {
|
||||||
|
// Without folding, ÇİN loses Ç/İ to the ASCII strip and collapses to a
|
||||||
|
// dropped 1-char "N", making KADJAR ÇİN tokenize identically to KADJAR.
|
||||||
|
expect(modelTokens("KADJAR ÇİN")).toEqual(["KADJAR", "CIN"]);
|
||||||
|
expect(modelTokens("ARKANA RUSYA")).toEqual(["ARKANA", "RUSYA"]);
|
||||||
|
// The single-digit "2" is dropped by the length>=2 filter (existing behavior).
|
||||||
|
expect(modelTokens("KOLEOS 2 - ÇİN")).toEqual(["KOLEOS", "CIN"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,26 +18,96 @@ export interface CatalogCandidate {
|
|||||||
id: string;
|
id: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
year: string | null;
|
year: string | null;
|
||||||
|
/** How many catalog categories this vehicle has fetched. Used only as a final
|
||||||
|
* tiebreak among otherwise-equal candidates — the fuller catalog is the
|
||||||
|
* mainstream one. Optional so the pure picker can be unit-tested without it. */
|
||||||
|
categoryCount?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Market/region qualifier tokens (diacritic-folded, upper-case). A catalog model
|
||||||
|
* that carries one of these tokens BEYOND the decoded model targets a specific
|
||||||
|
* regional market — e.g. "KADJAR ÇİN" is the China-market Kadjar catalog (source
|
||||||
|
* XZH, 22 categories) vs the mainstream European "KADJAR" (XFE, 44 categories).
|
||||||
|
* For a European (VF1…) VIN the regional catalog serves the wrong parts, so a
|
||||||
|
* candidate whose EXTRA tokens are region qualifiers is heavily penalized — but
|
||||||
|
* only when the token is extra (the decode itself doesn't carry a market today).
|
||||||
|
*
|
||||||
|
* Generation tokens (roman numerals, digits, platform codes like "II"/"3"/"XM3")
|
||||||
|
* are deliberately NOT here, so multi-generation models stay fully matchable;
|
||||||
|
* only MARKET qualifiers get the penalty.
|
||||||
|
*
|
||||||
|
* Grounded in prod data — the real Renault/Dacia catalog models that carry a
|
||||||
|
* region qualifier are: KADJAR ÇİN, CAPTUR II ÇİN, KOLEOS 2 - ÇİN, ARKANA RUSYA
|
||||||
|
* and X62 CHINE. ("EUROPE" appears too — ARKANA EUROPE, CAPTUR II EUROPE — but
|
||||||
|
* that is the mainstream market and is never penalized.)
|
||||||
|
*/
|
||||||
|
export const MARKET_QUALIFIERS = new Set<string>([
|
||||||
|
// China — "ÇİN" folds to CIN via diacritic normalization; CHINE is the French
|
||||||
|
// spelling seen in "X62 CHINE".
|
||||||
|
"CIN",
|
||||||
|
"CHINA",
|
||||||
|
"CHINE",
|
||||||
|
"CN",
|
||||||
|
// Russia
|
||||||
|
"RUSYA",
|
||||||
|
"RUSSIA",
|
||||||
|
"RU",
|
||||||
|
// Brazil
|
||||||
|
"BREZILYA",
|
||||||
|
"BRAZIL",
|
||||||
|
"BR",
|
||||||
|
// India
|
||||||
|
"HINDISTAN",
|
||||||
|
"INDIA",
|
||||||
|
"IN",
|
||||||
|
// Korea
|
||||||
|
"KORE",
|
||||||
|
"KOREA",
|
||||||
|
// Mexico / Mercosur
|
||||||
|
"MEKSIKA",
|
||||||
|
"MEXICO",
|
||||||
|
"MERCOSUR",
|
||||||
|
// Other regional
|
||||||
|
"GCC",
|
||||||
|
"USA",
|
||||||
|
"AMERIKA",
|
||||||
|
]);
|
||||||
|
|
||||||
export interface DecodedForMatch {
|
export interface DecodedForMatch {
|
||||||
model: string | null;
|
model: string | null;
|
||||||
modelYear: string | null;
|
modelYear: string | null;
|
||||||
|
/** Brand to match against (catalog_vehicles.brand_name). Defaults to Fiat when
|
||||||
|
* omitted, preserving the original Fiat-only behaviour. */
|
||||||
|
brand?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tokenize a model string into upper-case model tokens, dropping parenthetical
|
/** Tokenize a model string into upper-case model tokens, dropping parenthetical
|
||||||
* groups (year ranges) and bare 4-digit years. "TIPO - EGEA (2015-2021)" →
|
* groups (year ranges) and bare 4-digit *years*. "TIPO - EGEA (2015-2021)" →
|
||||||
* ["TIPO","EGEA"]. Keeps "500" / "500X" (not 4-digit years). */
|
* ["TIPO","EGEA"]. Keeps "500"/"500X" AND engine displacements like 1100 / 1370
|
||||||
|
* / 1580 / 2400 — only 1950-2039 (plausible car years) are treated as years.
|
||||||
|
* Dropping displacements made ancient catalogs ("TIPO 1100-1370-1580") collapse
|
||||||
|
* to just ["TIPO"], looking falsely specific and winning the fewest-extra-tokens
|
||||||
|
* tiebreak over the modern Egea. */
|
||||||
export function modelTokens(s: string | null | undefined): string[] {
|
export function modelTokens(s: string | null | undefined): string[] {
|
||||||
if (!s) return [];
|
if (!s) return [];
|
||||||
return s
|
return (
|
||||||
.toUpperCase()
|
s
|
||||||
.replace(/\([^)]*\)/g, " ") // drop parenthetical year ranges
|
.toUpperCase()
|
||||||
.replace(/\b\d{4}\b/g, " ") // drop bare years (keeps 3-digit "500")
|
// Fold diacritics to ASCII so market qualifiers survive tokenization. Without
|
||||||
.replace(/[^A-Z0-9]+/g, " ")
|
// this the Turkish "ÇİN" (China) loses its Ç/İ to the [^A-Z0-9] strip below
|
||||||
.split(" ")
|
// and collapses to a dropped 1-char "N" — making "KADJAR ÇİN" tokenize
|
||||||
.map((t) => t.trim())
|
// IDENTICALLY to "KADJAR" and defeating both the specificity tiebreak and the
|
||||||
.filter((t) => t.length >= 2);
|
// market penalty. NFD + combining-mark removal maps ÇİN→CIN, Ş→S, Ğ→G, Ö→O …
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/\p{M}/gu, "") // strip the combining marks NFD split off
|
||||||
|
.replace(/\([^)]*\)/g, " ") // drop parenthetical year ranges
|
||||||
|
.replace(/\b(19[5-9]\d|20[0-3]\d)\b/g, " ") // drop bare YEARS 1950-2039; keep displacements
|
||||||
|
.replace(/[^A-Z0-9]+/g, " ")
|
||||||
|
.split(" ")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => t.length >= 2)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a year range from free text. Returns {start, end} where end===null
|
/** Parse a year range from free text. Returns {start, end} where end===null
|
||||||
@@ -70,8 +140,18 @@ function yearInRange(year: number, range: { start: number; end: number | null })
|
|||||||
/**
|
/**
|
||||||
* Pick the best catalog_vehicles row for a decoded model + year. A candidate
|
* Pick the best catalog_vehicles row for a decoded model + year. A candidate
|
||||||
* qualifies only when EVERY decoded model token appears in the candidate's
|
* qualifies only when EVERY decoded model token appears in the candidate's
|
||||||
* model tokens (subset match). Among qualifiers, prefer year-range overlap,
|
* model tokens (subset match). Scoring, strongest signal first:
|
||||||
* then the most specific (fewest extra tokens). Returns the id or null.
|
* 1. Year-range overlap — the strongest signal; routes multi-generation models
|
||||||
|
* (a 2022 "TIPO-EGEA" → the 2020+ MCA catalog, not the 2015-2021 one).
|
||||||
|
* 2. EXACT normalized model match (candidate tokens ≡ decoded tokens) — a
|
||||||
|
* smaller bonus that breaks the tie in favour of a plain "KADJAR" over the
|
||||||
|
* superset "KADJAR ÇİN" when no year distinguishes them.
|
||||||
|
* 3. Market-qualifier penalty — extra tokens that are region qualifiers (ÇİN,
|
||||||
|
* RUSYA, …) make this a WRONG-market catalog; penalized hard so it only ever
|
||||||
|
* wins as the sole candidate. Generation / other extra tokens keep only the
|
||||||
|
* mild "fewer extras = more specific" penalty, so generations stay matchable.
|
||||||
|
* 4. Tiebreaks on equal score: richer catalog (more categories), then newer.
|
||||||
|
* Returns the id or null.
|
||||||
*/
|
*/
|
||||||
export function pickBestCatalogMatch(
|
export function pickBestCatalogMatch(
|
||||||
decoded: DecodedForMatch,
|
decoded: DecodedForMatch,
|
||||||
@@ -81,7 +161,12 @@ export function pickBestCatalogMatch(
|
|||||||
if (decTokens.length === 0) return null;
|
if (decTokens.length === 0) return null;
|
||||||
const decYear = decoded.modelYear ? Number.parseInt(decoded.modelYear, 10) : null;
|
const decYear = decoded.modelYear ? Number.parseInt(decoded.modelYear, 10) : null;
|
||||||
|
|
||||||
let best: { id: string; score: number } | null = null;
|
let best: {
|
||||||
|
id: string;
|
||||||
|
score: number;
|
||||||
|
categoryCount: number;
|
||||||
|
startYear: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
for (const cand of candidates) {
|
for (const cand of candidates) {
|
||||||
const candText = `${cand.model ?? ""} ${cand.year ?? ""}`;
|
const candText = `${cand.model ?? ""} ${cand.year ?? ""}`;
|
||||||
@@ -91,17 +176,47 @@ export function pickBestCatalogMatch(
|
|||||||
// Subset requirement — all decoded model tokens must be present.
|
// Subset requirement — all decoded model tokens must be present.
|
||||||
if (!decTokens.every((t) => candSet.has(t))) continue;
|
if (!decTokens.every((t) => candSet.has(t))) continue;
|
||||||
|
|
||||||
|
const extraTokens = candTokens.filter((t) => !decTokens.includes(t));
|
||||||
|
|
||||||
let score = 1000; // base for any qualifying candidate
|
let score = 1000; // base for any qualifying candidate
|
||||||
|
|
||||||
|
// (1) EXACT normalized model-name match: candidate carries NO tokens beyond
|
||||||
|
// the decoded model. This breaks the tie in favour of the plain model over a
|
||||||
|
// superset (e.g. "KADJAR" over "KADJAR ÇİN" when neither has a distinguishing
|
||||||
|
// year). Kept SMALLER than the year-range swing (±700) on purpose, so a
|
||||||
|
// decisive model-year still routes generations correctly — a 2022 "TIPO-EGEA"
|
||||||
|
// must still land on the 2020+ MCA catalog, not the exact-named 2015-2021 one.
|
||||||
|
if (extraTokens.length === 0) score += 300;
|
||||||
|
|
||||||
|
// (2) Year-range overlap.
|
||||||
const range = parseYearRange(candText);
|
const range = parseYearRange(candText);
|
||||||
if (decYear && Number.isFinite(decYear) && range) {
|
if (decYear && Number.isFinite(decYear) && range) {
|
||||||
score += yearInRange(decYear, range) ? 500 : -200;
|
score += yearInRange(decYear, range) ? 500 : -200;
|
||||||
}
|
}
|
||||||
// Tiebreak: fewer extra tokens = more specific match.
|
|
||||||
const extra = candTokens.filter((t) => !decTokens.includes(t)).length;
|
|
||||||
score -= extra;
|
|
||||||
|
|
||||||
if (!best || score > best.score) {
|
// (3) Extra-token penalties. A region-qualifier extra token means the
|
||||||
best = { id: cand.id, score };
|
// candidate is a wrong-market catalog for a decode that carries no market —
|
||||||
|
// penalize hard so it can only win when it is the ONLY candidate. Everything
|
||||||
|
// else (generation numerals, platform codes) keeps the mild specificity
|
||||||
|
// penalty, leaving multi-generation models fully matchable.
|
||||||
|
const marketExtras = extraTokens.filter((t) => MARKET_QUALIFIERS.has(t)).length;
|
||||||
|
const otherExtras = extraTokens.length - marketExtras;
|
||||||
|
score -= otherExtras;
|
||||||
|
score -= marketExtras * 5000;
|
||||||
|
|
||||||
|
// (4) Tiebreaks on equal score: prefer the richer catalog (more categories —
|
||||||
|
// the fuller catalog is the mainstream one), then the newer generation. A
|
||||||
|
// bare, year-less model (e.g. "TIPO") must never fall back onto an ancient
|
||||||
|
// generation — modern is the overwhelmingly likelier intent.
|
||||||
|
const categoryCount = cand.categoryCount ?? 0;
|
||||||
|
const startYear = range?.start ?? 0;
|
||||||
|
const better =
|
||||||
|
!best ||
|
||||||
|
score > best.score ||
|
||||||
|
(score === best.score && categoryCount > best.categoryCount) ||
|
||||||
|
(score === best.score && categoryCount === best.categoryCount && startYear > best.startYear);
|
||||||
|
if (better) {
|
||||||
|
best = { id: cand.id, score, categoryCount, startYear };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,25 +230,35 @@ export class VinpinCatalogMatcher {
|
|||||||
constructor(@Inject(DATABASE) private readonly db: Database) {}
|
constructor(@Inject(DATABASE) private readonly db: Database) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the best PL24 Fiat catalog_vehicle for a decoded model + year.
|
* Find the best PL24 catalog_vehicle (of the decoded brand — Fiat by default,
|
||||||
* Returns the catalog_vehicle id, or null when nothing matches.
|
* or Renault/Dacia) for a decoded model + year. Returns the id, or null.
|
||||||
*/
|
*/
|
||||||
async match(decoded: DecodedForMatch): Promise<string | null> {
|
async match(decoded: DecodedForMatch): Promise<string | null> {
|
||||||
if (!decoded.model) return null;
|
if (!decoded.model) return null;
|
||||||
|
const brand = (decoded.brand ?? "Fiat").toLowerCase();
|
||||||
const rows = await this.db
|
const rows = await this.db
|
||||||
.select({
|
.select({
|
||||||
id: catalogVehicles.id,
|
id: catalogVehicles.id,
|
||||||
model: catalogVehicles.model,
|
model: catalogVehicles.model,
|
||||||
year: catalogVehicles.year,
|
year: catalogVehicles.year,
|
||||||
|
// Category count feeds the richer-catalog tiebreak (more categories = the
|
||||||
|
// mainstream catalog). Correlated subquery keeps the row shape flat.
|
||||||
|
categoryCount: sql<number>`(
|
||||||
|
SELECT count(*)::int FROM categories
|
||||||
|
WHERE categories.catalog_vehicle_id = ${catalogVehicles.id}
|
||||||
|
)`,
|
||||||
})
|
})
|
||||||
.from(catalogVehicles)
|
.from(catalogVehicles)
|
||||||
.where(
|
.where(
|
||||||
and(sql`lower(${catalogVehicles.brandName}) = 'fiat'`, eq(catalogVehicles.source, "pl24")),
|
and(
|
||||||
|
sql`lower(${catalogVehicles.brandName}) = ${brand}`,
|
||||||
|
eq(catalogVehicles.source, "pl24"),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const id = pickBestCatalogMatch(decoded, rows as CatalogCandidate[]);
|
const id = pickBestCatalogMatch(decoded, rows as CatalogCandidate[]);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`match model="${decoded.model}" year=${decoded.modelYear ?? "?"} → ${id ?? "null"} (over ${rows.length} Fiat catalog vehicles)`,
|
`match brand=${brand} model="${decoded.model}" year=${decoded.modelYear ?? "?"} → ${id ?? "null"} (over ${rows.length} ${brand} catalog vehicles)`,
|
||||||
);
|
);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|||||||
76
apps/api/src/integrations/vinpin/vinpin.ocr.spec.ts
Normal file
76
apps/api/src/integrations/vinpin/vinpin.ocr.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { pollForText } from "./vinpin.ocr";
|
||||||
|
|
||||||
|
/** Deterministic fake clock: `sleep` advances virtual time; `now` reads it. */
|
||||||
|
function makeClock() {
|
||||||
|
let t = 0;
|
||||||
|
const slept: number[] = [];
|
||||||
|
return {
|
||||||
|
now: () => t,
|
||||||
|
sleep: async (ms: number) => {
|
||||||
|
slept.push(ms);
|
||||||
|
t += ms;
|
||||||
|
},
|
||||||
|
slept,
|
||||||
|
total: () => slept.reduce((a, b) => a + b, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("pollForText", () => {
|
||||||
|
it("returns early as soon as the predicate matches (fast path)", async () => {
|
||||||
|
const clock = makeClock();
|
||||||
|
const reads = ["no", "no", "hit"];
|
||||||
|
let i = 0;
|
||||||
|
const res = await pollForText({
|
||||||
|
read: async () => reads[i++] ?? "",
|
||||||
|
predicate: (t) => t === "hit",
|
||||||
|
capMs: 10_000,
|
||||||
|
intervalMs: 700,
|
||||||
|
sleep: clock.sleep,
|
||||||
|
now: clock.now,
|
||||||
|
});
|
||||||
|
expect(res.matched).toBe(true);
|
||||||
|
expect(res.text).toBe("hit");
|
||||||
|
// Stopped at the 3rd read — well under the cap.
|
||||||
|
expect(i).toBe(3);
|
||||||
|
expect(clock.total()).toBe(2100);
|
||||||
|
expect(clock.total()).toBeLessThan(10_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps total sleep at capMs when the predicate never matches (fallback == old fixed wait)", async () => {
|
||||||
|
const clock = makeClock();
|
||||||
|
let reads = 0;
|
||||||
|
const res = await pollForText({
|
||||||
|
read: async () => {
|
||||||
|
reads++;
|
||||||
|
return "no";
|
||||||
|
},
|
||||||
|
predicate: (t) => t === "hit",
|
||||||
|
capMs: 2_000,
|
||||||
|
intervalMs: 700,
|
||||||
|
sleep: clock.sleep,
|
||||||
|
now: clock.now,
|
||||||
|
});
|
||||||
|
expect(res.matched).toBe(false);
|
||||||
|
expect(res.text).toBe("no");
|
||||||
|
// Total sleep is exactly the cap — never longer than the old fixed wait.
|
||||||
|
expect(clock.total()).toBe(2_000);
|
||||||
|
// The final interval was clamped to the remaining budget (no overshoot).
|
||||||
|
expect(Math.max(...clock.slept)).toBeLessThanOrEqual(700);
|
||||||
|
expect(reads).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches on the very first read without over-sleeping", async () => {
|
||||||
|
const clock = makeClock();
|
||||||
|
const res = await pollForText({
|
||||||
|
read: async () => "ready",
|
||||||
|
predicate: (t) => t.includes("ready"),
|
||||||
|
capMs: 12_000,
|
||||||
|
intervalMs: 750,
|
||||||
|
sleep: clock.sleep,
|
||||||
|
now: clock.now,
|
||||||
|
});
|
||||||
|
expect(res.matched).toBe(true);
|
||||||
|
expect(clock.total()).toBe(750); // one interval, then matched
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -133,6 +133,43 @@ export async function ocrRegion(page: Page, clip?: OcrClip, scale = 3): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll a text `read` until `predicate(text)` holds, or the `capMs` budget is
|
||||||
|
* exhausted. Returns as soon as the predicate matches (the common, fast case);
|
||||||
|
* otherwise the total elapsed sleep equals `capMs` — the SAME fallback wait the
|
||||||
|
* old fixed `waitForTimeout(capMs)` used, so worst-case behaviour is unchanged.
|
||||||
|
*
|
||||||
|
* Pure/injectable (no Playwright/OCR deps) so it can be unit-tested directly:
|
||||||
|
* pass a fake `read`, `sleep` and `now`. The driver wraps it with an OCR read.
|
||||||
|
*/
|
||||||
|
export interface PollForTextOptions {
|
||||||
|
read: () => Promise<string>;
|
||||||
|
predicate: (text: string) => boolean;
|
||||||
|
/** Fallback cap — total sleep never exceeds this (== the old fixed wait). */
|
||||||
|
capMs: number;
|
||||||
|
/** Cadence between reads. */
|
||||||
|
intervalMs: number;
|
||||||
|
sleep: (ms: number) => Promise<void>;
|
||||||
|
/** Injectable clock (defaults to Date.now) — for deterministic tests. */
|
||||||
|
now?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollForText(
|
||||||
|
opts: PollForTextOptions,
|
||||||
|
): Promise<{ matched: boolean; text: string }> {
|
||||||
|
const now = opts.now ?? (() => Date.now());
|
||||||
|
const interval = Math.max(1, opts.intervalMs);
|
||||||
|
const deadline = now() + opts.capMs;
|
||||||
|
let text = "";
|
||||||
|
while (now() < deadline) {
|
||||||
|
const remaining = deadline - now();
|
||||||
|
await opts.sleep(Math.min(interval, remaining));
|
||||||
|
text = await opts.read();
|
||||||
|
if (opts.predicate(text)) return { matched: true, text };
|
||||||
|
}
|
||||||
|
return { matched: false, text };
|
||||||
|
}
|
||||||
|
|
||||||
/** Tear down the shared tesseract worker (best-effort). */
|
/** Tear down the shared tesseract worker (best-effort). */
|
||||||
export async function terminateOcr(): Promise<void> {
|
export async function terminateOcr(): Promise<void> {
|
||||||
if (!workerPromise) return;
|
if (!workerPromise) return;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { isUsableParse, parseVinpinModal } from "./vinpin.parser";
|
import {
|
||||||
|
isUsableParse,
|
||||||
|
isUsableRenaultParse,
|
||||||
|
parseRenaultHeader,
|
||||||
|
parseVinpinModal,
|
||||||
|
} from "./vinpin.parser";
|
||||||
|
|
||||||
const SAMPLE =
|
const SAMPLE =
|
||||||
"MVS, найденные по vin NM435600006H43436 6J - TIPO - EGEA (2015-2021) 356G37001162 - 3V HIGH PLUS 1.6 120CV D5 CM E6 GS TR Двигатель: 8165300 Автомобиль: 279476 Prod. date: 2/8/2017 Vin: NM435600006H43436";
|
"MVS, найденные по vin NM435600006H43436 6J - TIPO - EGEA (2015-2021) 356G37001162 - 3V HIGH PLUS 1.6 120CV D5 CM E6 GS TR Двигатель: 8165300 Автомобиль: 279476 Prod. date: 2/8/2017 Vin: NM435600006H43436";
|
||||||
@@ -35,4 +40,71 @@ describe("parseVinpinModal", () => {
|
|||||||
expect(isUsableParse(p)).toBe(false);
|
expect(isUsableParse(p)).toBe(false);
|
||||||
expect(isUsableParse(parseVinpinModal(null))).toBe(false);
|
expect(isUsableParse(parseVinpinModal(null))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a stale-breadcrumb model with no SINCOM (vehicle-not-found false positive)", () => {
|
||||||
|
// Real garbled OCR of a failed NM4131 decode: the "не найден" alert is
|
||||||
|
// mangled ("He HaaeHs") so it slips the not-found check, and the screen
|
||||||
|
// still shows the operator's last catalog ("FIAT » TIPO - EGEA"). A model
|
||||||
|
// token is present but there is NO SINCOM → must be treated as unusable so
|
||||||
|
// it is not false-mapped onto the ancient "TIPO 1987-1993" catalog.
|
||||||
|
const p = parseVinpinModal(
|
||||||
|
"Fiat Dealer Spare Parts FIAT TIPO - EGBA 500 HYBRID TIPO-EGEA MCA (2020) DOBLO FREEMONT LINEA IDEA To yKasaHHeIM N3paMETpaM 3BTOMOBHMN He HaaeHs",
|
||||||
|
);
|
||||||
|
expect(p.model).not.toBeNull(); // breadcrumb model still extracted…
|
||||||
|
expect(p.sincom).toBeNull(); // …but no SINCOM
|
||||||
|
expect(isUsableParse(p)).toBe(false); // → not a real decode
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseRenaultHeader", () => {
|
||||||
|
it("parses an Rpartstore RENAULT header (model + brand)", () => {
|
||||||
|
const p = parseRenaultHeader("RENAULT KADJAR (RFE) Şasi: VF1RFE00653633190");
|
||||||
|
expect(p.model).toBe("KADJAR");
|
||||||
|
expect(p.brand).toBe("RENAULT");
|
||||||
|
expect(isUsableRenaultParse(p)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads DACIA as the brand when the header carries it", () => {
|
||||||
|
const p = parseRenaultHeader("DACIA DUSTER (HSA) Şasi: VF1HJD40865644941");
|
||||||
|
expect(p.model).toBe("DUSTER");
|
||||||
|
expect(p.brand).toBe("DACIA");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts the base model and ignores the generation numeral (CLIO IV)", () => {
|
||||||
|
const p = parseRenaultHeader("RENAULT CLIO IV (R0G) Şasi: VF15R0G0H49335605");
|
||||||
|
expect(p.model).toBe("CLIO");
|
||||||
|
expect(p.brand).toBe("RENAULT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a Dialogys header with no explicit brand (defaults RENAULT)", () => {
|
||||||
|
const p = parseRenaultHeader("Clio II (X65), 1.5 dCi");
|
||||||
|
expect(p.model).toBe("CLIO");
|
||||||
|
expect(p.brand).toBe("RENAULT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures a model year when the header carries one", () => {
|
||||||
|
const p = parseRenaultHeader("RENAULT LATITUDE (2011-2015)");
|
||||||
|
expect(p.model).toBe("LATITUDE");
|
||||||
|
expect(p.modelYear).toBe("2011");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT mis-grab the status-bar license-expiry date as the model year", () => {
|
||||||
|
// The full-frame OCR fallback sweeps in the Rpartstore/Dialogys status bar,
|
||||||
|
// whose license EXPIRY date ("…14.07.2026") used to be read as the model year.
|
||||||
|
const p = parseRenaultHeader("RENAULT KADJAR (RFE) Şasi: VF1RFE00653633190 Lisans: 14.07.2026");
|
||||||
|
expect(p.model).toBe("KADJAR");
|
||||||
|
expect(p.modelYear).toBeNull(); // NOT "2026" from the expiry date
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still prefers a genuine generation range even when an expiry date is present", () => {
|
||||||
|
const p = parseRenaultHeader("RENAULT LATITUDE (2011-2015) Lisans: 14.07.2026");
|
||||||
|
expect(p.modelYear).toBe("2011"); // the (2011-2015) range wins over the date
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no model / unusable for headers without a known token", () => {
|
||||||
|
const p = parseRenaultHeader("İlişikli araç bulunamadı");
|
||||||
|
expect(p.model).toBeNull();
|
||||||
|
expect(isUsableRenaultParse(p)).toBe(false);
|
||||||
|
expect(isUsableRenaultParse(parseRenaultHeader(""))).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* Kept dependency-free and side-effect-free so it can be unit-tested directly.
|
* Kept dependency-free and side-effect-free so it can be unit-tested directly.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FIAT_MODEL_TOKENS } from "./vinpin.constants";
|
import { FIAT_MODEL_TOKENS, RENAULT_MODEL_TOKENS } from "./vinpin.constants";
|
||||||
|
|
||||||
export interface VinpinParsed {
|
export interface VinpinParsed {
|
||||||
model: string | null;
|
model: string | null;
|
||||||
@@ -91,7 +91,83 @@ export function parseVinpinModal(rawText: string | null | undefined): VinpinPars
|
|||||||
return { model, sincom, trim, modelYear, engine };
|
return { model, sincom, trim, modelYear, engine };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A parse is usable if we got at least a model or a SINCOM code. */
|
/**
|
||||||
|
* A parse is usable only when we got a SINCOM code — the trim identifier that a
|
||||||
|
* genuine ePER decode always emits ("356G37001162"). A model *alone* is a stale
|
||||||
|
* breadcrumb: when the VIN search returns "vehicle not found" (a TR NM4 VIN ePER
|
||||||
|
* can't resolve), the screen still shows whatever catalog the operator was last
|
||||||
|
* browsing (e.g. "FIAT » TIPO - EGEA"), and the OCR of the Cyrillic "не найден"
|
||||||
|
* alert garbles enough to slip past the not-found check. Grabbing that
|
||||||
|
* breadcrumb model ("TIPO"/"PANDA") and matching it to a catalog is a false
|
||||||
|
* positive that lands users on the wrong (often ancient) catalog. Requiring the
|
||||||
|
* SINCOM cleanly separates the two: every genuine decode carries one; the
|
||||||
|
* breadcrumb false positives never do.
|
||||||
|
*/
|
||||||
export function isUsableParse(p: VinpinParsed): boolean {
|
export function isUsableParse(p: VinpinParsed): boolean {
|
||||||
return Boolean(p.model || p.sincom);
|
return Boolean(p.sincom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Renault (Rpartstore / Dialogys) header parser ───────────────
|
||||||
|
|
||||||
|
export interface VinpinRenaultParsed {
|
||||||
|
/** Extracted model, e.g. "KADJAR", "CLIO", "DUSTER". */
|
||||||
|
model: string | null;
|
||||||
|
/** "RENAULT" | "DACIA" — read from the header, else defaulted to "RENAULT". */
|
||||||
|
brand: "RENAULT" | "DACIA";
|
||||||
|
/** Model year if the header carried one (best-effort). */
|
||||||
|
modelYear: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RENAULT_TOKEN_SET = new Set<string>(RENAULT_MODEL_TOKENS as readonly string[]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the Renault/Dacia model from an OCR'd vehicle-page header.
|
||||||
|
*
|
||||||
|
* Rpartstore header (primary): "RENAULT KADJAR (RFE)" / "DACIA DUSTER (...)" +
|
||||||
|
* "Şasi: VF1RFE00653633190".
|
||||||
|
* Dialogys header (fallback): "Clio II (X65), 1.5 dCi" (no explicit brand).
|
||||||
|
*
|
||||||
|
* Strategy (mirrors the Fiat token-run parser): collect the contiguous run of
|
||||||
|
* KNOWN Renault/Dacia model tokens (so "GRAND KOLEOS" → "KOLEOS", "CLIO II" →
|
||||||
|
* "CLIO"), and read the brand from a leading RENAULT/DACIA token (Dialogys omits
|
||||||
|
* it → default RENAULT). Generation numbers / roman numerals are intentionally
|
||||||
|
* dropped — PL24 rows carry their own generation suffixes and matching is
|
||||||
|
* best-effort.
|
||||||
|
*/
|
||||||
|
export function parseRenaultHeader(rawText: string | null | undefined): VinpinRenaultParsed {
|
||||||
|
const text = (rawText ?? "").replace(/\s+/g, " ").trim();
|
||||||
|
const upper = text.toUpperCase();
|
||||||
|
|
||||||
|
const brand: "RENAULT" | "DACIA" = /\bDACIA\b/.test(upper) ? "DACIA" : "RENAULT";
|
||||||
|
|
||||||
|
const tokens = upper.split(/[^A-Z0-9]+/).filter(Boolean);
|
||||||
|
const collected: string[] = [];
|
||||||
|
for (const tok of tokens) {
|
||||||
|
if (RENAULT_TOKEN_SET.has(tok)) {
|
||||||
|
collected.push(tok);
|
||||||
|
} else if (collected.length > 0) {
|
||||||
|
// Stop at the first non-model token after the model run has started.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// De-dup consecutive repeats (e.g. "CLIO CLIO" OCR doubling) preserving order.
|
||||||
|
const deduped = collected.filter((t, i) => i === 0 || t !== collected[i - 1]);
|
||||||
|
const model = deduped.length > 0 ? deduped.join("-") : null;
|
||||||
|
|
||||||
|
// Model year: prefer an explicit generation range "(2015-2021)" from the header.
|
||||||
|
// A BARE year is only taken as a fallback AND only after stripping full calendar
|
||||||
|
// dates — the Rpartstore/Dialogys STATUS BAR carries the license EXPIRY date
|
||||||
|
// ("…14.07.2026") which the full-frame OCR fallback would otherwise mis-grab as
|
||||||
|
// the model year (the "2026" bug). Stripping dd.mm.yyyy / dd/mm/yyyy / dd-mm-yyyy
|
||||||
|
// first constrains the bare-year scan to real header text.
|
||||||
|
const withoutDates = text.replace(/\b\d{1,2}[./-]\d{1,2}[./-]\d{4}\b/g, " ");
|
||||||
|
const modelYear =
|
||||||
|
text.match(YEAR_RANGE_RE)?.[1] ?? withoutDates.match(/\b(19|20)\d{2}\b/)?.[0] ?? null;
|
||||||
|
|
||||||
|
return { model, brand, modelYear };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Renault parse is usable once we have a model token. */
|
||||||
|
export function isUsableRenaultParse(p: VinpinRenaultParsed): boolean {
|
||||||
|
return Boolean(p.model);
|
||||||
}
|
}
|
||||||
|
|||||||
94
apps/api/src/integrations/vinpin/vinpin.routing.spec.ts
Normal file
94
apps/api/src/integrations/vinpin/vinpin.routing.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isVinpinBrandAllowed,
|
||||||
|
isVinpinBusinessHours,
|
||||||
|
orderTaskbarWindows,
|
||||||
|
selectVinpinBrandFlow,
|
||||||
|
selectVinpinWarmWindow,
|
||||||
|
} from "./vinpin.constants";
|
||||||
|
|
||||||
|
describe("selectVinpinBrandFlow", () => {
|
||||||
|
it("routes Renault WMIs (VF1/VF2) to the renault flow", () => {
|
||||||
|
expect(selectVinpinBrandFlow("VF1RFE00653633190")).toBe("renault"); // Kadjar
|
||||||
|
expect(selectVinpinBrandFlow("VF15R0G0H49335605")).toBe("renault"); // Clio IV
|
||||||
|
expect(selectVinpinBrandFlow("VF1LB030523661167")).toBe("renault"); // Clio II
|
||||||
|
expect(selectVinpinBrandFlow("VF2ABCDEFGH123456")).toBe("renault");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes a Dacia-badged VF1 VIN to the renault flow (brand read from header)", () => {
|
||||||
|
expect(selectVinpinBrandFlow("VF1HJD40865644941")).toBe("renault"); // Dacia Duster
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes the dedicated Dacia WMI (UU1) to the renault flow", () => {
|
||||||
|
expect(selectVinpinBrandFlow("UU1ABCDEFGH123456")).toBe("renault");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Fiat VINs on the fiat flow (byte-identical behaviour)", () => {
|
||||||
|
expect(selectVinpinBrandFlow("NM435600006H43436")).toBe("fiat"); // Tofaş Egea
|
||||||
|
expect(selectVinpinBrandFlow("ZFA31200000000000")).toBe("fiat");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults unknown/other WMIs to the fiat flow", () => {
|
||||||
|
expect(selectVinpinBrandFlow("WVWZZZ1JZ3W597935")).toBe("fiat"); // VW
|
||||||
|
expect(selectVinpinBrandFlow("")).toBe("fiat");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isVinpinBrandAllowed", () => {
|
||||||
|
it("allows Fiat, Renault and Dacia (case-insensitive)", () => {
|
||||||
|
expect(isVinpinBrandAllowed("Fiat")).toBe(true);
|
||||||
|
expect(isVinpinBrandAllowed("renault")).toBe(true);
|
||||||
|
expect(isVinpinBrandAllowed("DACIA")).toBe(true);
|
||||||
|
expect(isVinpinBrandAllowed(" Renault ")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects other brands and empty input", () => {
|
||||||
|
expect(isVinpinBrandAllowed("Peugeot")).toBe(false);
|
||||||
|
expect(isVinpinBrandAllowed(null)).toBe(false);
|
||||||
|
expect(isVinpinBrandAllowed(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selectVinpinWarmWindow — brand → taskbar window routing", () => {
|
||||||
|
it("routes Renault/Dacia VINs to the Rpartstore window (Dialogys is the in-flow fallback)", () => {
|
||||||
|
expect(selectVinpinWarmWindow("VF1RFE00653633190")).toBe("rpartstore"); // Renault Kadjar
|
||||||
|
expect(selectVinpinWarmWindow("UU1ABCDEFGH123456")).toBe("rpartstore"); // Dacia
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes Fiat (and everything else) to the ePER window", () => {
|
||||||
|
expect(selectVinpinWarmWindow("NM435600006H43436")).toBe("fiat"); // Tofaş Egea
|
||||||
|
expect(selectVinpinWarmWindow("WVWZZZ1JZ3W597935")).toBe("fiat"); // VW → fiat default
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("orderTaskbarWindows — OCR-order taskbar binding", () => {
|
||||||
|
it("returns the windows in their left→right OCR order", () => {
|
||||||
|
const bar = "VinPower Fiat Dealer ePER Renault RPartStore Renault Dialogys";
|
||||||
|
expect(orderTaskbarWindows(bar)).toEqual(["fiat", "rpartstore", "dialogys"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects a different open order (order is read, not hardcoded)", () => {
|
||||||
|
const bar = "Dialogys | RPartStore | ePER";
|
||||||
|
expect(orderTaskbarWindows(bar)).toEqual(["dialogys", "rpartstore", "fiat"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits windows whose label isn't on the bar", () => {
|
||||||
|
const bar = "some noise ePER more noise RPartStore";
|
||||||
|
expect(orderTaskbarWindows(bar)).toEqual(["fiat", "rpartstore"]);
|
||||||
|
expect(orderTaskbarWindows("nothing recognizable")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isVinpinBusinessHours — 08:00–21:00 Europe/Istanbul (UTC+3, no DST)", () => {
|
||||||
|
it("is true from 08:00 up to (not including) 21:00 local", () => {
|
||||||
|
expect(isVinpinBusinessHours(new Date("2026-07-14T05:00:00Z"))).toBe(true); // 08:00
|
||||||
|
expect(isVinpinBusinessHours(new Date("2026-07-14T12:00:00Z"))).toBe(true); // 15:00
|
||||||
|
expect(isVinpinBusinessHours(new Date("2026-07-14T17:59:00Z"))).toBe(true); // 20:59
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false before 08:00 and at/after 21:00 local", () => {
|
||||||
|
expect(isVinpinBusinessHours(new Date("2026-07-14T04:59:00Z"))).toBe(false); // 07:59
|
||||||
|
expect(isVinpinBusinessHours(new Date("2026-07-14T18:00:00Z"))).toBe(false); // 21:00
|
||||||
|
expect(isVinpinBusinessHours(new Date("2026-07-14T22:00:00Z"))).toBe(false); // 01:00
|
||||||
|
});
|
||||||
|
});
|
||||||
314
apps/api/src/internal-admin/api-keys.controller.ts
Normal file
314
apps/api/src/internal-admin/api-keys.controller.ts
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { desc, eq, inArray } from "drizzle-orm";
|
||||||
|
import { getApiKeyApi } from "../auth/auth";
|
||||||
|
import { Public } from "../common/decorators/public.decorator";
|
||||||
|
import { InternalTokenGuard } from "../common/guards/internal-token.guard";
|
||||||
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
|
import { apikeys, users } from "../database/schema/core";
|
||||||
|
import { PublicApiQuotaService } from "../public-api/public-api-quota.service";
|
||||||
|
|
||||||
|
// better-auth createApiKey `expiresIn`'i SANİYE bekler (ms değil).
|
||||||
|
const DAY_SECONDS = 24 * 60 * 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public API anahtarı yönetimi — kapalı beta döneminde anahtarlar self-service
|
||||||
|
* değil, buradan (Süper Panel / curl + x-internal-token) elle üretilir.
|
||||||
|
* Anahtarın düz metni YALNIZCA create cevabında döner; DB'de hash saklanır.
|
||||||
|
*/
|
||||||
|
@Controller("internal/admin/api-keys")
|
||||||
|
@Public()
|
||||||
|
@UseGuards(InternalTokenGuard)
|
||||||
|
export class ApiKeysAdminController {
|
||||||
|
constructor(
|
||||||
|
@Inject(DATABASE) private readonly db: Database,
|
||||||
|
private readonly quota: PublicApiQuotaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anahtar üret. Kullanıcı `userId` veya `email` ile seçilir (anahtar o
|
||||||
|
* kullanıcının aboneliği/marka yetkisiyle çalışır). `dailyDecodeLimit`
|
||||||
|
* anahtar metadata'sına yazılır; verilmezse env/100 varsayılanı geçerli.
|
||||||
|
*
|
||||||
|
* `publishable: true` → widget anahtarı (sase_pk_): müşteri sayfasında
|
||||||
|
* görünür, /widget shell'i yalnız bunları kabul eder ve `domains`
|
||||||
|
* listesinden frame-ancestors CSP kilidi üretir. domains zorunludur
|
||||||
|
* (boş liste = hiçbir 3. parti sitede render olmaz, sadece test).
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async create(
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
userId?: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
dailyDecodeLimit?: number;
|
||||||
|
expiresInDays?: number;
|
||||||
|
rateLimitMax?: number;
|
||||||
|
test?: boolean;
|
||||||
|
publishable?: boolean;
|
||||||
|
domains?: string[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const user = await this.resolveUser(body);
|
||||||
|
|
||||||
|
if (
|
||||||
|
body.dailyDecodeLimit !== undefined &&
|
||||||
|
(typeof body.dailyDecodeLimit !== "number" || body.dailyDecodeLimit <= 0)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException("dailyDecodeLimit pozitif bir sayı olmalı");
|
||||||
|
}
|
||||||
|
if (body.publishable && body.domains !== undefined && !Array.isArray(body.domains)) {
|
||||||
|
throw new BadRequestException('domains bir dizi olmalı (ör. ["musteri.com"])');
|
||||||
|
}
|
||||||
|
if (body.domains && !body.publishable) {
|
||||||
|
throw new BadRequestException("domains yalnız publishable anahtarlarda kullanılır");
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata: Record<string, unknown> = {};
|
||||||
|
if (body.dailyDecodeLimit) metadata.dailyDecodeLimit = Math.floor(body.dailyDecodeLimit);
|
||||||
|
if (body.publishable) {
|
||||||
|
metadata.publishable = true;
|
||||||
|
metadata.domains = (body.domains ?? []).filter((d) => typeof d === "string" && d.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await getApiKeyApi().createApiKey({
|
||||||
|
body: {
|
||||||
|
userId: user.id,
|
||||||
|
name: body.name ?? `${user.email} public-api`,
|
||||||
|
prefix: body.publishable ? "sase_pk_" : body.test ? "sase_test_" : "sase_live_",
|
||||||
|
...(body.expiresInDays ? { expiresIn: body.expiresInDays * DAY_SECONDS } : {}),
|
||||||
|
...(body.rateLimitMax
|
||||||
|
? { rateLimitEnabled: true, rateLimitTimeWindow: 60_000, rateLimitMax: body.rateLimitMax }
|
||||||
|
: {}),
|
||||||
|
...(Object.keys(metadata).length ? { metadata } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: created.id,
|
||||||
|
// Düz metin anahtar — sadece bu cevapta görünür, bir daha üretilemez.
|
||||||
|
key: created.key,
|
||||||
|
start: created.start,
|
||||||
|
user: { id: user.id, email: user.email },
|
||||||
|
name: created.name,
|
||||||
|
expiresAt: created.expiresAt,
|
||||||
|
dailyDecodeLimit: body.dailyDecodeLimit ?? null,
|
||||||
|
...(body.publishable ? { publishable: true, domains: metadata.domains } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anahtarları listele (hash asla dönmez). userId/email verilirse o
|
||||||
|
* kullanıcıya filtrelenir; verilmezse TÜM anahtarlar döner (Süper Panel
|
||||||
|
* genel görünümü). Her satırda sahibi kullanıcı da bulunur.
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
async list(@Query("userId") userId?: string, @Query("email") email?: string) {
|
||||||
|
const user = userId || email ? await this.resolveUser({ userId, email }) : null;
|
||||||
|
const rows = await this.db
|
||||||
|
.select({
|
||||||
|
id: apikeys.id,
|
||||||
|
name: apikeys.name,
|
||||||
|
start: apikeys.start,
|
||||||
|
prefix: apikeys.prefix,
|
||||||
|
enabled: apikeys.enabled,
|
||||||
|
rateLimitMax: apikeys.rateLimitMax,
|
||||||
|
requestCount: apikeys.requestCount,
|
||||||
|
lastRequest: apikeys.lastRequest,
|
||||||
|
expiresAt: apikeys.expiresAt,
|
||||||
|
createdAt: apikeys.createdAt,
|
||||||
|
metadata: apikeys.metadata,
|
||||||
|
userId: apikeys.userId,
|
||||||
|
userEmail: users.email,
|
||||||
|
})
|
||||||
|
.from(apikeys)
|
||||||
|
.innerJoin(users, eq(apikeys.userId, users.id))
|
||||||
|
.where(user ? eq(apikeys.userId, user.id) : undefined)
|
||||||
|
.orderBy(desc(apikeys.createdAt));
|
||||||
|
return {
|
||||||
|
...(user ? { user: { id: user.id, email: user.email } } : {}),
|
||||||
|
keys: rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Günlük decode-kotası anlık durumu (Redis sayaçları). `ids` virgüllü
|
||||||
|
* liste; verilmezse tüm anahtarlar. Kota günü TR gecesi döndüğü için
|
||||||
|
* `used` yalnız bugünü yansıtır — tarihçe tutulmaz.
|
||||||
|
*/
|
||||||
|
@Get("usage")
|
||||||
|
async usage(@Query("ids") ids?: string) {
|
||||||
|
const idList = (ids ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ id: apikeys.id, metadata: apikeys.metadata, enabled: apikeys.enabled })
|
||||||
|
.from(apikeys)
|
||||||
|
.where(idList.length ? inArray(apikeys.id, idList) : undefined);
|
||||||
|
const items = await Promise.all(
|
||||||
|
rows.map(async (row) => {
|
||||||
|
const limit = this.quota.resolveLimit(parseKeyMetadata(row.metadata));
|
||||||
|
const state = await this.quota.getState(row.id, limit);
|
||||||
|
return { id: row.id, enabled: row.enabled, ...state };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anahtar ayarlarını güncelle. Alan verilmezse dokunulmaz; `null`
|
||||||
|
* geçilen alan temizlenir (dailyDecodeLimit → env varsayılanına döner,
|
||||||
|
* expiresInDays → süresiz, rateLimitMax → plugin varsayılanı 120/dk).
|
||||||
|
*/
|
||||||
|
@Patch(":id")
|
||||||
|
async update(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
name?: string;
|
||||||
|
dailyDecodeLimit?: number | null;
|
||||||
|
domains?: string[];
|
||||||
|
rateLimitMax?: number | null;
|
||||||
|
expiresInDays?: number | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const [row] = await this.db.select().from(apikeys).where(eq(apikeys.id, id)).limit(1);
|
||||||
|
if (!row) throw new NotFoundException("API anahtarı bulunamadı");
|
||||||
|
|
||||||
|
const metadata = parseKeyMetadata(row.metadata);
|
||||||
|
let metadataTouched = false;
|
||||||
|
const set: Partial<typeof apikeys.$inferInsert> = { updatedAt: new Date() };
|
||||||
|
|
||||||
|
if (body.name !== undefined) {
|
||||||
|
const name = String(body.name).trim();
|
||||||
|
if (!name || name.length > 32) {
|
||||||
|
throw new BadRequestException("name 1-32 karakter olmalı");
|
||||||
|
}
|
||||||
|
set.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.dailyDecodeLimit !== undefined) {
|
||||||
|
if (body.dailyDecodeLimit === null) {
|
||||||
|
// JSON.stringify undefined alanı düşürür → env varsayılanına döner.
|
||||||
|
metadata.dailyDecodeLimit = undefined;
|
||||||
|
} else if (typeof body.dailyDecodeLimit !== "number" || body.dailyDecodeLimit <= 0) {
|
||||||
|
throw new BadRequestException("dailyDecodeLimit pozitif bir sayı veya null olmalı");
|
||||||
|
} else {
|
||||||
|
metadata.dailyDecodeLimit = Math.floor(body.dailyDecodeLimit);
|
||||||
|
}
|
||||||
|
metadataTouched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.domains !== undefined) {
|
||||||
|
if (!metadata.publishable) {
|
||||||
|
throw new BadRequestException("domains yalnız publishable anahtarlarda kullanılır");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(body.domains)) {
|
||||||
|
throw new BadRequestException('domains bir dizi olmalı (ör. ["musteri.com"])');
|
||||||
|
}
|
||||||
|
metadata.domains = body.domains.filter((d) => typeof d === "string" && d.trim());
|
||||||
|
metadataTouched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.rateLimitMax !== undefined) {
|
||||||
|
if (body.rateLimitMax === null) {
|
||||||
|
set.rateLimitMax = null;
|
||||||
|
set.rateLimitTimeWindow = null;
|
||||||
|
} else if (typeof body.rateLimitMax !== "number" || body.rateLimitMax <= 0) {
|
||||||
|
throw new BadRequestException("rateLimitMax pozitif bir sayı veya null olmalı");
|
||||||
|
} else {
|
||||||
|
set.rateLimitEnabled = true;
|
||||||
|
set.rateLimitTimeWindow = 60_000;
|
||||||
|
set.rateLimitMax = Math.floor(body.rateLimitMax);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.expiresInDays !== undefined) {
|
||||||
|
if (body.expiresInDays === null) {
|
||||||
|
set.expiresAt = null;
|
||||||
|
} else if (typeof body.expiresInDays !== "number" || body.expiresInDays <= 0) {
|
||||||
|
throw new BadRequestException("expiresInDays pozitif bir sayı veya null olmalı");
|
||||||
|
} else {
|
||||||
|
set.expiresAt = new Date(Date.now() + body.expiresInDays * DAY_SECONDS * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// better-auth şema transform'u tek JSON.stringify yazar — aynı format.
|
||||||
|
if (metadataTouched) set.metadata = JSON.stringify(metadata);
|
||||||
|
|
||||||
|
const [updated] = await this.db.update(apikeys).set(set).where(eq(apikeys.id, id)).returning({
|
||||||
|
id: apikeys.id,
|
||||||
|
name: apikeys.name,
|
||||||
|
enabled: apikeys.enabled,
|
||||||
|
rateLimitMax: apikeys.rateLimitMax,
|
||||||
|
expiresAt: apikeys.expiresAt,
|
||||||
|
metadata: apikeys.metadata,
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anahtarı devre dışı bırak (geri açmak için enable). */
|
||||||
|
@Post(":id/disable")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async disable(@Param("id") id: string) {
|
||||||
|
return this.setEnabled(id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/enable")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async enable(@Param("id") id: string) {
|
||||||
|
return this.setEnabled(id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setEnabled(id: string, enabled: boolean) {
|
||||||
|
const [row] = await this.db
|
||||||
|
.update(apikeys)
|
||||||
|
.set({ enabled, updatedAt: new Date() })
|
||||||
|
.where(eq(apikeys.id, id))
|
||||||
|
.returning({ id: apikeys.id, enabled: apikeys.enabled });
|
||||||
|
if (!row) throw new NotFoundException("API anahtarı bulunamadı");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveUser(input: { userId?: string; email?: string }) {
|
||||||
|
if (!input.userId && !input.email) {
|
||||||
|
throw new BadRequestException("userId veya email gerekli");
|
||||||
|
}
|
||||||
|
const [user] = await this.db
|
||||||
|
.select({ id: users.id, email: users.email })
|
||||||
|
.from(users)
|
||||||
|
.where(input.userId ? eq(users.id, input.userId) : eq(users.email, input.email as string))
|
||||||
|
.limit(1);
|
||||||
|
if (!user) throw new NotFoundException("Kullanıcı bulunamadı");
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** apikeys.metadata text kolonu — better-auth tek JSON.stringify ile yazar. */
|
||||||
|
function parseKeyMetadata(raw: string | null): Record<string, unknown> {
|
||||||
|
if (!raw) return {};
|
||||||
|
try {
|
||||||
|
let value: unknown = JSON.parse(raw);
|
||||||
|
// Bazı better-auth sürümleri çift stringify yapabiliyor; toleranslı ol.
|
||||||
|
if (typeof value === "string") value = JSON.parse(value);
|
||||||
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,10 +71,7 @@ export class BillingController {
|
|||||||
|
|
||||||
@Post(":id/cancel")
|
@Post(":id/cancel")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async cancel(
|
async cancel(@Param("id") id: string, @Body() body: { reason?: string; founderId?: string }) {
|
||||||
@Param("id") id: string,
|
|
||||||
@Body() body: { reason?: string; founderId?: string },
|
|
||||||
) {
|
|
||||||
this.requireFounder(body);
|
this.requireFounder(body);
|
||||||
this.requireReason(body, "cancel");
|
this.requireReason(body, "cancel");
|
||||||
return this.billing.cancel({
|
return this.billing.cancel({
|
||||||
@@ -86,10 +83,7 @@ export class BillingController {
|
|||||||
|
|
||||||
@Post(":id/resume")
|
@Post(":id/resume")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async resume(
|
async resume(@Param("id") id: string, @Body() body: { reason?: string; founderId?: string }) {
|
||||||
@Param("id") id: string,
|
|
||||||
@Body() body: { reason?: string; founderId?: string },
|
|
||||||
) {
|
|
||||||
this.requireFounder(body);
|
this.requireFounder(body);
|
||||||
this.requireReason(body, "resume");
|
this.requireReason(body, "resume");
|
||||||
return this.billing.resume({
|
return this.billing.resume({
|
||||||
@@ -128,3 +122,53 @@ export class BillingController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-scoped billing admin: start a fresh subscription for a user whose
|
||||||
|
* previous one is expired/cancelled (the panel's "Yeni abonelik başlat").
|
||||||
|
*/
|
||||||
|
@Controller("internal/admin/users")
|
||||||
|
@Public()
|
||||||
|
@UseGuards(InternalTokenGuard)
|
||||||
|
export class UserBillingController {
|
||||||
|
constructor(private billing: BillingService) {}
|
||||||
|
|
||||||
|
@Post(":id/subscriptions")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async startSubscription(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
planId?: string;
|
||||||
|
billingPeriod?: string;
|
||||||
|
days?: number;
|
||||||
|
brandIds?: string[];
|
||||||
|
reason?: string;
|
||||||
|
founderId?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!body.founderId) throw new BadRequestException("founderId required");
|
||||||
|
if (!body.reason || body.reason.trim().length < 5) {
|
||||||
|
throw new BadRequestException("start-subscription requires reason (min 5 chars)");
|
||||||
|
}
|
||||||
|
if (!body.planId) throw new BadRequestException("planId required");
|
||||||
|
if (body.billingPeriod !== "monthly" && body.billingPeriod !== "yearly") {
|
||||||
|
throw new BadRequestException("billingPeriod must be monthly|yearly");
|
||||||
|
}
|
||||||
|
if (body.days !== undefined && (typeof body.days !== "number" || body.days <= 0)) {
|
||||||
|
throw new BadRequestException("days must be a positive number");
|
||||||
|
}
|
||||||
|
if (body.brandIds !== undefined && !Array.isArray(body.brandIds)) {
|
||||||
|
throw new BadRequestException("brandIds must be an array");
|
||||||
|
}
|
||||||
|
return this.billing.startSubscription({
|
||||||
|
userId: id,
|
||||||
|
planId: body.planId,
|
||||||
|
billingPeriod: body.billingPeriod,
|
||||||
|
days: body.days,
|
||||||
|
brandIds: body.brandIds,
|
||||||
|
reason: body.reason,
|
||||||
|
founderId: body.founderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
181
apps/api/src/internal-admin/billing.service.spec.ts
Normal file
181
apps/api/src/internal-admin/billing.service.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { BillingService } from "./billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drizzle-shaped mock: every `db.select()` pops the next row-set from `selects`
|
||||||
|
* (order = order of queries in the service), `db.insert()` returns
|
||||||
|
* `insertRows` from `.returning()` and records `.values()` payloads.
|
||||||
|
*/
|
||||||
|
function createMockDb(selects: unknown[][], insertRows: unknown[] = []) {
|
||||||
|
const queue = [...selects];
|
||||||
|
const inserted: unknown[] = [];
|
||||||
|
function chain(terminal: unknown, onValues?: (v: unknown) => void) {
|
||||||
|
const c: Record<string, unknown> = {};
|
||||||
|
for (const m of [
|
||||||
|
"select",
|
||||||
|
"from",
|
||||||
|
"where",
|
||||||
|
"limit",
|
||||||
|
"insert",
|
||||||
|
"values",
|
||||||
|
"returning",
|
||||||
|
"update",
|
||||||
|
"set",
|
||||||
|
]) {
|
||||||
|
c[m] = vi.fn().mockReturnValue(c);
|
||||||
|
}
|
||||||
|
c.values = vi.fn().mockImplementation((v: unknown) => {
|
||||||
|
onValues?.(v);
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
// `await db.select()...where(...)` (no limit) resolves via thenable.
|
||||||
|
// biome-ignore lint/suspicious/noThenProperty: mimics Drizzle's thenable query builder
|
||||||
|
c.then = (res: (v: unknown) => unknown, rej?: (e: unknown) => unknown) =>
|
||||||
|
Promise.resolve(terminal).then(res, rej);
|
||||||
|
c.limit = vi.fn().mockReturnValue(Promise.resolve(terminal));
|
||||||
|
c.returning = vi.fn().mockReturnValue(Promise.resolve(terminal));
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
inserted,
|
||||||
|
select: vi.fn().mockImplementation(() => chain(queue.shift() ?? [])),
|
||||||
|
insert: vi.fn().mockImplementation(() => chain(insertRows, (v) => inserted.push(v))),
|
||||||
|
update: vi.fn().mockImplementation(() => chain([])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const USER = { id: "user-1" };
|
||||||
|
const FULL = { id: "plan-full", name: "Full Paket", brandCount: 0, isActive: true };
|
||||||
|
const ONE = { id: "plan-1", name: "1 Marka", brandCount: 1, isActive: true };
|
||||||
|
const BRANDS = [{ id: "b1" }, { id: "b2" }, { id: "b3" }];
|
||||||
|
const base = { userId: "user-1", reason: "manuel tanım", founderId: "founder-1" } as const;
|
||||||
|
|
||||||
|
function svc(db: unknown) {
|
||||||
|
return new BillingService(db as any, {} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BillingService.startSubscription", () => {
|
||||||
|
it("creates an active Full-plan subscription with every active brand and the given days", async () => {
|
||||||
|
const created = {
|
||||||
|
id: "sub-new",
|
||||||
|
startDate: new Date("2026-09-19T00:00:00Z"),
|
||||||
|
endDate: new Date("2029-09-19T00:00:00Z"),
|
||||||
|
};
|
||||||
|
const db = createMockDb([[USER], [], [FULL], BRANDS], [created]);
|
||||||
|
const res = await svc(db).startSubscription({
|
||||||
|
...base,
|
||||||
|
planId: FULL.id,
|
||||||
|
billingPeriod: "yearly",
|
||||||
|
days: 1095,
|
||||||
|
});
|
||||||
|
expect(res.success).toBe(true);
|
||||||
|
expect(res.subscriptionId).toBe("sub-new");
|
||||||
|
expect(res.brandCount).toBe(3);
|
||||||
|
// 1st insert = subscription row, 2nd = brand rows
|
||||||
|
expect(db.inserted).toHaveLength(2);
|
||||||
|
const subRow = db.inserted[0] as {
|
||||||
|
status: string;
|
||||||
|
billingPeriod: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
};
|
||||||
|
expect(subRow.status).toBe("active");
|
||||||
|
expect(subRow.billingPeriod).toBe("yearly");
|
||||||
|
const diffDays = Math.round(
|
||||||
|
(subRow.endDate.getTime() - subRow.startDate.getTime()) / 86_400_000,
|
||||||
|
);
|
||||||
|
expect(diffDays).toBe(1095);
|
||||||
|
expect(db.inserted[1]).toEqual(
|
||||||
|
BRANDS.map((b) => ({ userId: "user-1", subscriptionId: "sub-new", brandId: b.id })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the period from billingPeriod when days is omitted", async () => {
|
||||||
|
const db = createMockDb([[USER], [], [FULL], BRANDS], [{ id: "sub-new" }]);
|
||||||
|
await svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "monthly" });
|
||||||
|
const subRow = db.inserted[0] as { startDate: Date; endDate: Date };
|
||||||
|
const expected = new Date(subRow.startDate);
|
||||||
|
expected.setMonth(expected.getMonth() + 1);
|
||||||
|
expect(subRow.endDate.getTime()).toBe(expected.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the requested brands for a brand-limited plan", async () => {
|
||||||
|
const db = createMockDb([[USER], [], [ONE], BRANDS], [{ id: "sub-new" }]);
|
||||||
|
const res = await svc(db).startSubscription({
|
||||||
|
...base,
|
||||||
|
planId: ONE.id,
|
||||||
|
billingPeriod: "monthly",
|
||||||
|
brandIds: ["b2"],
|
||||||
|
});
|
||||||
|
expect(res.brandCount).toBe(1);
|
||||||
|
expect(db.inserted[1]).toEqual([
|
||||||
|
{ userId: "user-1", subscriptionId: "sub-new", brandId: "b2" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects wrong brand count / unknown brands for a brand-limited plan", async () => {
|
||||||
|
await expect(
|
||||||
|
svc(createMockDb([[USER], [], [ONE], BRANDS])).startSubscription({
|
||||||
|
...base,
|
||||||
|
planId: ONE.id,
|
||||||
|
billingPeriod: "monthly",
|
||||||
|
brandIds: [],
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(
|
||||||
|
svc(createMockDb([[USER], [], [ONE], BRANDS])).startSubscription({
|
||||||
|
...base,
|
||||||
|
planId: ONE.id,
|
||||||
|
billingPeriod: "monthly",
|
||||||
|
brandIds: ["nope"],
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when the user already has an active or trial subscription", async () => {
|
||||||
|
const db = createMockDb([[USER], [{ id: "sub-live", status: "trial" }]]);
|
||||||
|
await expect(
|
||||||
|
svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly" }),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(db.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404s on unknown user / plan and rejects inactive plans", async () => {
|
||||||
|
await expect(
|
||||||
|
svc(createMockDb([[]])).startSubscription({
|
||||||
|
...base,
|
||||||
|
planId: FULL.id,
|
||||||
|
billingPeriod: "yearly",
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
await expect(
|
||||||
|
svc(createMockDb([[USER], [], []])).startSubscription({
|
||||||
|
...base,
|
||||||
|
planId: "missing",
|
||||||
|
billingPeriod: "yearly",
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
await expect(
|
||||||
|
svc(createMockDb([[USER], [], [{ ...FULL, isActive: false }]])).startSubscription({
|
||||||
|
...base,
|
||||||
|
planId: FULL.id,
|
||||||
|
billingPeriod: "yearly",
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates days range and billingPeriod before touching the db", async () => {
|
||||||
|
const db = createMockDb([]);
|
||||||
|
await expect(
|
||||||
|
svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly", days: 0 }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(
|
||||||
|
svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly", days: 3651 }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(
|
||||||
|
svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "weekly" as any }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(db.select).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,9 +6,9 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { DATABASE, type Database } from "../database/database.provider";
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
import { brands, plans, userBrands, userSubscriptions } from "../database/schema/core";
|
import { brands, plans, userBrands, userSubscriptions, users } from "../database/schema/core";
|
||||||
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -31,8 +31,8 @@ export class BillingService {
|
|||||||
reason: string;
|
reason: string;
|
||||||
founderId: string;
|
founderId: string;
|
||||||
}) {
|
}) {
|
||||||
if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 90) {
|
if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 3650) {
|
||||||
throw new BadRequestException("days must be 1..90");
|
throw new BadRequestException("days must be 1..3650");
|
||||||
}
|
}
|
||||||
const [sub] = await this.db
|
const [sub] = await this.db
|
||||||
.select()
|
.select()
|
||||||
@@ -74,6 +74,133 @@ export class BillingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a brand-new ACTIVE subscription for a user whose previous one is
|
||||||
|
* expired/cancelled (or who has none). Mirrors what activateSubscription does
|
||||||
|
* for a paid checkout — status/dates/plan-based brand assignment — but does
|
||||||
|
* NOT emit revenue events (PostHog subscription_activated / Meta Purchase) or
|
||||||
|
* consume referral credit: this is a founder grant, no money moved here.
|
||||||
|
* `days` overrides the default period (monthly → +1 ay, yearly → +1 yıl).
|
||||||
|
*/
|
||||||
|
async startSubscription(input: {
|
||||||
|
userId: string;
|
||||||
|
planId: string;
|
||||||
|
billingPeriod: "monthly" | "yearly";
|
||||||
|
days?: number;
|
||||||
|
brandIds?: string[];
|
||||||
|
reason: string;
|
||||||
|
founderId: string;
|
||||||
|
}) {
|
||||||
|
if (input.billingPeriod !== "monthly" && input.billingPeriod !== "yearly") {
|
||||||
|
throw new BadRequestException("billingPeriod must be monthly|yearly");
|
||||||
|
}
|
||||||
|
if (input.days !== undefined) {
|
||||||
|
if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 3650) {
|
||||||
|
throw new BadRequestException("days must be 1..3650");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user] = await this.db
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, input.userId))
|
||||||
|
.limit(1);
|
||||||
|
if (!user) throw new NotFoundException("Kullanıcı bulunamadı");
|
||||||
|
|
||||||
|
const [live] = await this.db
|
||||||
|
.select({ id: userSubscriptions.id, status: userSubscriptions.status })
|
||||||
|
.from(userSubscriptions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userSubscriptions.userId, input.userId),
|
||||||
|
inArray(userSubscriptions.status, ["active", "trial"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (live) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Kullanıcının zaten '${live.status}' bir subscription'ı var (${live.id}) — activate / plan değiştir / uzat kullanın`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [plan] = await this.db.select().from(plans).where(eq(plans.id, input.planId)).limit(1);
|
||||||
|
if (!plan) throw new NotFoundException("Plan bulunamadı");
|
||||||
|
if (!plan.isActive) throw new ConflictException("Plan aktif değil");
|
||||||
|
|
||||||
|
const activeBrands = await this.db.select().from(brands).where(eq(brands.isActive, true));
|
||||||
|
let brandIds: string[];
|
||||||
|
if (plan.brandCount === 0) {
|
||||||
|
// Full plan → every active brand, whatever the caller sent.
|
||||||
|
brandIds = activeBrands.map((b) => b.id);
|
||||||
|
} else {
|
||||||
|
const requested = input.brandIds ?? [];
|
||||||
|
if (requested.length !== plan.brandCount) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Bu plan tam olarak ${plan.brandCount} marka gerektirir, ${requested.length} seçildi`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (new Set(requested).size !== requested.length) {
|
||||||
|
throw new BadRequestException("Marka listesi tekrar içeriyor");
|
||||||
|
}
|
||||||
|
const valid = new Set(activeBrands.map((b) => b.id));
|
||||||
|
for (const id of requested) {
|
||||||
|
if (!valid.has(id)) throw new BadRequestException(`Geçersiz veya pasif marka: ${id}`);
|
||||||
|
}
|
||||||
|
brandIds = requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const endDate = new Date(now);
|
||||||
|
if (input.days !== undefined) {
|
||||||
|
endDate.setDate(endDate.getDate() + input.days);
|
||||||
|
} else if (input.billingPeriod === "yearly") {
|
||||||
|
endDate.setFullYear(endDate.getFullYear() + 1);
|
||||||
|
} else {
|
||||||
|
endDate.setMonth(endDate.getMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await this.db
|
||||||
|
.insert(userSubscriptions)
|
||||||
|
.values({
|
||||||
|
userId: input.userId,
|
||||||
|
planId: input.planId,
|
||||||
|
status: "active",
|
||||||
|
billingPeriod: input.billingPeriod,
|
||||||
|
startDate: now,
|
||||||
|
endDate,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (brandIds.length > 0) {
|
||||||
|
await this.db.insert(userBrands).values(
|
||||||
|
brandIds.map((brandId) => ({
|
||||||
|
userId: input.userId,
|
||||||
|
subscriptionId: created.id,
|
||||||
|
brandId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`start: user=${input.userId} subscription=${created.id} plan=${plan.name} ` +
|
||||||
|
`${input.billingPeriod}${input.days !== undefined ? ` ${input.days}d` : ""} → ${endDate.toISOString()} ` +
|
||||||
|
`brands=${brandIds.length} founder=${input.founderId} reason="${input.reason.slice(0, 80)}"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
subscriptionId: created.id,
|
||||||
|
userId: input.userId,
|
||||||
|
planId: plan.id,
|
||||||
|
planName: plan.name,
|
||||||
|
billingPeriod: input.billingPeriod,
|
||||||
|
status: "active",
|
||||||
|
startDate: created.startDate?.toISOString() ?? null,
|
||||||
|
endDate: created.endDate?.toISOString() ?? null,
|
||||||
|
brandCount: brandIds.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async manuallyActivate(input: {
|
async manuallyActivate(input: {
|
||||||
subscriptionId: string;
|
subscriptionId: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
@@ -126,9 +253,7 @@ export class BillingService {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
if (!sub) throw new NotFoundException("Subscription bulunamadı");
|
if (!sub) throw new NotFoundException("Subscription bulunamadı");
|
||||||
if (sub.status !== "active" && sub.status !== "trial") {
|
if (sub.status !== "active" && sub.status !== "trial") {
|
||||||
throw new ConflictException(
|
throw new ConflictException(`'${sub.status}' subscription için plan değişimi yapılamaz`);
|
||||||
`'${sub.status}' subscription için plan değişimi yapılamaz`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (sub.planId === input.newPlanId) {
|
if (sub.planId === input.newPlanId) {
|
||||||
throw new ConflictException("Subscription zaten bu planda");
|
throw new ConflictException("Subscription zaten bu planda");
|
||||||
@@ -234,16 +359,10 @@ export class BillingService {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
if (!sub) throw new NotFoundException("Subscription bulunamadı");
|
if (!sub) throw new NotFoundException("Subscription bulunamadı");
|
||||||
if (sub.status !== "active" && sub.status !== "trial") {
|
if (sub.status !== "active" && sub.status !== "trial") {
|
||||||
throw new ConflictException(
|
throw new ConflictException(`'${sub.status}' subscription için brand atama yapılamaz`);
|
||||||
`'${sub.status}' subscription için brand atama yapılamaz`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [plan] = await this.db
|
const [plan] = await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1);
|
||||||
.select()
|
|
||||||
.from(plans)
|
|
||||||
.where(eq(plans.id, sub.planId))
|
|
||||||
.limit(1);
|
|
||||||
if (!plan) throw new NotFoundException("Plan bulunamadı");
|
if (!plan) throw new NotFoundException("Plan bulunamadı");
|
||||||
|
|
||||||
// brandCount 0 = "all brands" (Full plan): don't accept a custom list;
|
// brandCount 0 = "all brands" (Full plan): don't accept a custom list;
|
||||||
@@ -282,9 +401,7 @@ export class BillingService {
|
|||||||
.from(userBrands)
|
.from(userBrands)
|
||||||
.where(eq(userBrands.subscriptionId, input.subscriptionId));
|
.where(eq(userBrands.subscriptionId, input.subscriptionId));
|
||||||
|
|
||||||
await this.db
|
await this.db.delete(userBrands).where(eq(userBrands.subscriptionId, input.subscriptionId));
|
||||||
.delete(userBrands)
|
|
||||||
.where(eq(userBrands.subscriptionId, input.subscriptionId));
|
|
||||||
|
|
||||||
if (input.brandIds.length > 0) {
|
if (input.brandIds.length > 0) {
|
||||||
await this.db.insert(userBrands).values(
|
await this.db.insert(userBrands).values(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -15,7 +16,6 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
||||||
import { Public } from "../common/decorators/public.decorator";
|
import { Public } from "../common/decorators/public.decorator";
|
||||||
import { InternalTokenGuard } from "../common/guards/internal-token.guard";
|
import { InternalTokenGuard } from "../common/guards/internal-token.guard";
|
||||||
import { ImpersonationService } from "./impersonation.service";
|
import { ImpersonationService } from "./impersonation.service";
|
||||||
@@ -66,9 +66,8 @@ export class ImpersonationController {
|
|||||||
ttlMinutes: body.ttlMinutes ?? 15,
|
ttlMinutes: body.ttlMinutes ?? 15,
|
||||||
reason,
|
reason,
|
||||||
ipAddress: this.getIp(req),
|
ipAddress: this.getIp(req),
|
||||||
userAgent: typeof req.headers["user-agent"] === "string"
|
userAgent:
|
||||||
? req.headers["user-agent"]
|
typeof req.headers["user-agent"] === "string" ? req.headers["user-agent"] : undefined,
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseUrl = this.configService.get<string>("auth.url") ?? "";
|
const baseUrl = this.configService.get<string>("auth.url") ?? "";
|
||||||
@@ -149,8 +148,7 @@ export class ImpersonationController {
|
|||||||
|
|
||||||
private consumeSecret(): string {
|
private consumeSecret(): string {
|
||||||
const secret =
|
const secret =
|
||||||
process.env.INTERNAL_IMPERSONATION_SECRET ??
|
process.env.INTERNAL_IMPERSONATION_SECRET ?? this.configService.get<string>("auth.secret");
|
||||||
this.configService.get<string>("auth.secret");
|
|
||||||
if (!secret) throw new Error("INTERNAL_IMPERSONATION_SECRET or auth.secret required");
|
if (!secret) throw new Error("INTERNAL_IMPERSONATION_SECRET or auth.secret required");
|
||||||
return secret;
|
return secret;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,10 +32,7 @@ export class ImpersonationService {
|
|||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}> {
|
}> {
|
||||||
const ttl = Math.min(
|
const ttl = Math.min(Math.max(params.ttlMinutes || DEFAULT_TTL_MINUTES, 1), MAX_TTL_MINUTES);
|
||||||
Math.max(params.ttlMinutes || DEFAULT_TTL_MINUTES, 1),
|
|
||||||
MAX_TTL_MINUTES,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify target exists (and isn't an admin — defense in depth)
|
// Verify target exists (and isn't an admin — defense in depth)
|
||||||
const [target] = await this.db
|
const [target] = await this.db
|
||||||
@@ -78,9 +75,7 @@ export class ImpersonationService {
|
|||||||
const cookieValue = encodeURIComponent(signedValue);
|
const cookieValue = encodeURIComponent(signedValue);
|
||||||
|
|
||||||
const isHttps = (this.configService.get<string>("auth.url") ?? "").startsWith("https://");
|
const isHttps = (this.configService.get<string>("auth.url") ?? "").startsWith("https://");
|
||||||
const cookieName = isHttps
|
const cookieName = isHttps ? SESSION_COOKIE_NAME_SECURE : SESSION_COOKIE_NAME_INSECURE;
|
||||||
? SESSION_COOKIE_NAME_SECURE
|
|
||||||
: SESSION_COOKIE_NAME_INSECURE;
|
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Impersonation session created: founder=${params.founderId} target=${params.targetUserId} ttl=${ttl}m reason="${params.reason.slice(0, 80)}" sessionId=${sessionId.slice(0, 8)}…`,
|
`Impersonation session created: founder=${params.founderId} target=${params.targetUserId} ttl=${ttl}m reason="${params.reason.slice(0, 80)}" sessionId=${sessionId.slice(0, 8)}…`,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { StripeModule } from "../payments/stripe/stripe.module";
|
import { StripeModule } from "../payments/stripe/stripe.module";
|
||||||
|
import { PublicApiQuotaService } from "../public-api/public-api-quota.service";
|
||||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||||
import { BillingController } from "./billing.controller";
|
import { ApiKeysAdminController } from "./api-keys.controller";
|
||||||
|
import { BillingController, UserBillingController } from "./billing.controller";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
import { ImpersonationController } from "./impersonation.controller";
|
import { ImpersonationController } from "./impersonation.controller";
|
||||||
import { ImpersonationService } from "./impersonation.service";
|
import { ImpersonationService } from "./impersonation.service";
|
||||||
@@ -14,9 +16,11 @@ import { InternalVehiclesService } from "./vehicles.service";
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [SubscriptionsModule, StripeModule],
|
imports: [SubscriptionsModule, StripeModule],
|
||||||
controllers: [
|
controllers: [
|
||||||
|
ApiKeysAdminController,
|
||||||
ImpersonationController,
|
ImpersonationController,
|
||||||
LifecycleController,
|
LifecycleController,
|
||||||
BillingController,
|
BillingController,
|
||||||
|
UserBillingController,
|
||||||
PaymentsAdminController,
|
PaymentsAdminController,
|
||||||
InternalVehiclesController,
|
InternalVehiclesController,
|
||||||
],
|
],
|
||||||
@@ -25,6 +29,9 @@ import { InternalVehiclesService } from "./vehicles.service";
|
|||||||
LifecycleService,
|
LifecycleService,
|
||||||
BillingService,
|
BillingService,
|
||||||
InternalVehiclesService,
|
InternalVehiclesService,
|
||||||
|
// Redis kota sayaçlarını /internal/admin/api-keys/usage için okur
|
||||||
|
// (RedisModule @Global olduğundan bağımlılığı hazır).
|
||||||
|
PublicApiQuotaService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class InternalAdminModule {}
|
export class InternalAdminModule {}
|
||||||
|
|||||||
276
apps/api/src/jobs/__tests__/canonical-lexicon.spec.ts
Normal file
276
apps/api/src/jobs/__tests__/canonical-lexicon.spec.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { PRIORITY, classifyNode, mapToCanonical } from "../canonical-lexicon";
|
||||||
|
|
||||||
|
// Inputs are already NORMALIZE_SQL-folded (ASCII, lowercase, collapsed spaces).
|
||||||
|
|
||||||
|
describe("classifyNode", () => {
|
||||||
|
it("flags Opel model-code selector nodes as structural", () => {
|
||||||
|
expect(classifyNode("j82 ascona-c 1982 - 1988")).toBe("structural");
|
||||||
|
expect(classifyNode("c06 bls 2006 - 2009")).toBe("structural");
|
||||||
|
expect(classifyNode("b16 astra-k 2016 - 2021")).toBe("structural");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags bare year-range nodes as structural", () => {
|
||||||
|
expect(classifyNode("astra-h 2004 - 2014")).toBe("structural");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags MAN truck model descriptors as structural", () => {
|
||||||
|
expect(classifyNode("12.155 star")).toBe("structural");
|
||||||
|
expect(classifyNode("10/11.150 hocl/r")).toBe("structural");
|
||||||
|
expect(classifyNode("18t hocl-sd")).toBe("structural");
|
||||||
|
// a real category starting with digits+space (not a truck code) stays part
|
||||||
|
expect(classifyNode("4 silindirli motor")).toBe("part");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags MAN bus/coachbuilder model names as structural", () => {
|
||||||
|
expect(classifyNode("lion's city 12m")).toBe("structural");
|
||||||
|
expect(classifyNode("nl 202 muenchen")).toBe("structural");
|
||||||
|
expect(classifyNode("ng 242/272")).toBe("structural");
|
||||||
|
expect(classifyNode("man 292/332/362 h")).toBe("structural");
|
||||||
|
expect(classifyNode("n 1216 hd cityliner")).toBe("structural");
|
||||||
|
expect(classifyNode("aufbau caetano")).toBe("structural");
|
||||||
|
// "LPG" bus models (NL 205 M11 LPG) had wrongly mapped to Yakıt — now caught
|
||||||
|
expect(classifyNode("nl 205 m11 lpg")).toBe("structural");
|
||||||
|
// guard: real electrical part still maps, not swallowed by "n ?\d" etc.
|
||||||
|
expect(mapToCanonical("elektr. sistemler, gost. cih.").slug).toBe("elektrik");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags bare Mercedes catalog codes structural and index pages junk", () => {
|
||||||
|
expect(classifyNode("c442")).toBe("structural");
|
||||||
|
expect(classifyNode("c105011")).toBe("structural");
|
||||||
|
expect(classifyNode("sa listesi")).toBe("junk");
|
||||||
|
expect(classifyNode("ilk on resim aciklama sayfasi")).toBe("junk");
|
||||||
|
// descriptor codes that carry a part word must stay part (map via keyword)
|
||||||
|
expect(classifyNode("d765300(22h), ls 7 f, mb servo direksiyon")).toBe("part");
|
||||||
|
expect(classifyNode("conta takimi")).toBe("part");
|
||||||
|
expect(classifyNode("debriyaj balatasi")).toBe("part");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags empty / doc nodes as junk", () => {
|
||||||
|
expect(classifyNode("")).toBe("junk");
|
||||||
|
expect(classifyNode("ab")).toBe("junk");
|
||||||
|
expect(classifyNode("teknik literatur talimatlar")).toBe("junk");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags VIN markers and bare engine/order codes as structural", () => {
|
||||||
|
expect(classifyNode("wvwzzz9nz5-180000")).toBe("structural");
|
||||||
|
expect(classifyNode("z16dt[lfu]")).toBe("structural");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a real part that merely carries a date-validity marker", () => {
|
||||||
|
// ">>" alone must not reclassify a genuine part name
|
||||||
|
expect(classifyNode("pencere camlari d >> - 14.07.2019")).toBe("part");
|
||||||
|
expect(classifyNode("gosterge tablosu d - 30.05.2011>> - 27.05.2012")).toBe("part");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps real part-category names as part", () => {
|
||||||
|
expect(classifyNode("fren balata on")).toBe("part");
|
||||||
|
expect(classifyNode("eksantrik mili, supaplar (1,4/1,6ltr.)")).toBe("part");
|
||||||
|
expect(classifyNode("yag pompasi yag filtresi")).toBe("part");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapToCanonical", () => {
|
||||||
|
it("maps clear names to the expected bucket", () => {
|
||||||
|
expect(mapToCanonical("fren balata on").slug).toBe("fren");
|
||||||
|
expect(mapToCanonical("eksantrik mili supaplar").slug).toBe("motor");
|
||||||
|
expect(mapToCanonical("far temizleme sistemi").slug).toBe("aydinlatma");
|
||||||
|
expect(mapToCanonical("5-vitesli duz sanziman").slug).toBe("sanziman");
|
||||||
|
expect(mapToCanonical("koltuk sirtlik dolgu").slug).toBe("ic-doseme");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps expanded-lexicon terms (egzoz, karoseri, bcm)", () => {
|
||||||
|
expect(mapToCanonical("kat konvertorlu egz borusu").slug).toBe("yakit-sistemi");
|
||||||
|
expect(mapToCanonical("pikap karoserisi").slug).toBe("panjur-panel");
|
||||||
|
expect(mapToCanonical("merkezi elektrik kontrol unitesi bcm").slug).toBe("elektrik");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps short-stem inflections (yağı, mili, kardan)", () => {
|
||||||
|
expect(mapToCanonical("hps yagi").slug).toBe("yaglar-sivilar");
|
||||||
|
expect(mapToCanonical("kardan mili").slug).toBe("sanziman");
|
||||||
|
expect(mapToCanonical("araba akusu").slug).toBe("elektrik");
|
||||||
|
// regression: a strong term still wins over the newly-added weak inflection
|
||||||
|
expect(mapToCanonical("eksantrik mili supaplar").slug).toBe("motor");
|
||||||
|
expect(mapToCanonical("yag filtresi").slug).toBe("filtreler");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a root named exactly like its canonical bucket", () => {
|
||||||
|
expect(mapToCanonical("suspansiyon, sasi").slug).toBe("suspansiyon");
|
||||||
|
expect(mapToCanonical("elektrik").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("aydinlatma").slug).toBe("aydinlatma");
|
||||||
|
expect(mapToCanonical("direksiyon").slug).toBe("direksiyon");
|
||||||
|
expect(mapToCanonical("motor").slug).toBe("motor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps pcat shared-taxonomy roots", () => {
|
||||||
|
expect(mapToCanonical("jenerator").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("yaglama sistemi").slug).toBe("yaglar-sivilar");
|
||||||
|
expect(mapToCanonical("karo, arac cami").slug).toBe("panjur-panel");
|
||||||
|
expect(mapToCanonical("guc aktarim organlari").slug).toBe("sanziman");
|
||||||
|
expect(mapToCanonical("cerceve").slug).toBe("panjur-panel");
|
||||||
|
// regression: şasi stays under süspansiyon when paired with it
|
||||||
|
expect(mapToCanonical("suspansiyon, sasi").slug).toBe("suspansiyon");
|
||||||
|
expect(mapToCanonical("sasi kabugu ve plakalar").slug).toBe("panjur-panel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the new wheel + accessory buckets and the resolved ambiguous roots", () => {
|
||||||
|
expect(mapToCanonical("jantlar, lastikler").slug).toBe("jant-lastik");
|
||||||
|
expect(mapToCanonical("gobek ve tekerlekler").slug).toBe("jant-lastik");
|
||||||
|
expect(mapToCanonical("aksesuarlar ve cesitli").slug).toBe("aksesuar");
|
||||||
|
expect(mapToCanonical("hava sistemi").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("bakim parcalari").slug).toBe("filtreler");
|
||||||
|
// "hava yastığı" (airbag) must beat sogutma's weak "hava"
|
||||||
|
expect(mapToCanonical("hava yastigi surucu tarafi").slug).toBe("direksiyon");
|
||||||
|
// "hava filtresi" still filtreler; "…Yağlar Araç Bakım Ürünleri" still yakıt
|
||||||
|
expect(mapToCanonical("hava filtresi").slug).toBe("filtreler");
|
||||||
|
expect(mapToCanonical("yakitlar ve yaglar arac bakim urunleri").slug).toBe("yakit-sistemi");
|
||||||
|
// a real part with an "aksesuar" mention still wins over the catch-all
|
||||||
|
expect(mapToCanonical("fren aksesuarlari").slug).toBe("fren");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps English category names (pl24 Asian brands / untranslated leaves)", () => {
|
||||||
|
expect(mapToCanonical("engine").slug).toBe("motor");
|
||||||
|
expect(mapToCanonical("transmission").slug).toBe("sanziman");
|
||||||
|
expect(mapToCanonical("brake").slug).toBe("fren");
|
||||||
|
expect(mapToCanonical("chassis").slug).toBe("panjur-panel");
|
||||||
|
expect(mapToCanonical("axle & suspension").slug).toBe("suspansiyon");
|
||||||
|
expect(mapToCanonical("floor covering").slug).toBe("ic-doseme");
|
||||||
|
expect(mapToCanonical("body seats").slug).toBe("ic-doseme");
|
||||||
|
// system word beats the weak location word in a phrase
|
||||||
|
expect(mapToCanonical("body electrical").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("engine mechanical").slug).toBe("motor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps Opel spelling variants", () => {
|
||||||
|
expect(mapToCanonical("otomatik sanzuman").slug).toBe("sanziman");
|
||||||
|
expect(mapToCanonical("on dingil ve suspensiyon").slug).toBe("suspansiyon");
|
||||||
|
expect(mapToCanonical("disli secici kolu").slug).toBe("sanziman");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the Mercedes long-tail (English + Turkish part names)", () => {
|
||||||
|
expect(mapToCanonical("propeller shaft").slug).toBe("sanziman");
|
||||||
|
expect(mapToCanonical("radiator").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("sliding roof").slug).toBe("panjur-panel");
|
||||||
|
expect(mapToCanonical("driver's door").slug).toBe("dis-kaporta");
|
||||||
|
expect(mapToCanonical("windows").slug).toBe("dis-kaporta");
|
||||||
|
expect(mapToCanonical("timing").slug).toBe("triger-zincir-rulman");
|
||||||
|
expect(mapToCanonical("carburetor").slug).toBe("yakit-sistemi");
|
||||||
|
expect(mapToCanonical("pedal sistemi").slug).toBe("fren");
|
||||||
|
expect(mapToCanonical("arka kapak").slug).toBe("dis-kaporta");
|
||||||
|
expect(mapToCanonical("ortu").slug).toBe("ic-doseme");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps BMW electronics + lighting and Ford trans/engine variants", () => {
|
||||||
|
expect(mapToCanonical("audio, navigasyon, elektronik sistem").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("iletisim sistemi").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("distance systems, cruise control").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("isik verme").slug).toBe("aydinlatma");
|
||||||
|
expect(mapToCanonical("tvmt6 - 6 speed manual trans").slug).toBe("sanziman");
|
||||||
|
expect(mapToCanonical("t20dd0j - 2.0l diesel panther").slug).toBe("motor");
|
||||||
|
expect(mapToCanonical("govde ve boya").slug).toBe("panjur-panel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags BMW service/doc pseudo-pages as junk", () => {
|
||||||
|
expect(classifyNode("teknik dokuman")).toBe("junk");
|
||||||
|
expect(classifyNode("servis ve onarim kapsamlari")).toBe("junk");
|
||||||
|
expect(classifyNode("value parts&packages servisi ve onarim")).toBe("junk");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps small-brand terms (English + Turkish) added for max coverage", () => {
|
||||||
|
expect(mapToCanonical("diagnostic devices").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("in car entertainment").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("genel arac elektrigi").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("heater,a/c & ventilation").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("braking system").slug).toBe("fren");
|
||||||
|
expect(mapToCanonical("exterior fittings and sunroof").slug).toBe("dis-kaporta");
|
||||||
|
expect(mapToCanonical("occupant restraints").slug).toBe("ic-doseme");
|
||||||
|
expect(mapToCanonical("aracta konfor ve yasam").slug).toBe("ic-doseme");
|
||||||
|
expect(mapToCanonical("yapistiricilar / onarim kitleri").slug).toBe("yaglar-sivilar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps Kollar / Ön Takım to süspansiyon without breaking stronger matches", () => {
|
||||||
|
expect(mapToCanonical("kollar").slug).toBe("suspansiyon");
|
||||||
|
expect(mapToCanonical("on takim").slug).toBe("suspansiyon");
|
||||||
|
expect(mapToCanonical("arka takim").slug).toBe("suspansiyon");
|
||||||
|
// strong words still win over the weak axle terms
|
||||||
|
expect(mapToCanonical("conta takimi").slug).toBe("motor");
|
||||||
|
expect(mapToCanonical("vites kollari").slug).toBe("sanziman");
|
||||||
|
// "kol demiri" (armrest) must NOT become süspansiyon (we didn't add bare "kol")
|
||||||
|
expect(mapToCanonical("kol demirleri").slug).not.toBe("suspansiyon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps emex high-frequency terms", () => {
|
||||||
|
expect(mapToCanonical("stabilizer / baglanti elemanlari").slug).toBe("suspansiyon");
|
||||||
|
expect(mapToCanonical("sok absorber").slug).toBe("suspansiyon");
|
||||||
|
expect(mapToCanonical("isi esanjoru, ic isitma").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("kondansator").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("ventilator").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("uflec / parcalari").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("yer halilari ve halilar").slug).toBe("ic-doseme");
|
||||||
|
expect(mapToCanonical("dis kilitler").slug).toBe("dis-kaporta");
|
||||||
|
expect(mapToCanonical("stepne donanimlari").slug).toBe("jant-lastik");
|
||||||
|
expect(mapToCanonical("yagmurluklar").slug).toBe("dis-kaporta");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps emex low-brand terms (deep-dive additions)", () => {
|
||||||
|
expect(mapToCanonical("buharlastirici").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("kurutucu").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("aktuatorler").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("selenoid anahtar").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("anten").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("lambda kontrol").slug).toBe("yakit-sistemi");
|
||||||
|
expect(mapToCanonical("rolanti kontrolu").slug).toBe("yakit-sistemi");
|
||||||
|
expect(mapToCanonical("kanat parcalari").slug).toBe("panjur-panel");
|
||||||
|
expect(mapToCanonical("yan capraz kiris").slug).toBe("panjur-panel");
|
||||||
|
expect(mapToCanonical("burkma cubugu").slug).toBe("suspansiyon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("phrase overrides fix adjective+head mis-buckets", () => {
|
||||||
|
// "<system> yağı" = fluid, not the system
|
||||||
|
expect(mapToCanonical("motor yagi").slug).toBe("yaglar-sivilar");
|
||||||
|
expect(mapToCanonical("sanziman yagi").slug).toBe("yaglar-sivilar");
|
||||||
|
// but the PARTS keep their bucket (end-anchor excludes them)
|
||||||
|
expect(mapToCanonical("motor yagi filtresi").slug).toBe("filtreler");
|
||||||
|
expect(mapToCanonical("motor yagi pompasi").slug).toBe("motor");
|
||||||
|
// body electric motors → elektrik, not engine
|
||||||
|
expect(mapToCanonical("cam silecek motoru").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("cam motoru").slug).toBe("elektrik");
|
||||||
|
// timing gear → triger, not şanzıman
|
||||||
|
expect(mapToCanonical("zamanlama dislileri").slug).toBe("triger-zincir-rulman");
|
||||||
|
// engine cooling → soğutma; turbocharger → motor; drive belt → triger
|
||||||
|
expect(mapToCanonical("ic motor sogutma sistemi").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("emme manifoldu ve turbo sarjli").slug).toBe("motor");
|
||||||
|
expect(mapToCanonical("ic motor tahrik kayisi").slug).toBe("triger-zincir-rulman");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no keyword matches", () => {
|
||||||
|
expect(mapToCanonical("romork duzenegi cikarilabilir").slug).toBeNull();
|
||||||
|
// genuinely generic Mercedes names stay unmapped (better than wrong bucket)
|
||||||
|
expect(mapToCanonical("ayar").slug).toBeNull();
|
||||||
|
expect(mapToCanonical("genel parcalar").slug).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a strong head-noun beats a coincidental weak term (not ambiguous)", () => {
|
||||||
|
// 'filtre' (strong, filtreler) outweighs 'yag' (weak, yaglar-sivilar)
|
||||||
|
const m = mapToCanonical("yag filtresi");
|
||||||
|
expect(m.slug).toBe("filtreler");
|
||||||
|
expect(m.ambiguous).toBe(false);
|
||||||
|
// 'fren' (strong) outweighs 'hidrolik' (weak) — the old logic flagged this ambiguous
|
||||||
|
const f = mapToCanonical("fren hidrolik deposu");
|
||||||
|
expect(f.slug).toBe("fren");
|
||||||
|
expect(f.ambiguous).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a genuine two-strong tie as ambiguous", () => {
|
||||||
|
// fren(fren+balata) vs debriyaj(debriyaj+baski) — both score 6
|
||||||
|
const m = mapToCanonical("fren balata debriyaj baski");
|
||||||
|
expect(m.ambiguous).toBe(true);
|
||||||
|
expect(m.confidence).toBeLessThan(0.6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only returns slugs present in the priority list", () => {
|
||||||
|
const m = mapToCanonical("motor blok");
|
||||||
|
expect(m.slug).not.toBeNull();
|
||||||
|
expect(PRIORITY).toContain(m.slug);
|
||||||
|
});
|
||||||
|
});
|
||||||
62
apps/api/src/jobs/__tests__/canonical-sublexicon.spec.ts
Normal file
62
apps/api/src/jobs/__tests__/canonical-sublexicon.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { SUB_LEXICON, allSubLeaves, mapToSubcategory } from "../canonical-sublexicon";
|
||||||
|
|
||||||
|
describe("mapToSubcategory", () => {
|
||||||
|
it("maps Fren part names to the right sub-leaf (incl. Turkish variants)", () => {
|
||||||
|
expect(mapToSubcategory("on fren balatasi", "fren")).toBe("fren-balata");
|
||||||
|
expect(mapToSubcategory("fren diski on", "fren")).toBe("fren-disk");
|
||||||
|
expect(mapToSubcategory("fren kaliyeri", "fren")).toBe("fren-kaliper"); // kaliyer variant
|
||||||
|
expect(mapToSubcategory("fren kampanasi", "fren")).toBe("fren-kampana");
|
||||||
|
expect(mapToSubcategory("abs sensoru", "fren")).toBe("fren-abs");
|
||||||
|
expect(mapToSubcategory("fren hidroligi", "fren")).toBe("fren-hidrolik"); // hidroliği variant
|
||||||
|
expect(mapToSubcategory("el fren teli", "fren")).toBe("fren-elfren");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches assembly hardware in the donanım leaf, not a part leaf", () => {
|
||||||
|
expect(mapToSubcategory("baglanti civatasi", "fren")).toBe("fren-donanim");
|
||||||
|
expect(mapToSubcategory("klips", "fren")).toBe("fren-donanim");
|
||||||
|
// a real part keyword still wins over hardware words in the same name
|
||||||
|
expect(mapToSubcategory("fren balatasi klipsi", "fren")).toBe("fren-balata");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps across other high-value buckets", () => {
|
||||||
|
expect(mapToSubcategory("yag filtresi", "filtreler")).toBe("filtre-yag");
|
||||||
|
expect(mapToSubcategory("hava filtresi", "filtreler")).toBe("filtre-hava");
|
||||||
|
expect(mapToSubcategory("on far", "aydinlatma")).toBe("ayd-far");
|
||||||
|
expect(mapToSubcategory("stop lambasi", "aydinlatma")).toBe("ayd-stop");
|
||||||
|
expect(mapToSubcategory("on amortisor", "suspansiyon")).toBe("susp-amortisor");
|
||||||
|
expect(mapToSubcategory("silindir kapak contasi", "motor")).toBe("motor-silindirkapak");
|
||||||
|
expect(mapToSubcategory("debriyaj baski balata seti", "debriyaj")).toBe("debriyaj-balata");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps queue-shrink additions (2026-07-06)", () => {
|
||||||
|
expect(mapToSubcategory("sok absorber", "suspansiyon")).toBe("susp-amortisor");
|
||||||
|
expect(mapToSubcategory("guneslik", "ic-doseme")).toBe("ic-tavan");
|
||||||
|
expect(mapToSubcategory("gosterge paneli", "elektrik")).toBe("elektrik-modul");
|
||||||
|
expect(mapToSubcategory("anten", "elektrik")).toBe("elektrik-modul");
|
||||||
|
expect(mapToSubcategory("korna", "elektrik")).toBe("elektrik-korna");
|
||||||
|
expect(mapToSubcategory("silecek lastigi", "elektrik")).toBe("elektrik-silecek");
|
||||||
|
expect(mapToSubcategory("silecek motoru", "elektrik")).toBe("elektrik-motor"); // motor wins
|
||||||
|
expect(mapToSubcategory("havalandirma ve isitma", "sogutma")).toBe("sogutma-kalorifer");
|
||||||
|
expect(mapToSubcategory("isi esanjoru", "sogutma")).toBe("sogutma-kalorifer");
|
||||||
|
expect(mapToSubcategory("yag tavasi", "yaglar-sivilar")).toBe("yag-sistemi");
|
||||||
|
expect(mapToSubcategory("kisa dingil", "suspansiyon")).toBe("susp-aks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unknown bucket or unmatched name (→ SP manual)", () => {
|
||||||
|
expect(mapToSubcategory("balata", "aksesuar")).toBeNull(); // bucket not sub-split
|
||||||
|
expect(mapToSubcategory("belirtilmemis", "fren")).toBeNull();
|
||||||
|
expect(mapToSubcategory("", "fren")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every sub-leaf slug is globally unique and bucket-prefixed", () => {
|
||||||
|
const leaves = allSubLeaves();
|
||||||
|
const slugs = leaves.map((l) => l.slug);
|
||||||
|
expect(new Set(slugs).size).toBe(slugs.length); // no dupes
|
||||||
|
for (const l of leaves) {
|
||||||
|
// slug starts with a stem of its bucket (fren-*, motor-*, ayd-*, susp-*, …)
|
||||||
|
expect(l.slug).toMatch(/^[a-z]+-/);
|
||||||
|
}
|
||||||
|
expect(Object.keys(SUB_LEXICON).length).toBeGreaterThanOrEqual(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,9 +25,17 @@ export const QUEUE_NAMES = {
|
|||||||
SUBSCRIPTION_EXPIRY: "subscription-expiry",
|
SUBSCRIPTION_EXPIRY: "subscription-expiry",
|
||||||
QUERY_CLEANUP: "query-cleanup",
|
QUERY_CLEANUP: "query-cleanup",
|
||||||
CATALOG_PREFETCH: "catalog-prefetch",
|
CATALOG_PREFETCH: "catalog-prefetch",
|
||||||
|
// Dedicated lane for freshly-decoded vehicles: the main queue's deep-drill
|
||||||
|
// backlog runs in a production>consumption regime for hours at a time, where
|
||||||
|
// lifo tail-insertion starves ANY job pushed a moment earlier — including new
|
||||||
|
// vehicles' init chains (BMW 320i sat 58min at 0 parts). A separate queue +
|
||||||
|
// worker gives fresh decodes their own consumption budget.
|
||||||
|
CATALOG_PREFETCH_FAST: "catalog-prefetch-fast",
|
||||||
TRANSLATION: "translation",
|
TRANSLATION: "translation",
|
||||||
LIFECYCLE_EMAIL: "lifecycle-email",
|
LIFECYCLE_EMAIL: "lifecycle-email",
|
||||||
EXPERT_REWARDS: "expert-rewards",
|
EXPERT_REWARDS: "expert-rewards",
|
||||||
PART_PRICE_REFRESH: "part-price-refresh",
|
PART_PRICE_REFRESH: "part-price-refresh",
|
||||||
VINPIN_DECODE: "vinpin-decode",
|
VINPIN_DECODE: "vinpin-decode",
|
||||||
|
RPARTSTORE_DECODE: "rpartstore-decode",
|
||||||
|
CANONICAL_BACKFILL: "canonical-backfill",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
367
apps/api/src/jobs/canonical-lexicon.ts
Normal file
367
apps/api/src/jobs/canonical-lexicon.ts
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
// Canonical taxonomy mapping — shared logic for the canonical-backfill job.
|
||||||
|
//
|
||||||
|
// Two-stage mapping (proven on the VW+Opel dev pilot 2026-07-04):
|
||||||
|
// 1. classifyNode — is this raw OEM node an actual part-category, or a
|
||||||
|
// vehicle-selector (model/year/VIN/engine-code) node, or junk? Opel's pl24
|
||||||
|
// tree is ~87% model-code nodes ("j82 ascona-c 1982-1988"); those must NOT
|
||||||
|
// be mapped.
|
||||||
|
// 2. mapToCanonical — WEIGHTED keyword-match the surviving part-categories to
|
||||||
|
// one of the 20 canonical top buckets (migration 0024 seed). Strong
|
||||||
|
// head-nouns weigh 3, supporting terms 1; the score margin decides the
|
||||||
|
// winner and flags near-ties as ambiguous (rather than the old equal-count
|
||||||
|
// tie-break, which over-flagged).
|
||||||
|
//
|
||||||
|
// Names arrive already folded by NORMALIZE_SQL (Turkish → ASCII, lowercased,
|
||||||
|
// whitespace collapsed) so the same expression can drive the SELECT that builds
|
||||||
|
// the map and the UPDATE that materializes categories.canonical_category_id.
|
||||||
|
|
||||||
|
// Keep in exact sync with the UPDATE join in canonical-backfill.processor.ts —
|
||||||
|
// both sides must fold identically or the join misses.
|
||||||
|
export const NORMALIZE_SQL =
|
||||||
|
"btrim(regexp_replace(translate(lower(name),'çşğıöü','csgiou'),'\\s+',' ','g'))";
|
||||||
|
|
||||||
|
const STRONG_WEIGHT = 3;
|
||||||
|
const WEAK_WEIGHT = 1;
|
||||||
|
|
||||||
|
// JS equivalent of NORMALIZE_SQL, for on-the-fly serving-side mapping of
|
||||||
|
// categories whose canonical_category_id isn't materialized yet (freshly
|
||||||
|
// decoded vehicles between backfills). Must fold like the SQL side.
|
||||||
|
const TR_FOLD: Record<string, string> = {
|
||||||
|
ç: "c",
|
||||||
|
ş: "s",
|
||||||
|
ğ: "g",
|
||||||
|
ı: "i",
|
||||||
|
ö: "o",
|
||||||
|
ü: "u",
|
||||||
|
};
|
||||||
|
export function foldName(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[çşğıöü]/g, (c) => TR_FOLD[c] ?? c)
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Bucket {
|
||||||
|
strong: string[];
|
||||||
|
weak: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// slug → { strong head-nouns, weak supporting terms }. slug MUST match a
|
||||||
|
// canonical_categories.slug seeded in migration 0024. Weak terms are the ones
|
||||||
|
// that recur across buckets (yag, hidrolik, cam, panel, rot, kayis…) so they
|
||||||
|
// only tip the balance, never carry a match alone.
|
||||||
|
export const LEXICON: Record<string, Bucket> = {
|
||||||
|
// English keywords (engine/body/brake…) are mixed in per bucket: many pl24
|
||||||
|
// Asian-brand catalogs (Hyundai/Kia/Nissan/Toyota) and untranslated emex/pcat
|
||||||
|
// leaves ship English category names. System words are strong, location words
|
||||||
|
// (body/floor/axle) weak so "BODY ELECTRICAL" → elektrik, not panjur.
|
||||||
|
motor: {
|
||||||
|
// "conta" (gasket) strong: mostly engine, and beats the weak "takim" so
|
||||||
|
// "conta takımı" → motor (not süspansiyon).
|
||||||
|
strong:
|
||||||
|
"motor silindir krank piston segman biyel supap subap eksantrik karter kulbutor conta engine".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
// "diesel"/"dohc" Ford engine variants; "mekanik" (Turkish mechanical);
|
||||||
|
// "aggregate" (MAN engine/genset unit).
|
||||||
|
weak: "blok manifold yatak kompresyon bolmesi kam turbo mechanical mekanik diesel dohc aggregate".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"yakit-sistemi": {
|
||||||
|
// 'depo' is weak, not strong: "deposu" (reservoir) recurs in fren-hidrolik-
|
||||||
|
// deposu / cam-suyu-deposu etc.; "yakit" carries the fuel-tank signal.
|
||||||
|
strong:
|
||||||
|
"yakit enjektor enjeksiyon karburator carburetor egzoz egz konvertor katalitik susturucu fuel exhaust".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
weak: "benzin mazot lpg rail dagitici depo lambda rolanti".split(" "),
|
||||||
|
},
|
||||||
|
"atesleme-sistemi": {
|
||||||
|
strong: "buji bobin atesleme distributor kizdirma ignition".split(" "),
|
||||||
|
weak: [],
|
||||||
|
},
|
||||||
|
"triger-zincir-rulman": {
|
||||||
|
strong: "triger timing".split(" "),
|
||||||
|
weak: "zincir gergi kayis rulman".split(" "),
|
||||||
|
},
|
||||||
|
sogutma: {
|
||||||
|
// emex adds: esanjor (ısı eşanjörü), kondansator, ventilator, kompresor
|
||||||
|
// (AC), sogutucu; "uflec" (blower) weak.
|
||||||
|
strong:
|
||||||
|
"radyator termostat klima kalorifer intercooler evaporator buharlastirici kurutucu devirdaim fan cooling heater ventilation ventilator esanjor kondansator kompresor sogutucu".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
// "hava" weak so "Hava Sistemi" → soğutma (user decision) but "hava filtresi"
|
||||||
|
// (filtre strong) / "hava yastığı" (yastigi strong, direksiyon) still win.
|
||||||
|
weak: "sogutma isitici hortum hava heating radiator uflec".split(" "),
|
||||||
|
},
|
||||||
|
filtreler: {
|
||||||
|
strong: "filtre filter".split(" "),
|
||||||
|
// "bakım parçaları / rutin bakım" → filtreler (user decision); weak so a
|
||||||
|
// stronger term (yakıt/yağ) still wins on "…Araç Bakım Ürünleri" roots.
|
||||||
|
weak: "bakim".split(" "),
|
||||||
|
},
|
||||||
|
fren: {
|
||||||
|
strong: "fren balata kaliper kampana disk abs brake braking".split(" "),
|
||||||
|
// "pedal" weak → Mercedes "PEDAL SİSTEMİ" (pedal cluster) lands in fren.
|
||||||
|
weak: "mustur diskli pedal".split(" "),
|
||||||
|
},
|
||||||
|
debriyaj: {
|
||||||
|
strong: "debriyaj volan baski clutch".split(" "),
|
||||||
|
weak: "kavrama".split(" "),
|
||||||
|
},
|
||||||
|
sanziman: {
|
||||||
|
// "sanzuman"/"disli" are Opel spelling variants (şanzıman / dişli).
|
||||||
|
strong: "sanziman sanzuman vites diferansiyel kardan aktarim transmission gearbox".split(" "),
|
||||||
|
// 3-char stems (mil…) don't prefix-match their inflections, so list them.
|
||||||
|
// "speed"/"spd" for Ford trans variants ("6 Speed Manual Trans", "8 Spd Auto").
|
||||||
|
weak: "flans kavrama saft mil mili miller gear disli propeller tahrik speed spd".split(" "),
|
||||||
|
},
|
||||||
|
suspansiyon: {
|
||||||
|
// "suspensiyon" Opel variant; "dingil"=axle; "stabilizator/stabilizer"=anti-
|
||||||
|
// roll bar; "absorber"=(şok) absorber (emex).
|
||||||
|
strong:
|
||||||
|
"suspansiyon suspensiyon amortisor salincak rotil helezon viraj mafsal suspension stabilizator stabilizer absorber".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
// "takoz/takozu"=strut/engine mount; "kollar"=(salıncak) control arms;
|
||||||
|
// "takim"=(ön/arka) axle assembly. Weak so strong words win: "conta takımı"
|
||||||
|
// → motor, "vites kolları" → şanzıman, but standalone "Kollar"/"Ön Takım" →
|
||||||
|
// süspansiyon. NOT "kol" (singular): "kol demiri" (armrest) is ic-doseme.
|
||||||
|
weak: "rot aks dingil burc yay bilya rulman tasiyici zemberek denge axle takoz takozu kollar takim burkma".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
direksiyon: {
|
||||||
|
// "yastigi" strong so "hava yastığı" (airbag) beats the sogutma "hava" weak.
|
||||||
|
strong: "direksiyon kremayer airbag yastigi steering".split(" "),
|
||||||
|
weak: "rot hidrolik".split(" "),
|
||||||
|
},
|
||||||
|
elektrik: {
|
||||||
|
strong:
|
||||||
|
"elektrik elektr elektrigi alternator jenerator mars sarj role sigorta sensor korna bcm ecu beyin modul aku akusu enstruman gosterge electric electrical electricity audio ses navigasyon elektronik iletisim multimedya diagnostic entertainment bilgisayar aktuator selenoid anten".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
weak: "kablo sinyal silecek tesisat salter salteri verici vericisi cruise".split(" "),
|
||||||
|
},
|
||||||
|
aydinlatma: {
|
||||||
|
// far/farlar/fari — 3-char "far" only matches exactly; add inflections.
|
||||||
|
// "isik" = BMW "Işık verme" (lighting).
|
||||||
|
strong: "aydinlatma isik far farlar fari stop lamba ampul sis reflektor lighting lamp".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
weak: "sinyal".split(" "),
|
||||||
|
},
|
||||||
|
"dis-kaporta": {
|
||||||
|
strong: "ayna".split(" "),
|
||||||
|
weak: "cam camlar camlari fitil krikosu kapi kapak kapaklar mentese cikma bagaj camlik mirror glass door window sunroof exterior locking kilit yagmurluk".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
tampon: {
|
||||||
|
strong: "tampon izgara".split(" "),
|
||||||
|
weak: "grill cekme".split(" "),
|
||||||
|
},
|
||||||
|
"panjur-panel": {
|
||||||
|
strong: "panjur kaput kaporta davlumbaz marspiyel camurluk karoser karo cerceve".split(" "),
|
||||||
|
// "sasi"/"body"/"chassis" weak so system words (electrical…) win in phrases
|
||||||
|
// like "BODY ELECTRICAL"; "süspansiyon, şasi" also stays a clean süspansiyon.
|
||||||
|
weak: "panel tavan sac canak direk sasi govde kanat kiris body chassis roof wall underpan".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"ic-doseme": {
|
||||||
|
// "koltugu" = seat with possessive suffix (koltuk→koltuğu, k→ğ mutation
|
||||||
|
// breaks the prefix match, so list it explicitly).
|
||||||
|
strong:
|
||||||
|
"koltuk koltugu doseme sirtlik arkalik guneslik minder torpido konsol konfor hali seat seats trim restraint occupant".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
weak: "kaplama zemin baslik kemer emniyet panel bardaklik dayama dolgu floor covering interior ortu halilar".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"yaglar-sivilar": {
|
||||||
|
strong: "antifriz gres adblue".split(" "),
|
||||||
|
// "yag" (3 chars) misses "yagi/yaglar/yagli" under the >=4 prefix rule.
|
||||||
|
weak: "yag yagi yaglar yagli yaglama hidrolik sprey katki madeni sivi oil yapistirici".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"jant-lastik": {
|
||||||
|
strong: "jant lastik tekerlek gobek stepne wheel tyre tire".split(" "),
|
||||||
|
weak: "hub".split(" "),
|
||||||
|
},
|
||||||
|
aksesuar: {
|
||||||
|
strong: "aksesuar accessory accessories".split(" "),
|
||||||
|
weak: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deterministic tie-break for exactly-equal scores: earlier = more specific.
|
||||||
|
export const PRIORITY: string[] = [
|
||||||
|
"atesleme-sistemi",
|
||||||
|
"triger-zincir-rulman",
|
||||||
|
"filtreler",
|
||||||
|
"fren",
|
||||||
|
"debriyaj",
|
||||||
|
"sanziman",
|
||||||
|
"aydinlatma",
|
||||||
|
"ic-doseme",
|
||||||
|
"sogutma",
|
||||||
|
"yakit-sistemi",
|
||||||
|
"yaglar-sivilar",
|
||||||
|
"direksiyon",
|
||||||
|
"suspansiyon",
|
||||||
|
"jant-lastik",
|
||||||
|
"tampon",
|
||||||
|
"panjur-panel",
|
||||||
|
"dis-kaporta",
|
||||||
|
"elektrik",
|
||||||
|
"motor",
|
||||||
|
// Catch-all last: a name with any real part keyword wins over "aksesuar".
|
||||||
|
"aksesuar",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Model-code prefix: Opel j82/c06/tf0/b16. 1-3 letters + 2 digits at start.
|
||||||
|
const MODEL_CODE = /^[a-z]{1,3}\d{2}\b/;
|
||||||
|
// VW/OEM VIN prefixes (wvwzzz…, wvgzzz…) — decode markers, not categories.
|
||||||
|
const VIN_LIKE = /\b[a-z]{2,3}zzz|w[a-z]{2}\d{3,}/;
|
||||||
|
// Pure "YYYY - YYYY" range with little else = a year/model selector node.
|
||||||
|
const YEAR_RANGE_ONLY = /^\D*(19|20)\d{2}\s*-\s*(19|20)\d{2}\D*$/;
|
||||||
|
const YEAR_RANGE_ANY = /\b(19|20)\d{2}\s*-\s*(19|20)\d{2}\b/;
|
||||||
|
// Bare engine/order code like "z16dt", "z16dt[lfu]" — a single token with digits.
|
||||||
|
const CODE_TOKEN = /^[a-z]{1,3}\d{1,3}[a-z]{0,4}(\[[a-z]+\])?$/;
|
||||||
|
// Mercedes BARE catalog codes only ("C442", "C105011"). Anchored so descriptor
|
||||||
|
// nodes that also carry a part name ("D765300(22H), LS 7 F, MB SERVO DİREKSİYON"
|
||||||
|
// → direksiyon) still map via keyword instead of being wrongly dropped.
|
||||||
|
const MB_CATALOG_CODE = /^[cd]\d{3,}$/;
|
||||||
|
// MAN truck model descriptors: "12.155 STAR", "10/11.150 HOCL/R", "18T HOCL-SD".
|
||||||
|
// (Bare "14250"-style codes are NOT matched — ^\d{4,}/^\d{4,5} would hit 362 pl24
|
||||||
|
// + 2800 emex mapped rows.)
|
||||||
|
const TRUCK_MODEL_CODE = /^\d{1,2}[./]\d|^\d{2}t[ -]/i;
|
||||||
|
// MAN bus/truck model + coachbuilder names (LION'S CITY, SKYLINER, CITYLINER,
|
||||||
|
// NL 202, ND 313, NG 242, MAN 292, AUFBAU CAETANO). Vehicle selectors, not part
|
||||||
|
// categories — verified 0 emex/pcat mapped rows collide; the few pl24 hits are
|
||||||
|
// LION'S/LPG models that had wrongly matched a part keyword.
|
||||||
|
const MAN_MODEL_CODE =
|
||||||
|
/^(nd|ng|nl|ngt|man|el|gs|le|sg|sl|sr|sue|uel|rh|nm|nmt|nue|sd) \d|^(maz|mftpl)\b|^266m|lion|skyliner|starliner|cityliner|tourliner|trendliner|jetliner|centroliner|intercity|neoplan|sinotruk|doromat|aufbau|goeppel|gorica|joskin|neoman|caetano|newcar|new flyer|gelenk|maribor|slovenien/i;
|
||||||
|
const DOC_JUNK = new Set([
|
||||||
|
"teknik literatur talimatlar",
|
||||||
|
"aletler",
|
||||||
|
"kargo tahditi",
|
||||||
|
"kriko alet",
|
||||||
|
"preliminary remarks",
|
||||||
|
]);
|
||||||
|
// Catalog index / service / doc pseudo-pages — not part categories. Mercedes
|
||||||
|
// "SA LİSTESİ"/"…resim açıklama sayfası"; BMW "Teknik doküman", "Servis ve
|
||||||
|
// onarım kapsamları", "Value Parts&Packages servisi", "Parça Onarım Servisi".
|
||||||
|
const DOC_PAGE =
|
||||||
|
/resim aciklama|^sa listesi$|genel bakis tablosu|teknik dokuman|servis ve onarim|onarim servisi|onarim kapsam|value parts|servis aralik|ek onarim/;
|
||||||
|
|
||||||
|
export type NodeKind = "part" | "structural" | "junk";
|
||||||
|
|
||||||
|
function wordTokens(folded: string): string[] {
|
||||||
|
return folded.split(" ").filter((w) => w.length > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** folded = output of NORMALIZE_SQL. */
|
||||||
|
export function classifyNode(folded: string): NodeKind {
|
||||||
|
const f = folded.trim();
|
||||||
|
if (!f || f.length < 3) return "junk";
|
||||||
|
if (DOC_JUNK.has(f) || DOC_PAGE.test(f)) return "junk";
|
||||||
|
if (MB_CATALOG_CODE.test(f) || TRUCK_MODEL_CODE.test(f) || MAN_MODEL_CODE.test(f))
|
||||||
|
return "structural";
|
||||||
|
// NOTE: don't treat a bare ">>" marker as structural — real VW part names
|
||||||
|
// carry validity-date markers ("pencere camlari d >> - 14.07.2019"); VIN_LIKE
|
||||||
|
// below catches the actual VIN nodes ("wvwzzz…") without dropping real parts.
|
||||||
|
if (VIN_LIKE.test(f)) return "structural";
|
||||||
|
if (MODEL_CODE.test(f)) return "structural";
|
||||||
|
if (YEAR_RANGE_ONLY.test(f)) return "structural";
|
||||||
|
const words = wordTokens(f);
|
||||||
|
// Single bare code token (engine/order code) → selector, not a category.
|
||||||
|
if (words.length <= 1 && CODE_TOKEN.test(f)) return "structural";
|
||||||
|
// year range + very few real words → still a selector ("astra-h 2004 - 2014")
|
||||||
|
if (YEAR_RANGE_ANY.test(f) && words.length <= 4) return "structural";
|
||||||
|
return "part";
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokens(folded: string): string[] {
|
||||||
|
return folded.split(/[^a-z0-9]+/).filter((t) => t.length > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitCount(toks: string[], keywords: string[]): number {
|
||||||
|
let c = 0;
|
||||||
|
for (const t of toks) {
|
||||||
|
for (const k of keywords) {
|
||||||
|
if (t === k || (k.length >= 4 && t.startsWith(k))) {
|
||||||
|
c++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanonicalMatch {
|
||||||
|
slug: string | null;
|
||||||
|
ambiguous: boolean;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weighted keyword-map a part-category folded name to a canonical top slug.
|
||||||
|
* Score = 3·(strong hits) + 1·(weak hits) per bucket. The winner is the top
|
||||||
|
* score; a runner-up within 1 point (no strong keyword decisively separated the
|
||||||
|
* two) is flagged ambiguous. Confidence scales with the winning margin.
|
||||||
|
*/
|
||||||
|
// High-confidence PHRASE overrides, checked before the bag-of-words score.
|
||||||
|
// They fix "adjective + head-noun" cases where a strong keyword wins on the
|
||||||
|
// modifier instead of the head: "motor yağı" scores motor(3) but the head noun
|
||||||
|
// is the oil. Order doesn't matter (first match wins); keep each tight.
|
||||||
|
const OVERRIDES: [RegExp, string][] = [
|
||||||
|
// "<system> yağı/yağları" (head = oil) → fluid. END-ANCHORED so the PARTS
|
||||||
|
// "motor yağı filtresi / pompası / soğutucusu" are NOT captured (they keep
|
||||||
|
// scoring to filtre / motor / soğutma).
|
||||||
|
[/(motor|sanziman|diferansiyel|direksiyon|hidrolik|fren|torpido) yag(i|lari)$/, "yaglar-sivilar"],
|
||||||
|
// body/electric motors (window/wiper/blower), not the engine.
|
||||||
|
[/(cam|silecek|kalorifer|kaput) motor/, "elektrik"],
|
||||||
|
// timing gear/chain/belt → triger, not şanzıman via the weak "dişli".
|
||||||
|
[/zamanlama (disli|zincir|kayis|seti|gergi|kasnak)/, "triger-zincir-rulman"],
|
||||||
|
// "(IC) Motor Soğutma …" = engine COOLING; head is soğutma, "motor" is the
|
||||||
|
// subsystem qualifier (pcat's "IC Motor <system>" prefix inflates motor).
|
||||||
|
[/motor sogutma/, "sogutma"],
|
||||||
|
// "türbo şarj(lı)" = turbocharger → engine, not elektrik via "şarj" (charge).
|
||||||
|
[/turbo sarj/, "motor"],
|
||||||
|
// "(tahrik) kayışı" = drive/accessory belt → triger (belt bucket), not motor.
|
||||||
|
[/tahrik kayis/, "triger-zincir-rulman"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function mapToCanonical(folded: string): CanonicalMatch {
|
||||||
|
for (const [re, slug] of OVERRIDES) {
|
||||||
|
if (re.test(folded)) return { slug, ambiguous: false, confidence: 0.95 };
|
||||||
|
}
|
||||||
|
const toks = tokens(folded);
|
||||||
|
let bestSlug: string | null = null;
|
||||||
|
let best = 0;
|
||||||
|
let second = 0;
|
||||||
|
for (const slug of PRIORITY) {
|
||||||
|
const b = LEXICON[slug];
|
||||||
|
const score = STRONG_WEIGHT * hitCount(toks, b.strong) + WEAK_WEIGHT * hitCount(toks, b.weak);
|
||||||
|
if (score > best) {
|
||||||
|
second = best;
|
||||||
|
best = score;
|
||||||
|
bestSlug = slug;
|
||||||
|
} else if (score > second) {
|
||||||
|
second = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best === 0) return { slug: null, ambiguous: false, confidence: 0 };
|
||||||
|
const margin = best - second;
|
||||||
|
if (second === 0) return { slug: bestSlug, ambiguous: false, confidence: 0.95 };
|
||||||
|
const ambiguous = margin < 2;
|
||||||
|
const confidence = margin >= 3 ? 0.9 : margin >= 2 ? 0.8 : margin >= 1 ? 0.65 : 0.5;
|
||||||
|
return { slug: bestSlug, ambiguous, confidence };
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user