Compare commits
332 Commits
fix/backfi
...
feat/admin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
854ccbbe12 | ||
| d9bc61b99e | |||
|
|
8d284f6442 | ||
| 4cae337241 | |||
|
|
dde6f6a15c | ||
|
|
3cfa3d588a | ||
|
|
02ec3856d5 | ||
| 2add2b253a | |||
|
|
56a8ea09be | ||
| 62ad9ac318 | |||
|
|
e50a907931 | ||
| ca18749ec5 | |||
|
|
330015fbb6 | ||
| 24f7e7aa5b | |||
| 5d9da95830 | |||
| 1e3f37f71e | |||
| 181bdbe971 | |||
| c12b2992f9 | |||
| 57d16b9aea | |||
| eb277cd55a | |||
| 8346a1db8e | |||
| 2df73e6a48 | |||
| c21472d930 | |||
| 3597b03c4f | |||
| 2cda50f9af | |||
| 6c936d546b | |||
| bf0cc4e96c | |||
| 685c6af3ed | |||
| 95818b2501 | |||
| 55b4cd38e9 | |||
| c0045fa372 | |||
| 6610bdf720 | |||
| 8c1ee15fac | |||
| ed8a6ce6e9 | |||
| 8a4dd6e10e | |||
| bf834b8f41 | |||
| bd134bba06 | |||
| 7fd486855f | |||
| 8d07d9a31a | |||
| fdbaaf375f | |||
| ebbdb22e33 | |||
| 8e4581f1b5 | |||
| 679664d7b0 | |||
| c3ea03db03 | |||
| 7ec4fb9297 | |||
| 6df9c18efb | |||
| f42f1888ae | |||
| 0f2126d694 | |||
| 3248ab1c3a | |||
| a6437dcd67 | |||
| 544b3f25d1 | |||
| cc543ace21 | |||
| c03e7f4079 | |||
| 41f2f1777f | |||
| d7d78a77a6 | |||
| 5a79e94df3 | |||
| dac0708cda | |||
| 5628cbceaf | |||
| aa798ec03d | |||
| 0011c80ec3 | |||
| 46f0210ba6 | |||
| 31aabfc28f | |||
| 0de05e5515 | |||
| 9cb58c583c | |||
| d55a2b570f | |||
| cf36da07af | |||
| 814d11d03e | |||
| ee56dec2d5 | |||
| 8137845198 | |||
| c8d9e45207 | |||
| 281c54a423 | |||
| 5b93864dd5 | |||
| f54511d393 | |||
| 9f6693e833 | |||
| e0b3de8dc7 | |||
| 6c5f8401f8 | |||
| 542ab0cb2b | |||
| 74c718fb18 | |||
| 0ad3f114f3 | |||
| 68e3eb4a61 | |||
| 61b5769e48 | |||
| 8e10ebc883 | |||
| 29e10b432f | |||
| 55dd34a450 | |||
| 66b39862c8 | |||
| fcc02984dd | |||
| edde2dc3b4 | |||
| f52cafd622 | |||
| d0caf57e89 | |||
| 32655ead7b | |||
| 98376747cd | |||
| ccf2417cc8 | |||
| d253a43ad2 | |||
| c2bb1e7382 | |||
| 1fade89df4 | |||
| 7387c02da0 | |||
| 7069ceaeae | |||
| cf9454482f | |||
| eaa500ddd5 | |||
| e47e795a09 | |||
| 77c93af9a0 | |||
| 24716d03a9 | |||
| 6de7839107 | |||
| edcd168d48 | |||
| c20fc5462a | |||
| 4bba42fc13 | |||
| 2bf7525fe2 | |||
| bf49900b57 | |||
| ff96dcac1c | |||
| 5a3e1a2299 | |||
| 24495d8970 | |||
| de960a0dd4 | |||
| cee11b5769 | |||
| 5697447988 | |||
| 70d901f9f5 | |||
| 731ac93a55 | |||
|
|
0e8b2ab294 | ||
| 814b798b3f | |||
| c51b7b4e78 | |||
| ff81b244b7 | |||
| 8dd2d223e9 | |||
| 36ed37d5c0 | |||
| c8803ada35 | |||
| ccc2cd75a5 | |||
| f2a37b78cb | |||
| 0e2871c964 | |||
| 116def2c79 | |||
| bcf8bb38cd | |||
| d38bcaa36f | |||
| 25965717c3 | |||
| 671e9af15b | |||
| da95b0484d | |||
| 5ed3959a12 | |||
| eb92c5c5f8 | |||
| 8a079d84f8 | |||
| 1c4c896d64 | |||
| 3c6c27cb00 | |||
| 8d11ae7400 | |||
| cafe206001 | |||
| a803d9da10 | |||
|
|
8054e2faf3 | ||
| 7956fc9e9b | |||
| 006cf1fd63 | |||
| 6fb27d9509 | |||
| 394e71e269 | |||
| 454eab6b0e | |||
| 6585412932 | |||
| df04784ddb | |||
| 239a46451d | |||
| d167c15c5e | |||
| ae31a7b61a | |||
| 101ee755f1 | |||
| dc65c61440 | |||
| 380ab60572 | |||
| 28720c7d7d | |||
| 8b7bd56c7b | |||
| 7fb3608dea | |||
| 8ce387c0c5 | |||
| 5a933fec9d | |||
| 682f189e6e | |||
| f5d7a27ea3 | |||
| d997abc3d3 | |||
| e5fd44820a | |||
| b5b846f3f2 | |||
| f25ef2d54f | |||
| 6eb7dc11a4 | |||
| 7b1daffe97 | |||
| c5c569b941 | |||
| 013cd43e7e | |||
| c6e5d06172 | |||
| 34d97b47ac | |||
| dc5cf4039c | |||
| 6f8e9ff618 | |||
| 2581908cc4 | |||
| 6e556e198a | |||
| cec68a8cd9 | |||
| 75dbadcb47 | |||
| 046b8a8f4c | |||
| 1aaf2d8d8b | |||
| 33f58e14c6 | |||
| db646d10f4 | |||
| a9a40e4587 | |||
| 2d5e2d04a9 | |||
| cf11379b49 | |||
| 9dd3b2c858 | |||
| 5ac92556e3 | |||
| 458603fb98 | |||
| f63ccf0aef | |||
| 5900b8d399 | |||
| 57d9ff2e30 | |||
| a10654f210 | |||
| d04d27d221 | |||
| 86a08dbe12 | |||
| c4608a8421 | |||
| c88b6b45c5 | |||
| 0517a57021 | |||
| e42345f35b | |||
| dce3a4f332 | |||
| ee7925c960 | |||
| 527f69dc1b | |||
| 50a92c78cc | |||
| b6a6893f6f | |||
| db4b85da8e | |||
| e428a82bd5 | |||
| 44987e7e7e | |||
| 675fab0c79 | |||
| 1fcbfdf5d1 | |||
| afd92e90b6 | |||
| 347d9f2ddb | |||
| 51eec60c6c | |||
| f1f36d8cf1 | |||
| bf259f3d79 | |||
| 25cf0fa670 | |||
| c9f96f8666 | |||
| 82793a8257 | |||
| 51032342b8 | |||
| aba1bfc126 | |||
| 3fb7fa1aa3 | |||
| c70b9b61b9 | |||
| 6bbc32d9c6 | |||
| 05951f9853 | |||
| 50c7d91707 | |||
| 4b6642a78a | |||
| ef8240a012 | |||
| b547140eb1 | |||
| 5abdc6f628 | |||
| efa395b7b5 | |||
| b96ca78942 | |||
| 5a74a0e552 | |||
| 643e1d923c | |||
| dedcf71419 | |||
| 07d4bf7e69 | |||
| cc1d22a7d4 | |||
| af2b35e6e2 | |||
| 3c26f00a19 | |||
| cd66947303 | |||
| c3d0dbaa0b | |||
| 8b88ccd238 | |||
| 754f1af61c | |||
| 0ae7c3ceb6 | |||
| 7b5e678c55 | |||
| d2f42af935 | |||
| ec95ab025d | |||
| 8f6894eecb | |||
| 123b701ed7 | |||
| cd30278ad9 | |||
| f586b2f9c2 | |||
| da2eaecd47 | |||
| d46a463076 | |||
| e952687710 | |||
| ae61ac055e | |||
| acadd84e4a | |||
| 773e9c35fa | |||
| 06f247beb7 | |||
| c9c33d58e1 | |||
| 7a4fe51d09 | |||
| 4614d79d77 | |||
| 371aed3645 | |||
| 98c21fa30e | |||
| 5b640988c4 | |||
| 8735eece13 | |||
| 373af248b4 | |||
| 5763e7fbff | |||
| 9df85c335f | |||
| 346ebaa0e6 | |||
| a4e53f3cfd | |||
| 3de7fa4989 | |||
| fff873a64d | |||
| 38c2afa587 | |||
| 8e45d00173 | |||
| dfe90fb968 | |||
| 72889796e5 | |||
| 3096d35113 | |||
| a4103a8e08 | |||
| 31fbcf2821 | |||
| 0760989b52 | |||
| 9232e40942 | |||
| 00d1e8c961 | |||
| b3dd119c99 | |||
| 335a3b0cae | |||
| 95061dfa53 | |||
| fd884271b7 | |||
| d119b1eb2c | |||
| 5200d5a61d | |||
| 7c715af0c5 | |||
| 61436db48f | |||
| a36008017c | |||
| 70b6597811 | |||
| 64b2c9fb06 | |||
| 690bdeb647 | |||
| 3cadf80286 | |||
| 50cef50289 | |||
| 34d68510e3 | |||
| bee68b6a34 | |||
| 5aad3e8b22 | |||
| b992c0075b | |||
| aa4503f1c9 | |||
| aba8e5eb9b | |||
| 97c1dbcece | |||
| d5362a5390 | |||
| 7571438a6b | |||
| e57a9968b2 | |||
| 6c6b74a8e5 | |||
| 4879f7cdef | |||
| f394897a08 | |||
| 51d8104cdc | |||
| 500738fcd4 | |||
| 532e1ad9ef | |||
| 6a54e63c0f | |||
| 7d10107f4b | |||
| a304420456 | |||
| 2c848df97d | |||
| 33e59c9554 | |||
| cbdff3311f | |||
| 137d0c07a6 | |||
| 8c1d247c45 | |||
| 579ee8d275 | |||
| 4df06ab3a1 | |||
| 58a0faeb99 | |||
| 6c414f9a59 | |||
| fe35bbd826 | |||
| 55894bec0e | |||
| 4068091a21 | |||
| 5c7803e12a | |||
| 24e668ea01 | |||
| eccecf73e6 | |||
| 6575c611e8 | |||
| 4072c6e736 | |||
| d922fb7a02 | |||
| 8fddd09087 | |||
| 09a9487564 | |||
| e6c8c9c32e |
10
Dockerfile
10
Dockerfile
@@ -30,14 +30,20 @@ ARG VITE_POSTHOG_KEY=
|
||||
ARG VITE_META_PIXEL_ID=
|
||||
ARG VITE_CHATWOOT_BASE_URL=
|
||||
ARG VITE_CHATWOOT_WEBSITE_TOKEN=
|
||||
ARG VITE_GOOGLE_ADS_ID=
|
||||
ARG VITE_GOOGLE_ADS_SIGNUP_LABEL=
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
# ── Stage 4: Production ───────────────────────────────
|
||||
FROM node:22-alpine AS production
|
||||
|
||||
# Chromium for Playwright (EMEX/PartsCatalogs)
|
||||
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
|
||||
# Chromium for Playwright (EMEX/PartsCatalogs). ffmpeg is required by the Vinpin
|
||||
# OCR pipeline (vinpin.ocr cropScale): it crops+3x-upscales the tiny centered
|
||||
# Rpartstore launch-error / decode-modal region so tesseract can read it. Without
|
||||
# it cropScale silently returns the full 1600x900 frame and every clipped OCR
|
||||
# degrades to an illegible full-frame 1x read (the DOWN-Rpartstore misclassify bug).
|
||||
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont ffmpeg
|
||||
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
|
||||
|
||||
@@ -23,3 +23,8 @@ NOVU_API_KEY=
|
||||
APP_PUBLIC_URL=https://sase.tr
|
||||
# HMAC secret for signed track.sase.tr click links (Bitwarden "mailtrack tracking (track.sase.tr)"). Empty → no click tracking.
|
||||
MAILTRACK_SECRET=
|
||||
# Unsubscribe links (List-Unsubscribe header + mail footer). Empty secret → mailto-only header, no footer link (dev default).
|
||||
UNSUBSCRIBE_SECRET=
|
||||
# Defaults to <APP_PUBLIC_URL>/api/email/unsubscribe when empty.
|
||||
UNSUBSCRIBE_URL_BASE=
|
||||
UNSUBSCRIBE_EMAIL=unsubscribe@sase.tr
|
||||
|
||||
37
apps/api/drizzle/0015_oem_votes.sql
Normal file
37
apps/api/drizzle/0015_oem_votes.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- OEM community voting + gamification ledger.
|
||||
-- oem_votes: one row per (user, oem_code) uyumlu/uyumsuz verdict cast from the
|
||||
-- catalog part rows. Votes pool globally per code; part/vehicle/category are
|
||||
-- analytics breadcrumbs only (catalog routes pass catalog_vehicles ids → no FK,
|
||||
-- mirrors oem_code_copies). Re-votes update the row in place.
|
||||
-- oem_vote_points: append-only, exactly one row per vote (vote_id unique),
|
||||
-- written in the same transaction. points = 1 (vote) + 2 (agreed with strict
|
||||
-- majority at cast time; first voter always 3). Never retro-adjusted.
|
||||
CREATE TABLE "oem_votes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"oem_code" varchar(100) NOT NULL,
|
||||
"vote" varchar(12) NOT NULL,
|
||||
"part_id" uuid,
|
||||
"vehicle_id" uuid,
|
||||
"category_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oem_vote_points" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"vote_id" uuid NOT NULL,
|
||||
"oem_code" varchar(100) NOT NULL,
|
||||
"points" integer NOT NULL,
|
||||
"correct" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "oem_votes" ADD CONSTRAINT "oem_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_vote_id_oem_votes_id_fk" FOREIGN KEY ("vote_id") REFERENCES "public"."oem_votes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "oem_votes_user_oem_idx" ON "oem_votes" USING btree ("user_id","oem_code");--> statement-breakpoint
|
||||
CREATE INDEX "oem_votes_oem_code_idx" ON "oem_votes" USING btree ("oem_code");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "oem_vote_points_vote_id_idx" ON "oem_vote_points" USING btree ("vote_id");--> statement-breakpoint
|
||||
CREATE INDEX "oem_vote_points_user_id_idx" ON "oem_vote_points" USING btree ("user_id");
|
||||
18
apps/api/drizzle/0016_oem_suggestions.sql
Normal file
18
apps/api/drizzle/0016_oem_suggestions.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Community OEM cross-reference suggestions, collected on the OEM detail page
|
||||
-- ("muadil parça öner": brand + code). Primarily for codes the P snapshot has
|
||||
-- no match for. suggested_code_norm (upper alphanumerics) powers grouping and
|
||||
-- the per-user dedup; status is a moderation hook (active|hidden).
|
||||
CREATE TABLE "oem_suggestions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"oem_code" varchar(100) NOT NULL,
|
||||
"brand" varchar(80) NOT NULL,
|
||||
"suggested_code" varchar(100) NOT NULL,
|
||||
"suggested_code_norm" varchar(100) NOT NULL,
|
||||
"status" varchar(16) DEFAULT 'active' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "oem_suggestions" ADD CONSTRAINT "oem_suggestions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "oem_suggestions_user_oem_code_idx" ON "oem_suggestions" USING btree ("user_id","oem_code","suggested_code_norm");--> statement-breakpoint
|
||||
CREATE INDEX "oem_suggestions_oem_code_idx" ON "oem_suggestions" USING btree ("oem_code");
|
||||
18
apps/api/drizzle/0017_oem_expert_rewards.sql
Normal file
18
apps/api/drizzle/0017_oem_expert_rewards.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Monthly Parça Uzmanları prizes: one row per (TR-month, rank). Written by the
|
||||
-- expert-rewards cron (1st of month 00:00 Europe/Istanbul) when it closes the
|
||||
-- finished season and grants the top 3 voters a subscription extension
|
||||
-- (30/15/7 days). The unique index is the run-once guard — a re-run for the
|
||||
-- same period inserts nothing, so days are never granted twice.
|
||||
CREATE TABLE "oem_expert_rewards" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"period_start" timestamp with time zone NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"rank" integer NOT NULL,
|
||||
"points" integer NOT NULL,
|
||||
"reward_days" integer NOT NULL,
|
||||
"granted_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "oem_expert_rewards" ADD CONSTRAINT "oem_expert_rewards_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "oem_expert_rewards_period_rank_idx" ON "oem_expert_rewards" USING btree ("period_start","rank");--> statement-breakpoint
|
||||
CREATE INDEX "oem_expert_rewards_user_id_idx" ON "oem_expert_rewards" USING btree ("user_id");
|
||||
25
apps/api/drizzle/0018_part_price_history.sql
Normal file
25
apps/api/drizzle/0018_part_price_history.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Tedarikçi fiyat geçmişi (parça kodu bazlı, takip MySQL'den beslenir).
|
||||
-- part_price_tracks: hangi kod izleniyor (ilk seriyi API lazy-backfill yazar,
|
||||
-- günlük cron sadece buradaki kodları tazeler). part_price_daily: (kod, kaynak,
|
||||
-- gün) başına stoktaki tekliflerin p50/p95/p99'u + teklif sayısı. source şimdilik
|
||||
-- hep 'supplier'; perakende verisi geldiğinde aynı tabloya 'retail' olarak girer.
|
||||
-- Tedarikçi kimliği bilinçli olarak HİÇBİR kolonda yok.
|
||||
CREATE TABLE "part_price_tracks" (
|
||||
"code_norm" varchar(64) PRIMARY KEY NOT NULL,
|
||||
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"backfilled_at" timestamp with time zone,
|
||||
"last_refreshed_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "part_price_daily" (
|
||||
"code_norm" varchar(64) NOT NULL,
|
||||
"source" varchar(16) DEFAULT 'supplier' NOT NULL,
|
||||
"date" date NOT NULL,
|
||||
"p50" numeric(15, 4),
|
||||
"p95" numeric(15, 4),
|
||||
"p99" numeric(15, 4),
|
||||
"offer_count" integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT "part_price_daily_code_norm_source_date_pk" PRIMARY KEY("code_norm","source","date")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "part_price_daily_code_date_idx" ON "part_price_daily" USING btree ("code_norm","date");
|
||||
32
apps/api/drizzle/0019_part_price_brand.sql
Normal file
32
apps/api/drizzle/0019_part_price_brand.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- Parça fiyat anahtarına marka eklendi: kısa sayısal kodlar (FEBI 27155 vs
|
||||
-- GROS 27155 vs İBRAŞ 27155) markalar arası çakışıyor ve FARKLI fiziksel
|
||||
-- parçaların fiyatları tek havuzda karışıyordu. Kimlik artık (kod, marka);
|
||||
-- markasız izleme yalnızca uzun/benzersiz kodlar için ('' brand_norm).
|
||||
-- Tablolar 0018'den bu yana yalnızca dev'de üç test kodu içeriyordu — veri
|
||||
-- taşımak yerine drop+create (lazy-backfill ilk görüntülemede yeniden doldurur).
|
||||
DROP TABLE IF EXISTS "part_price_daily";
|
||||
--> statement-breakpoint
|
||||
DROP TABLE IF EXISTS "part_price_tracks";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "part_price_tracks" (
|
||||
"code_norm" varchar(64) NOT NULL,
|
||||
"brand_norm" varchar(64) DEFAULT '' NOT NULL,
|
||||
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"backfilled_at" timestamp with time zone,
|
||||
"last_refreshed_at" timestamp with time zone,
|
||||
CONSTRAINT "part_price_tracks_code_norm_brand_norm_pk" PRIMARY KEY("code_norm","brand_norm")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "part_price_daily" (
|
||||
"code_norm" varchar(64) NOT NULL,
|
||||
"brand_norm" varchar(64) DEFAULT '' NOT NULL,
|
||||
"source" varchar(16) DEFAULT 'supplier' NOT NULL,
|
||||
"date" date NOT NULL,
|
||||
"p50" numeric(15, 4),
|
||||
"p95" numeric(15, 4),
|
||||
"p99" numeric(15, 4),
|
||||
"offer_count" integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT "part_price_daily_code_norm_brand_norm_source_date_pk" PRIMARY KEY("code_norm","brand_norm","source","date")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "part_price_daily_code_date_idx" ON "part_price_daily" USING btree ("code_norm","brand_norm","date");
|
||||
17
apps/api/drizzle/0020_stripe_recurring.sql
Normal file
17
apps/api/drizzle/0020_stripe_recurring.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Stripe recurring billing (Checkout mode:"subscription") bağlantı kolonları.
|
||||
-- users.stripe_customer_id → sonraki checkout'lar aynı Stripe Customer'da
|
||||
-- toplanır (kayıtlı kart + fatura geçmişi tek kayıtta).
|
||||
-- user_subscriptions.stripe_subscription_id → yenileme faturaları
|
||||
-- (invoice.paid/payment_failed webhook'ları) bizim abonelik satırına bu
|
||||
-- kolondan çözülür; trial ve eski tek-seferlik satırlarda NULL kalır.
|
||||
-- payments.stripe_invoice_id → invoice.paid webhook retry'larında mükerrer
|
||||
-- gelir kaydını önleyen dedupe anahtarı.
|
||||
ALTER TABLE "users" ADD COLUMN "stripe_customer_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_subscriptions" ADD COLUMN "stripe_subscription_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "payments" ADD COLUMN "stripe_invoice_id" text;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "user_subscriptions_stripe_sub_idx" ON "user_subscriptions" USING btree ("stripe_subscription_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "payments_stripe_invoice_id_idx" ON "payments" USING btree ("stripe_invoice_id");
|
||||
30
apps/api/drizzle/0021_vinpin_decodes.sql
Normal file
30
apps/api/drizzle/0021_vinpin_decodes.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- Vinpin ePER decode-oracle cache (feature-flagged: VINPIN_ENABLED).
|
||||
-- One row per VIN. The vinpin-decode BullMQ job drives the Vinpin ePER web
|
||||
-- catalog for Fiat VINs the normal chain (PL24/pcat/emex) can't identify, then
|
||||
-- matches the decoded model to an EXISTING PL24 catalog_vehicle. Vinpin is ONLY
|
||||
-- a decode oracle — parts are always served from PL24's existing catalog.
|
||||
-- status: 'pending' | 'decoded' | 'not_found' | 'failed'.
|
||||
CREATE TABLE IF NOT EXISTS "vinpin_decodes" (
|
||||
"vin" text PRIMARY KEY NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"brand_name" text,
|
||||
"model" text,
|
||||
"sincom" text,
|
||||
"trim" text,
|
||||
"model_year" text,
|
||||
"engine" text,
|
||||
"catalog_vehicle_id" uuid,
|
||||
"raw" jsonb,
|
||||
"attempts" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
"decoded_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "vinpin_decodes" ADD CONSTRAINT "vinpin_decodes_catalog_vehicle_id_catalog_vehicles_id_fk" FOREIGN KEY ("catalog_vehicle_id") REFERENCES "public"."catalog_vehicles"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "vinpin_decodes_status_idx" ON "vinpin_decodes" USING btree ("status");
|
||||
34
apps/api/drizzle/0022_discount_codes.sql
Normal file
34
apps/api/drizzle/0022_discount_codes.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- Discount codes for checkout promotions. Each row mirrors a Stripe Coupon +
|
||||
-- Promotion Code; Stripe enforces the redemption cap / expiry at checkout while
|
||||
-- this table powers the admin panel's listing + usage tracking and lets us
|
||||
-- preview a code without a per-render Stripe call. Amounts in kuruş (TRY
|
||||
-- smallest unit), matching plans.price* / payments.amount.
|
||||
CREATE TABLE IF NOT EXISTS "discount_codes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"code" varchar(50) NOT NULL,
|
||||
"stripe_coupon_id" text,
|
||||
"stripe_promotion_code_id" text,
|
||||
"discount_type" varchar(12) NOT NULL,
|
||||
"percent_off" integer,
|
||||
"amount_off" integer,
|
||||
"duration" varchar(12) DEFAULT 'once' NOT NULL,
|
||||
"duration_in_months" integer,
|
||||
"max_redemptions" integer,
|
||||
"times_redeemed" integer DEFAULT 0 NOT NULL,
|
||||
"expires_at" timestamp with time zone,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"note" text,
|
||||
"created_by" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "discount_codes" ADD CONSTRAINT "discount_codes_created_by_users_id_fk"
|
||||
FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null;
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "discount_codes_code_idx" ON "discount_codes" USING btree ("code");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "discount_codes_active_idx" ON "discount_codes" USING btree ("is_active");
|
||||
38
apps/api/drizzle/0023_api_keys.sql
Normal file
38
apps/api/drizzle/0023_api_keys.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- Public API anahtarları — better-auth `apiKey` plugin'inin tablosu (model
|
||||
-- "apikey", usePlural → "apikeys"). Kolon seti plugin şemasının birebir
|
||||
-- karşılığı; "key" kolonu secret'ın SHA256 hash'i (düz metin sadece üretim
|
||||
-- anında bir kez gösterilir). "metadata" JSON string (dailyDecodeLimit gibi
|
||||
-- anahtar-bazlı limitler burada taşınır).
|
||||
CREATE TABLE IF NOT EXISTS "apikeys" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text,
|
||||
"start" text,
|
||||
"prefix" text,
|
||||
"key" text NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"refill_interval" integer,
|
||||
"refill_amount" integer,
|
||||
"last_refill_at" timestamp with time zone,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"rate_limit_enabled" boolean DEFAULT true NOT NULL,
|
||||
"rate_limit_time_window" integer,
|
||||
"rate_limit_max" integer,
|
||||
"request_count" integer DEFAULT 0 NOT NULL,
|
||||
"remaining" integer,
|
||||
"last_request" timestamp with time zone,
|
||||
"expires_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"permissions" text,
|
||||
"metadata" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "apikeys" ADD CONSTRAINT "apikeys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "apikeys_key_idx" ON "apikeys" ("key");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "apikeys_user_id_idx" ON "apikeys" ("user_id");
|
||||
70
apps/api/drizzle/0024_canonical_categories.sql
Normal file
70
apps/api/drizzle/0024_canonical_categories.sql
Normal file
@@ -0,0 +1,70 @@
|
||||
-- Unified cross-source parts taxonomy. The raw per-source category trees
|
||||
-- (categories.parent_id chains + pl24/emex/pcat mirrors) are left untouched;
|
||||
-- this adds an orthogonal canonical spine so every manufacturer's OEM microfiche
|
||||
-- can be browsed/searched under one set of headings. See schema/core.ts for the
|
||||
-- design rationale (name-level mapping, not row-level).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "canonical_categories" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"parent_id" uuid,
|
||||
"slug" varchar(100) NOT NULL,
|
||||
"name" varchar(200) NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "canonical_categories_slug_idx" ON "canonical_categories" ("slug");
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "category_canonical_map" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"source" varchar(20) NOT NULL,
|
||||
"normalized_name" varchar(500) NOT NULL,
|
||||
"canonical_category_id" uuid NOT NULL,
|
||||
"method" varchar(20) DEFAULT 'keyword' NOT NULL,
|
||||
"confidence" numeric(4,3),
|
||||
"is_manual" boolean DEFAULT false NOT NULL,
|
||||
"ambiguous" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "category_canonical_map" ADD CONSTRAINT "category_canonical_map_canonical_id_fk"
|
||||
FOREIGN KEY ("canonical_category_id") REFERENCES "public"."canonical_categories"("id") ON DELETE cascade;
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "category_canonical_map_source_name_idx" ON "category_canonical_map" ("source", "normalized_name");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "category_canonical_map_canonical_idx" ON "category_canonical_map" ("canonical_category_id");
|
||||
--> statement-breakpoint
|
||||
|
||||
ALTER TABLE "categories" ADD COLUMN IF NOT EXISTS "canonical_category_id" uuid;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "categories_canonical_category_id_idx" ON "categories" ("canonical_category_id");
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Seed the top-level canonical taxonomy (18 buckets: aloparca's 17 retail tops
|
||||
-- + "İç Döşeme ve Koltuk" which retail sites don't carry but OEM catalogs do —
|
||||
-- ~16% of parts). Idempotent via ON CONFLICT on the unique slug.
|
||||
INSERT INTO "canonical_categories" ("slug", "name", "sort_order") VALUES
|
||||
('motor', 'Motor', 1),
|
||||
('yakit-sistemi', 'Yakıt Sistemi', 2),
|
||||
('atesleme-sistemi', 'Ateşleme Sistemi', 3),
|
||||
('triger-zincir-rulman', 'Triger, Zincir ve Rulman', 4),
|
||||
('sogutma', 'Soğutma ve Isıtma', 5),
|
||||
('filtreler', 'Filtreler', 6),
|
||||
('fren', 'Fren', 7),
|
||||
('debriyaj', 'Debriyaj', 8),
|
||||
('sanziman', 'Şanzıman ve Diferansiyel', 9),
|
||||
('suspansiyon', 'Süspansiyon', 10),
|
||||
('direksiyon', 'Direksiyon', 11),
|
||||
('elektrik', 'Elektrik', 12),
|
||||
('aydinlatma', 'Aydınlatma', 13),
|
||||
('dis-kaporta', 'Dış Kaporta Aksamı', 14),
|
||||
('tampon', 'Tampon ve Aksamı', 15),
|
||||
('panjur-panel', 'Panjur ve Panel', 16),
|
||||
('ic-doseme', 'İç Döşeme ve Koltuk', 17),
|
||||
('yaglar-sivilar', 'Yağlar ve Sıvılar', 18)
|
||||
ON CONFLICT ("slug") DO NOTHING;
|
||||
7
apps/api/drizzle/0025_canonical_wheel_accessory.sql
Normal file
7
apps/api/drizzle/0025_canonical_wheel_accessory.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Two more canonical top buckets the OEM catalogs need but the retail-derived
|
||||
-- seed lacked: "Jant ve Lastik" (pcat "Jantlar, Lastikler" appears on ~every
|
||||
-- vehicle) and "Aksesuar". Idempotent via the unique slug.
|
||||
INSERT INTO "canonical_categories" ("slug", "name", "sort_order") VALUES
|
||||
('jant-lastik', 'Jant ve Lastik', 19),
|
||||
('aksesuar', 'Aksesuar', 20)
|
||||
ON CONFLICT ("slug") DO NOTHING;
|
||||
294
apps/api/drizzle/0026_canonical_subcategories.sql
Normal file
294
apps/api/drizzle/0026_canonical_subcategories.sql
Normal file
@@ -0,0 +1,294 @@
|
||||
-- 0026: second-level canonical sub-categories (otoparcasan-derived)
|
||||
ALTER TABLE "categories" ADD COLUMN IF NOT EXISTS "canonical_subcategory_id" uuid;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "categories_canonical_subcategory_id_idx" ON "categories" ("canonical_subcategory_id");
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "category_canonical_map" ADD COLUMN IF NOT EXISTS "canonical_subcategory_id" uuid;
|
||||
--> statement-breakpoint
|
||||
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-abs', 'ABS Sensörü / Ünitesi', 1 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-balata', 'Balata', 2 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-disk', 'Fren Diski / Ayna', 3 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-kaliper', 'Kaliper', 4 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-kampana', 'Kampana', 5 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-merkez', 'Ana Merkez / Silindir', 6 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-guclendirici', 'Fren Güçlendirici (Servo)', 7 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-hidrolik', 'Hortum / Boru / Hidrolik', 8 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-elfren', 'El Freni', 9 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-musur', 'Fren Müşürü / İkaz', 10 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-vakum', 'Vakum Pompası', 11 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-pedal', 'Fren Pedalı', 12 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-donanim', 'Bağlantı ve Donanım', 13 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-balata', 'Debriyaj Balatası', 1 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-baski', 'Baskı', 2 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-set', 'Debriyaj Seti', 3 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-rulman', 'Debriyaj Rulmanı', 4 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-merkez', 'Debriyaj Merkezi', 5 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-volan', 'Volan', 6 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-catal', 'Çatal / Bilya', 7 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-tel', 'Debriyaj Teli / Pedalı', 8 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-donanim', 'Bağlantı ve Donanım', 9 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-silindirkapak', 'Silindir Kapağı', 1 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-piston', 'Piston / Segman / Gömlek', 2 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-krank', 'Krank / Biyel', 3 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-supap', 'Supap / Külbütör', 4 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-conta', 'Conta', 5 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-kece', 'Keçe', 6 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-karter', 'Karter', 7 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-yagpompa', 'Yağ Pompası', 8 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-takoz', 'Motor Takozu', 9 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-turbo', 'Turbo', 10 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-manifold', 'Manifold', 11 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-komple', 'Motor Komple / Blok', 12 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-donanim', 'Bağlantı ve Donanım', 13 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-enjektor', 'Enjektör', 1 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-pompa', 'Yakıt Pompası', 2 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-kelebek', 'Gaz Kelebeği', 3 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-depo', 'Yakıt Deposu / Şamandıra', 4 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-boru', 'Yakıt Borusu', 5 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-egr', 'EGR', 6 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-karburator', 'Karbüratör', 7 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-egzoz', 'Egzoz', 8 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-sistemi-donanim', 'Bağlantı ve Donanım', 9 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-buji', 'Buji', 1 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-bobin', 'Ateşleme Bobini', 2 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-kablo', 'Buji Kablosu', 3 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-distributor', 'Distribütör', 4 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-kayis', 'Triger Kayışı / Seti', 1 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-zincir', 'Triger Zinciri', 2 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-gergi', 'Gergi / Rulman', 3 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-radyator', 'Su Radyatörü', 1 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-termostat', 'Termostat', 2 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-supompa', 'Devirdaim / Su Pompası', 3 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-klima', 'Klima (Kompresör/Radyatör)', 4 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-kalorifer', 'Kalorifer', 5 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-fan', 'Fan / Pervane', 6 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-hortum', 'Su Hortumu', 7 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-musur', 'Hararet / Fan Müşürü', 8 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-intercooler', 'Intercooler / Turbo Radyatör', 9 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-donanim', 'Bağlantı ve Donanım', 10 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-yag', 'Yağ Filtresi', 1 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-hava', 'Hava Filtresi', 2 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-yakit', 'Yakıt Filtresi', 3 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-polen', 'Polen / Kabin Filtresi', 4 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-diger', 'Diğer Filtre', 5 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-amortisor', 'Amortisör', 1 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-salincak', 'Salıncak', 2 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rotil', 'Rotil', 3 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rot', 'Rot / Rot Başı', 4 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-viraj', 'Viraj Demiri / Z Rot', 5 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-helezon', 'Helezon Yay', 6 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rulman', 'Aks / Porya Rulmanı', 7 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-aks', 'Aks / Aktarma', 8 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-burc', 'Burç / Takoz', 9 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-bijon', 'Bijon', 10 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'suspansiyon-donanim', 'Bağlantı ve Donanım', 11 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-pompa', 'Direksiyon Pompası', 1 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-kutu', 'Direksiyon Kutusu / Kremayer', 2 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-airbag', 'Airbag', 3 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-koruk', 'Direksiyon Körüğü', 4 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-mil', 'Direksiyon Mili / Kolu', 5 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-vites', 'Vites Kolu / Mekanizması', 1 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-takoz', 'Şanzıman Takozu', 2 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-saft', 'Şaft / Mafsal', 3 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-diferansiyel', 'Diferansiyel', 4 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-sensor', 'Hız / Km Sensörü', 5 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-karter', 'Şanzıman Karteri / Yağ', 6 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-donanim', 'Bağlantı ve Donanım', 7 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-aku', 'Akü', 1 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-alternator', 'Alternatör', 2 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-mars', 'Marş Motoru', 3 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-sensor', 'Sensör', 4 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-sigorta', 'Sigorta / Röle', 5 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-motor', 'Cam / Silecek Motoru', 6 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-kontak', 'Kontak / Marş Anahtarı', 7 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-modul', 'Modül / Beyin / ECU', 8 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-kablo', 'Kablo / Tesisat', 9 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-donanim', 'Bağlantı ve Donanım', 10 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-far', 'Far', 1 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-stop', 'Stop Lambası', 2 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-sinyal', 'Sinyal', 3 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-sis', 'Sis Farı', 4 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-plaka', 'Plaka Lambası', 5 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-ic', 'İç / Tavan Lambası', 6 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-ampul', 'Ampul', 7 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kapi', 'Kapı / Menteşe / Kilit', 1 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-ayna', 'Dikiz Aynası', 2 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-cam', 'Cam / Kriko / Fitil', 3 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-bagaj', 'Bagaj Kapağı', 4 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kaput', 'Motor Kaputu', 5 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kol', 'Kapı Kolu', 6 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-camurluk', 'Çamurluk', 1 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-panjur', 'Panjur / Ön Panel', 2 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-marspiyel', 'Marşpiyel', 3 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-farcerceve', 'Far Çerçevesi / Sacı', 4 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-travers', 'Travers / Takviye', 5 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-etek', 'Etek Sacı / Muhafaza', 6 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-tampon', 'Tampon', 1 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-demir', 'Tampon Demiri / Travers', 2 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-izgara', 'Izgara / Panjur', 3 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-braket', 'Braket / Sac', 4 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-cita', 'Çıta / Kaplama', 5 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-koltuk', 'Koltuk / Kılıf', 1 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-torpido', 'Torpido / Konsol', 2 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-tavan', 'Tavan Döşeme', 3 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-kemer', 'Emniyet Kemeri', 4 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-kolcak', 'Kolçak / Bardaklık', 5 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-paspas', 'Paspas / Halı', 6 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-motor', 'Motor Yağı', 1 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-sanziman', 'Şanzıman / Dif. Yağı', 2 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-fren', 'Fren Hidroliği', 3 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-direksiyon', 'Direksiyon Yağı', 4 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-antifriz', 'Antifriz', 5 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-gres', 'Gres', 6 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-adblue', 'AdBlue', 7 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-katki', 'Katkı / Sprey', 8 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-jant', 'Jant', 1 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-tire', 'Lastik', 2 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-kapak', 'Jant Kapağı / Bijon', 3 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-sibop', 'Sibop', 4 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-rulman', 'Teker Rulmanı / Göbek', 5 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-zincir', 'Kar Zinciri', 6 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
301
apps/api/drizzle/0027_canonical_subcategories_v2.sql
Normal file
301
apps/api/drizzle/0027_canonical_subcategories_v2.sql
Normal file
@@ -0,0 +1,301 @@
|
||||
-- 0027: additional sub-leaves for weak buckets (yaglar/panjur/direksiyon)
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-abs', 'ABS Sensörü / Ünitesi', 1 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-balata', 'Balata', 2 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-disk', 'Fren Diski / Ayna', 3 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-kaliper', 'Kaliper', 4 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-kampana', 'Kampana', 5 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-merkez', 'Ana Merkez / Silindir', 6 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-guclendirici', 'Fren Güçlendirici (Servo)', 7 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-hidrolik', 'Hortum / Boru / Hidrolik', 8 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-elfren', 'El Freni', 9 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-musur', 'Fren Müşürü / İkaz', 10 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-vakum', 'Vakum Pompası', 11 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-pedal', 'Fren Pedalı', 12 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-donanim', 'Bağlantı ve Donanım', 13 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-balata', 'Debriyaj Balatası', 1 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-baski', 'Baskı', 2 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-set', 'Debriyaj Seti', 3 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-rulman', 'Debriyaj Rulmanı', 4 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-merkez', 'Debriyaj Merkezi / Hidrolik', 5 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-volan', 'Volan', 6 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-catal', 'Çatal / Bilya', 7 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-tel', 'Debriyaj Teli / Pedalı', 8 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-donanim', 'Bağlantı ve Donanım', 9 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-silindirkapak', 'Silindir Kapağı', 1 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-piston', 'Piston / Segman / Gömlek', 2 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-krank', 'Krank / Biyel', 3 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-supap', 'Supap / Külbütör', 4 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-conta', 'Conta', 5 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-kece', 'Keçe', 6 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-karter', 'Karter', 7 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-yagpompa', 'Yağ Pompası', 8 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-takoz', 'Motor Takozu', 9 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-turbo', 'Turbo', 10 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-manifold', 'Manifold', 11 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-komple', 'Motor Komple / Blok', 12 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-donanim', 'Bağlantı ve Donanım', 13 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-enjektor', 'Enjektör', 1 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-pompa', 'Yakıt Pompası', 2 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-kelebek', 'Gaz Kelebeği', 3 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-depo', 'Yakıt Deposu / Şamandıra', 4 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-boru', 'Yakıt Borusu', 5 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-egr', 'EGR', 6 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-karburator', 'Karbüratör', 7 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-egzoz', 'Egzoz', 8 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-sistemi-donanim', 'Bağlantı ve Donanım', 9 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-buji', 'Buji', 1 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-bobin', 'Ateşleme Bobini', 2 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-kablo', 'Buji Kablosu', 3 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-distributor', 'Distribütör', 4 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-kayis', 'Triger Kayışı / Seti', 1 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-zincir', 'Triger Zinciri', 2 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-gergi', 'Gergi / Rulman', 3 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-radyator', 'Su Radyatörü', 1 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-termostat', 'Termostat', 2 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-supompa', 'Devirdaim / Su Pompası', 3 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-klima', 'Klima (Kompresör/Radyatör)', 4 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-kalorifer', 'Kalorifer', 5 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-fan', 'Fan / Pervane', 6 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-hortum', 'Su Hortumu', 7 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-musur', 'Hararet / Fan Müşürü', 8 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-intercooler', 'Intercooler / Turbo Radyatör', 9 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-donanim', 'Bağlantı ve Donanım', 10 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-yag', 'Yağ Filtresi', 1 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-hava', 'Hava Filtresi', 2 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-yakit', 'Yakıt Filtresi', 3 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-polen', 'Polen / Kabin Filtresi', 4 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-diger', 'Diğer Filtre', 5 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-amortisor', 'Amortisör', 1 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-salincak', 'Salıncak', 2 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rotil', 'Rotil', 3 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rot', 'Rot / Rot Başı', 4 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-viraj', 'Viraj Demiri / Z Rot', 5 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-helezon', 'Helezon Yay', 6 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rulman', 'Aks / Porya Rulmanı', 7 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-aks', 'Aks / Aktarma', 8 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-burc', 'Burç / Takoz', 9 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-bijon', 'Bijon', 10 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'suspansiyon-donanim', 'Bağlantı ve Donanım', 11 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-simit', 'Direksiyon Simidi', 1 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-pompa', 'Direksiyon Pompası / Hidrolik', 2 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-kutu', 'Direksiyon Kutusu / Dişli / Kremayer', 3 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-airbag', 'Airbag', 4 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-koruk', 'Direksiyon Körüğü', 5 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-elektronik', 'Kumanda / Sensör', 6 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-mil', 'Direksiyon Mili / Boğaz / Göbek', 7 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-vites', 'Vites Kolu / Mekanizması', 1 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-takoz', 'Şanzıman Takozu', 2 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-saft', 'Şaft / Mafsal', 3 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-diferansiyel', 'Diferansiyel', 4 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-sensor', 'Hız / Km Sensörü', 5 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-karter', 'Şanzıman Karteri / Yağ', 6 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-donanim', 'Bağlantı ve Donanım', 7 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-aku', 'Akü', 1 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-alternator', 'Alternatör', 2 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-mars', 'Marş Motoru', 3 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-sensor', 'Sensör', 4 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-sigorta', 'Sigorta / Röle', 5 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-motor', 'Cam / Silecek Motoru', 6 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-kontak', 'Kontak / Marş Anahtarı', 7 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-modul', 'Modül / Beyin / ECU', 8 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-kablo', 'Kablo / Tesisat', 9 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-donanim', 'Bağlantı ve Donanım', 10 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-far', 'Far', 1 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-stop', 'Stop Lambası', 2 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-sinyal', 'Sinyal', 3 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-sis', 'Sis Farı', 4 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-plaka', 'Plaka Lambası', 5 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-ic', 'İç / Tavan Lambası', 6 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-ampul', 'Ampul', 7 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kapi', 'Kapı / Menteşe / Kilit', 1 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-ayna', 'Dikiz Aynası', 2 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-cam', 'Cam / Kriko / Fitil', 3 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-bagaj', 'Bagaj Kapağı', 4 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kaput', 'Motor Kaputu', 5 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kol', 'Kapı Kolu', 6 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-camurluk', 'Çamurluk / Kanat', 1 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-kaput', 'Kaput / Kaporta', 2 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-tavan', 'Tavan', 3 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-sasi', 'Şasi / Alt Panel', 4 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-cam', 'Cam / Karo', 5 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-panjur', 'Panjur / Ön Panel', 6 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-marspiyel', 'Marşpiyel', 7 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-farcerceve', 'Far Çerçevesi / Sacı', 8 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-travers', 'Travers / Takviye', 9 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-etek', 'Etek Sacı / Muhafaza', 10 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-tampon', 'Tampon', 1 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-demir', 'Tampon Demiri / Travers', 2 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-izgara', 'Izgara / Panjur', 3 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-braket', 'Braket / Sac', 4 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-cita', 'Çıta / Kaplama', 5 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-koltuk', 'Koltuk / Kılıf', 1 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-torpido', 'Torpido / Konsol', 2 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-tavan', 'Tavan Döşeme', 3 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-kemer', 'Emniyet Kemeri', 4 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-kolcak', 'Kolçak / Bardaklık', 5 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-paspas', 'Paspas / Halı', 6 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-motor', 'Motor Yağı', 1 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-sanziman', 'Şanzıman / Dif. Yağı', 2 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-fren', 'Fren Hidroliği', 3 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-direksiyon', 'Direksiyon Yağı', 4 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-antifriz', 'Antifriz', 5 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-gres', 'Gres', 6 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-adblue', 'AdBlue', 7 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-katki', 'Katkı / Sprey', 8 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-sistemi', 'Yağlama Sistemi (Pompa/Çubuk/Boru)', 9 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-jant', 'Jant', 1 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-tire', 'Lastik', 2 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-kapak', 'Jant Kapağı / Bijon', 3 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-sibop', 'Sibop', 4 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-rulman', 'Teker Rulmanı / Göbek', 5 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-zincir', 'Kar Zinciri', 6 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
305
apps/api/drizzle/0028_canonical_subcat_v3.sql
Normal file
305
apps/api/drizzle/0028_canonical_subcat_v3.sql
Normal file
@@ -0,0 +1,305 @@
|
||||
-- 0028: sub-leaves for queue-shrink additions (silecek, korna + kw)
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-abs', 'ABS Sensörü / Ünitesi', 1 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-balata', 'Balata', 2 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-disk', 'Fren Diski / Ayna', 3 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-kaliper', 'Kaliper', 4 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-kampana', 'Kampana', 5 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-merkez', 'Ana Merkez / Silindir', 6 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-guclendirici', 'Fren Güçlendirici (Servo)', 7 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-hidrolik', 'Hortum / Boru / Hidrolik', 8 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-elfren', 'El Freni', 9 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-musur', 'Fren Müşürü / İkaz', 10 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-vakum', 'Vakum Pompası', 11 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-pedal', 'Fren Pedalı', 12 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'fren-donanim', 'Bağlantı ve Donanım', 13 FROM "canonical_categories" WHERE slug='fren' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-balata', 'Debriyaj Balatası', 1 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-baski', 'Baskı', 2 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-set', 'Debriyaj Seti', 3 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-rulman', 'Debriyaj Rulmanı', 4 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-merkez', 'Debriyaj Merkezi / Hidrolik', 5 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-volan', 'Volan', 6 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-catal', 'Çatal / Bilya', 7 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-tel', 'Debriyaj Teli / Pedalı', 8 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'debriyaj-donanim', 'Bağlantı ve Donanım', 9 FROM "canonical_categories" WHERE slug='debriyaj' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-silindirkapak', 'Silindir Kapağı', 1 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-piston', 'Piston / Segman / Gömlek', 2 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-krank', 'Krank / Biyel', 3 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-supap', 'Supap / Külbütör', 4 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-conta', 'Conta', 5 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-kece', 'Keçe', 6 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-karter', 'Karter', 7 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-yagpompa', 'Yağ Pompası', 8 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-takoz', 'Motor Takozu', 9 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-turbo', 'Turbo', 10 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-manifold', 'Manifold', 11 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-komple', 'Motor Komple / Blok / Takım', 12 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'motor-donanim', 'Bağlantı ve Donanım', 13 FROM "canonical_categories" WHERE slug='motor' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-enjektor', 'Enjektör', 1 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-pompa', 'Yakıt Pompası', 2 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-kelebek', 'Gaz Kelebeği', 3 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-depo', 'Yakıt Deposu / Şamandıra', 4 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-boru', 'Yakıt Borusu', 5 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-egr', 'EGR', 6 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-karburator', 'Karbüratör', 7 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-egzoz', 'Egzoz / Lambda', 8 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yakit-sistemi-donanim', 'Bağlantı ve Donanım', 9 FROM "canonical_categories" WHERE slug='yakit-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-buji', 'Buji', 1 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-bobin', 'Ateşleme Bobini', 2 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-kablo', 'Buji Kablosu', 3 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'atesleme-distributor', 'Distribütör', 4 FROM "canonical_categories" WHERE slug='atesleme-sistemi' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-kayis', 'Triger Kayışı / Seti', 1 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-zincir', 'Triger Zinciri', 2 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'triger-gergi', 'Gergi / Rulman', 3 FROM "canonical_categories" WHERE slug='triger-zincir-rulman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-radyator', 'Radyatör / Soğutucu', 1 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-termostat', 'Termostat', 2 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-supompa', 'Devirdaim / Su Pompası', 3 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-klima', 'Klima (Kompresör/Radyatör)', 4 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-kalorifer', 'Kalorifer / Havalandırma / Isıtma', 5 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-fan', 'Fan / Pervane', 6 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-hortum', 'Su Hortumu', 7 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-musur', 'Hararet / Fan Müşürü', 8 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-intercooler', 'Intercooler / Turbo Radyatör', 9 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sogutma-donanim', 'Bağlantı ve Donanım', 10 FROM "canonical_categories" WHERE slug='sogutma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-yag', 'Yağ Filtresi', 1 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-hava', 'Hava Filtresi', 2 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-yakit', 'Yakıt Filtresi', 3 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-polen', 'Polen / Kabin Filtresi', 4 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'filtre-diger', 'Diğer Filtre', 5 FROM "canonical_categories" WHERE slug='filtreler' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-amortisor', 'Amortisör', 1 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-salincak', 'Salıncak', 2 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rotil', 'Rotil', 3 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rot', 'Rot / Rot Başı', 4 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-viraj', 'Viraj Demiri / Z Rot / Burkma', 5 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-helezon', 'Helezon Yay', 6 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-rulman', 'Aks / Porya Rulmanı', 7 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-aks', 'Aks / Aktarma', 8 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-burc', 'Burç / Takoz / Pim', 9 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'susp-bijon', 'Bijon', 10 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'suspansiyon-donanim', 'Bağlantı ve Donanım', 11 FROM "canonical_categories" WHERE slug='suspansiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-simit', 'Direksiyon Simidi', 1 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-pompa', 'Direksiyon Pompası / Hidrolik', 2 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-kutu', 'Direksiyon Kutusu / Dişli / Kremayer', 3 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-airbag', 'Airbag', 4 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-koruk', 'Direksiyon Körüğü', 5 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-elektronik', 'Kumanda / Sensör', 6 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'direksiyon-mil', 'Direksiyon Mili / Boğaz / Göbek', 7 FROM "canonical_categories" WHERE slug='direksiyon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-vites', 'Vites / İç Kontroller', 1 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-takoz', 'Şanzıman Takozu', 2 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-saft', 'Şaft / Mafsal', 3 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-diferansiyel', 'Diferansiyel', 4 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-sensor', 'Hız / Km Sensörü', 5 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-karter', 'Şanzıman Karteri / Yağ', 6 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'sanziman-donanim', 'Bağlantı ve Donanım', 7 FROM "canonical_categories" WHERE slug='sanziman' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-aku', 'Akü', 1 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-alternator', 'Alternatör', 2 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-mars', 'Marş Motoru', 3 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-sensor', 'Sensör', 4 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-sigorta', 'Sigorta / Röle', 5 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-motor', 'Cam / Silecek Motoru', 6 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-silecek', 'Silecek', 7 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-korna', 'Korna', 8 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-kontak', 'Kontak / Marş Anahtarı', 9 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-modul', 'Modül / Gösterge / Elektronik', 10 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-kablo', 'Kablo / Tesisat', 11 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'elektrik-donanim', 'Bağlantı ve Donanım', 12 FROM "canonical_categories" WHERE slug='elektrik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-far', 'Far', 1 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-stop', 'Stop Lambası', 2 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-sinyal', 'Sinyal', 3 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-sis', 'Sis Farı', 4 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-plaka', 'Plaka Lambası', 5 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-ic', 'İç / Tavan Lambası', 6 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ayd-ampul', 'Ampul', 7 FROM "canonical_categories" WHERE slug='aydinlatma' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kapi', 'Kapı / Menteşe / Kilit', 1 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-ayna', 'Dikiz Aynası', 2 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-cam', 'Cam / Kriko / Fitil', 3 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-bagaj', 'Bagaj Kapağı', 4 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kaput', 'Motor Kaputu', 5 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'disk-kol', 'Kapı Kolu', 6 FROM "canonical_categories" WHERE slug='dis-kaporta' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-camurluk', 'Çamurluk / Kanat', 1 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-kaput', 'Kaput / Kaporta', 2 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-tavan', 'Tavan', 3 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-sasi', 'Şasi / Kiriş / Alt Panel', 4 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-cam', 'Cam / Karo', 5 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-panjur', 'Panjur / Ön Panel', 6 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-marspiyel', 'Marşpiyel', 7 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-farcerceve', 'Far Çerçevesi / Sacı', 8 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-travers', 'Travers / Takviye', 9 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'panjur-etek', 'Etek Sacı / Muhafaza', 10 FROM "canonical_categories" WHERE slug='panjur-panel' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-tampon', 'Tampon', 1 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-demir', 'Tampon Demiri / Travers', 2 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-izgara', 'Izgara / Panjur', 3 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-braket', 'Braket / Sac', 4 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'tampon-cita', 'Çıta / Kaplama', 5 FROM "canonical_categories" WHERE slug='tampon' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-koltuk', 'Koltuk / Kılıf', 1 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-torpido', 'Torpido / Konsol', 2 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-tavan', 'Tavan Döşeme / Güneşlik', 3 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-kemer', 'Emniyet Kemeri', 4 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-kolcak', 'Kolçak / Bardaklık', 5 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'ic-paspas', 'Paspas / Halı', 6 FROM "canonical_categories" WHERE slug='ic-doseme' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-motor', 'Motor Yağı', 1 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-sanziman', 'Şanzıman / Dif. Yağı', 2 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-fren', 'Fren Hidroliği', 3 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-direksiyon', 'Direksiyon Yağı', 4 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-antifriz', 'Antifriz', 5 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-gres', 'Gres', 6 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-adblue', 'AdBlue', 7 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-katki', 'Katkı / Sprey', 8 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'yag-sistemi', 'Yağlama Sistemi (Pompa/Tava/Keçe)', 9 FROM "canonical_categories" WHERE slug='yaglar-sivilar' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-jant', 'Jant', 1 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-tire', 'Lastik', 2 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-kapak', 'Jant Kapağı / Bijon', 3 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-sibop', 'Sibop', 4 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-rulman', 'Teker Rulmanı / Göbek', 5 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "canonical_categories" ("parent_id","slug","name","sort_order") SELECT id, 'jant-zincir', 'Kar Zinciri', 6 FROM "canonical_categories" WHERE slug='jant-lastik' AND parent_id IS NULL ON CONFLICT ("slug") DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
11
apps/api/drizzle/0029_canonical_structure_template.sql
Normal file
11
apps/api/drizzle/0029_canonical_structure_template.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS "canonical_structure_template" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"source" varchar(20) NOT NULL,
|
||||
"brand" varchar(120) NOT NULL,
|
||||
"canonical_category_id" uuid NOT NULL,
|
||||
"canonical_subcategory_id" uuid,
|
||||
"vehicle_count" integer DEFAULT 0 NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "canonical_structure_template_source_brand_idx" ON "canonical_structure_template" ("source","brand");
|
||||
91
apps/api/drizzle/0030_categories_dedup_unique.sql
Normal file
91
apps/api/drizzle/0030_categories_dedup_unique.sql
Normal file
@@ -0,0 +1,91 @@
|
||||
-- 0030: merge duplicate categories, then enforce uniqueness with NULLS NOT DISTINCT.
|
||||
--
|
||||
-- Root cause: categories_vehicle_name_source_idx (vehicle_id, catalog_vehicle_id,
|
||||
-- name, source) treats NULLs as distinct, and emex/pcat rows have NULL
|
||||
-- catalog_vehicle_id (pl24 rows have NULL vehicle_id) — so decode retries slipped
|
||||
-- past every onConflictDoNothing and re-inserted the same category (+3k/day).
|
||||
--
|
||||
-- This migration (idempotent):
|
||||
-- 1. Iteratively merges duplicate groups (same vehicle_id, catalog_vehicle_id,
|
||||
-- name, source — NULLs equal): winner = most children, then most parts,
|
||||
-- then oldest. Children are reparented (which can create next-level dupes —
|
||||
-- hence the loop), parts are repointed with parts_dedup_idx-safe staging,
|
||||
-- schema_pics / oem_code_copies / oem_votes are repointed, losers deleted.
|
||||
-- 2. Swaps the unique index to NULLS NOT DISTINCT (PG15+; prod is PG17), which
|
||||
-- makes all existing onConflictDoNothing inserts actually dedupe.
|
||||
DO $$
|
||||
DECLARE
|
||||
merged integer;
|
||||
pass integer := 0;
|
||||
BEGIN
|
||||
LOOP
|
||||
pass := pass + 1;
|
||||
|
||||
CREATE TEMP TABLE dedup_map ON COMMIT DROP AS
|
||||
WITH r AS (
|
||||
SELECT id,
|
||||
first_value(id) OVER w AS winner,
|
||||
row_number() OVER w AS rn
|
||||
FROM categories c
|
||||
WINDOW w AS (
|
||||
PARTITION BY vehicle_id, catalog_vehicle_id, name, source
|
||||
ORDER BY (EXISTS(SELECT 1 FROM categories ch WHERE ch.parent_id = c.id)) DESC,
|
||||
(SELECT count(*) FROM parts p WHERE p.category_id = c.id) DESC,
|
||||
created_at, id
|
||||
)
|
||||
)
|
||||
SELECT id AS loser, winner FROM r WHERE rn > 1;
|
||||
|
||||
SELECT count(*) INTO merged FROM dedup_map;
|
||||
IF merged = 0 THEN
|
||||
DROP TABLE dedup_map;
|
||||
EXIT;
|
||||
END IF;
|
||||
RAISE NOTICE 'dedup pass %: merging % duplicate categories', pass, merged;
|
||||
|
||||
-- reparent children of losers onto the winner (may create next-level dupes)
|
||||
UPDATE categories SET parent_id = m.winner FROM dedup_map m
|
||||
WHERE categories.parent_id = m.loser;
|
||||
|
||||
-- repoint parts: stage with row_number over the TARGET dedup key so no two
|
||||
-- moved rows collide with each other; skip ones colliding with the winner's
|
||||
-- existing parts; delete the remainder (exact duplicates).
|
||||
CREATE TEMP TABLE dedup_mv ON COMMIT DROP AS
|
||||
SELECT p.id, m.winner,
|
||||
row_number() OVER (
|
||||
PARTITION BY p.vehicle_id, m.winner, p.oem_code, p.name, p."position"
|
||||
ORDER BY p.id
|
||||
) AS rn
|
||||
FROM parts p JOIN dedup_map m ON m.loser = p.category_id;
|
||||
|
||||
UPDATE parts SET category_id = mv.winner FROM dedup_mv mv
|
||||
WHERE parts.id = mv.id AND mv.rn = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM parts w
|
||||
WHERE w.category_id = mv.winner
|
||||
AND w.vehicle_id IS NOT DISTINCT FROM parts.vehicle_id
|
||||
AND w.oem_code IS NOT DISTINCT FROM parts.oem_code
|
||||
AND w.name IS NOT DISTINCT FROM parts.name
|
||||
AND w."position" IS NOT DISTINCT FROM parts."position"
|
||||
);
|
||||
DELETE FROM parts USING dedup_map m WHERE parts.category_id = m.loser;
|
||||
|
||||
UPDATE schema_pics SET category_id = m.winner FROM dedup_map m
|
||||
WHERE schema_pics.category_id = m.loser;
|
||||
UPDATE oem_code_copies SET category_id = m.winner FROM dedup_map m
|
||||
WHERE oem_code_copies.category_id = m.loser;
|
||||
UPDATE oem_votes SET category_id = m.winner FROM dedup_map m
|
||||
WHERE oem_votes.category_id = m.loser;
|
||||
|
||||
DELETE FROM categories USING dedup_map m WHERE categories.id = m.loser;
|
||||
|
||||
DROP TABLE dedup_map;
|
||||
DROP TABLE dedup_mv;
|
||||
END LOOP;
|
||||
RAISE NOTICE 'dedup complete after % pass(es)', pass;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "categories_vehicle_name_source_idx";
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "categories_vehicle_name_source_idx"
|
||||
ON "categories" ("vehicle_id", "catalog_vehicle_id", "name", "source") NULLS NOT DISTINCT;
|
||||
17
apps/api/drizzle/0031_canonical_template_entries.sql
Normal file
17
apps/api/drizzle/0031_canonical_template_entries.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- 0031: brand-template ENTRY names — the browsable source-category names every
|
||||
-- vehicle of (source, brand) shares. Lets a freshly-decoded vehicle show the
|
||||
-- FULL canonical structure (named entries under each bucket) before its own
|
||||
-- deep drill; only part counts/schemas are vehicle-specific. Rebuilt by the
|
||||
-- global canonical-backfill run alongside canonical_structure_template.
|
||||
CREATE TABLE IF NOT EXISTS "canonical_template_entries" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"source" varchar(20) NOT NULL,
|
||||
"brand" varchar(120) NOT NULL,
|
||||
"canonical_category_id" uuid NOT NULL,
|
||||
"canonical_subcategory_id" uuid,
|
||||
"name" varchar(500) NOT NULL,
|
||||
"vehicle_count" integer DEFAULT 0 NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "canonical_template_entries_sb_idx" ON "canonical_template_entries" ("source","brand");
|
||||
8
apps/api/drizzle/0032_part_price_oe_reset.sql
Normal file
8
apps/api/drizzle/0032_part_price_oe_reset.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Fiyat görünümleri artık markasız/araç-markalı bağlamlarda yalnız ORİJİNAL
|
||||
-- (kokpit tur='orjinal' etiketli) teklifleri içerir. Mevcut satırlar eski
|
||||
-- kuralla (orijinal + yan sanayi tek havuz) hesaplandığından tutarsız — sil;
|
||||
-- seriler ilk görüntülenmede takip history'sinden OE-only kurallarla yeniden
|
||||
-- backfill edilir (product_history 2026-01-29 epoch'undan beri eksiksiz),
|
||||
-- cron sonraki günleri aynı kuralla ekler. (~166 track / ~4,8k satır — ucuz.)
|
||||
DELETE FROM "part_price_daily";--> statement-breakpoint
|
||||
DELETE FROM "part_price_tracks";
|
||||
9
apps/api/drizzle/0033_parts_oem_code_norm_idx.sql
Normal file
9
apps/api/drizzle/0033_parts_oem_code_norm_idx.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- OEM kodu normalize-eşleşme index'i. Reverse-catalog (vehiclesByOem) ve
|
||||
-- fiyat-kartı marka çözümü artık normalize edilmiş koda göre eşleşir: URL
|
||||
-- kodu boşluksuz ("9827622780") gelirken katalog boşluklu saklayabilir
|
||||
-- ("9827 622 780"; özellikle PSA/Peugeot/Citroen) → exact match kaçırıyordu.
|
||||
-- Functional btree, sorgudaki ifadeyle birebir aynı olmalı ki planner kullansın.
|
||||
-- Not: non-concurrent build kısa süreli YAZMA kilidi alır (prod ~10s, salt-okuma
|
||||
-- etkilenmez); parts ~9,3M satır. IF NOT EXISTS → idempotent/yeniden koşulur.
|
||||
CREATE INDEX IF NOT EXISTS "parts_oem_code_norm_idx"
|
||||
ON "parts" (regexp_replace(upper("oem_code"), '[^A-Z0-9]', '', 'g'));
|
||||
9
apps/api/drizzle/0034_vehicle_fully_fetched.sql
Normal file
9
apps/api/drizzle/0034_vehicle_fully_fetched.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Kalıcı tamlık işareti: prefetch zinciri parça ile bittiğinde true olur
|
||||
-- (PrefetchWorkerService.incrementCompleted). Redis prefetch:complete:* (21g TTL,
|
||||
-- operasyonel skip mantığı) yerine SÜRESİZ ölçüm — "% tam çekilmiş" güvenilir
|
||||
-- takibi. _at son doğrulanma zamanını tutar. IF NOT EXISTS → idempotent.
|
||||
ALTER TABLE "vehicles" ADD COLUMN IF NOT EXISTS "fully_fetched" boolean DEFAULT false NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "vehicles" ADD COLUMN IF NOT EXISTS "fully_fetched_at" timestamp with time zone;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "vehicles_fully_fetched_idx" ON "vehicles" USING btree ("fully_fetched");
|
||||
9
apps/api/drizzle/0035_subscription_dunning.sql
Normal file
9
apps/api/drizzle/0035_subscription_dunning.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Dunning görünürlüğü: yenileme tahsilatı patlayınca (invoice.payment_failed)
|
||||
-- damgalanır, fatura ödenince (invoice.paid) veya abonelik kesin iptal olunca
|
||||
-- temizlenir. dunning_invoice_url = Stripe hosted fatura sayfası — kullanıcının
|
||||
-- açık faturayı ödeyebileceği/yeni kart girebileceği TEK self-serve yüzey
|
||||
-- (uygulamada kart-güncelleme yok; %0 dunning kurtarmanın kök nedeni buydu).
|
||||
-- IF NOT EXISTS → idempotent.
|
||||
ALTER TABLE "user_subscriptions" ADD COLUMN IF NOT EXISTS "dunning_since" timestamp with time zone;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_subscriptions" ADD COLUMN IF NOT EXISTS "dunning_invoice_url" text;
|
||||
@@ -106,6 +106,153 @@
|
||||
"when": 1781136000000,
|
||||
"tag": "0014_proxy_logs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1781222400000,
|
||||
"tag": "0015_oem_votes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1781308800000,
|
||||
"tag": "0016_oem_suggestions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1781395200000,
|
||||
"tag": "0017_oem_expert_rewards",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1781481600000,
|
||||
"tag": "0018_part_price_history",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1781485200000,
|
||||
"tag": "0019_part_price_brand",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1781268490495,
|
||||
"tag": "0020_stripe_recurring",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1781568000000,
|
||||
"tag": "0021_vinpin_decodes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1782652379976,
|
||||
"tag": "0022_discount_codes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1783084194145,
|
||||
"tag": "0023_api_keys",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "7",
|
||||
"when": 1783085194145,
|
||||
"tag": "0024_canonical_categories",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1783086194145,
|
||||
"tag": "0025_canonical_wheel_accessory",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1783087194145,
|
||||
"tag": "0026_canonical_subcategories",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "7",
|
||||
"when": 1783088194145,
|
||||
"tag": "0027_canonical_subcategories_v2",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "7",
|
||||
"when": 1783089194145,
|
||||
"tag": "0028_canonical_subcat_v3",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1783090194145,
|
||||
"tag": "0029_canonical_structure_template",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1783091194145,
|
||||
"tag": "0030_categories_dedup_unique",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "7",
|
||||
"when": 1783092194145,
|
||||
"tag": "0031_canonical_template_entries",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "7",
|
||||
"when": 1784023885041,
|
||||
"tag": "0032_part_price_oe_reset",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 33,
|
||||
"version": "7",
|
||||
"when": 1784027180912,
|
||||
"tag": "0033_parts_oem_code_norm_idx",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 34,
|
||||
"version": "7",
|
||||
"when": 1784965129879,
|
||||
"tag": "0034_vehicle_fully_fetched",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "7",
|
||||
"when": 1786435493000,
|
||||
"tag": "0035_subscription_dunning",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -65,6 +65,7 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0",
|
||||
"stripe": "^22.1.1",
|
||||
"tesseract.js": "^5.1.1",
|
||||
"undici": "^7.22.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
|
||||
@@ -21,23 +21,30 @@ import { RolesGuard } from "./common/guards/roles.guard";
|
||||
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor";
|
||||
import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor";
|
||||
import { TransformInterceptor } from "./common/interceptors/transform.interceptor";
|
||||
import { TelegramModule } from "./common/telegram.module";
|
||||
import configuration from "./config/configuration";
|
||||
import { validate } from "./config/env.validation";
|
||||
import { ContactModule } from "./contact/contact.module";
|
||||
import { DatabaseModule } from "./database/database.module";
|
||||
import { DemoModule } from "./demo/demo.module";
|
||||
import { DiscountCodesModule } from "./discount-codes/discount-codes.module";
|
||||
import { EmailModule } from "./email/email.module";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { EmexModule } from "./integrations/emex/emex.module";
|
||||
import { PModule } from "./integrations/p/p.module";
|
||||
import { VinpinModule } from "./integrations/vinpin/vinpin.module";
|
||||
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
|
||||
import { JobsModule } from "./jobs/jobs.module";
|
||||
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
|
||||
import { NotificationsModule } from "./notifications/notifications.module";
|
||||
import { OemSuggestionsModule } from "./oem-suggestions/oem-suggestions.module";
|
||||
import { OemVotesModule } from "./oem-votes/oem-votes.module";
|
||||
import { PartPricesModule } from "./part-prices/part-prices.module";
|
||||
import { PartsModule } from "./parts/parts.module";
|
||||
import { PaymentsModule } from "./payments/payments.module";
|
||||
import { PlansModule } from "./plans/plans.module";
|
||||
import { PostHogModule } from "./posthog/posthog.module";
|
||||
import { PublicApiModule } from "./public-api/public-api.module";
|
||||
import { RedisModule } from "./redis/redis.module";
|
||||
import { ReferralsModule } from "./referrals/referrals.module";
|
||||
import { StorageModule } from "./storage/storage.module";
|
||||
@@ -73,6 +80,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
||||
]),
|
||||
DatabaseModule,
|
||||
RedisModule,
|
||||
TelegramModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
EmailModule,
|
||||
@@ -82,17 +90,22 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
||||
PlansModule,
|
||||
SubscriptionsModule,
|
||||
PaymentsModule,
|
||||
DiscountCodesModule,
|
||||
ReferralsModule,
|
||||
VehiclesModule,
|
||||
CategoriesModule,
|
||||
DemoModule,
|
||||
PartsModule,
|
||||
PModule,
|
||||
PartPricesModule,
|
||||
JobsModule,
|
||||
EmexModule,
|
||||
VinpinModule,
|
||||
TranslationsModule,
|
||||
AdminModule,
|
||||
AnalyticsModule,
|
||||
OemVotesModule,
|
||||
OemSuggestionsModule,
|
||||
CatalogModule,
|
||||
ChangelogModule,
|
||||
ChatwootModule,
|
||||
@@ -102,6 +115,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
||||
PostHogModule,
|
||||
TelemetryModule,
|
||||
InternalAdminModule,
|
||||
PublicApiModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { generateReferralCode, normalizeName } from "@sase/shared";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { captcha } from "better-auth/plugins";
|
||||
import { apiKey, captcha } from "better-auth/plugins";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
@@ -224,6 +224,21 @@ export function createAuth(
|
||||
"http://localhost:4000",
|
||||
],
|
||||
plugins: [
|
||||
// Public API anahtarları (api.sase.tr / /api/v1). Doğrulama ApiKeyGuard
|
||||
// üzerinden auth.api.verifyApiKey ile yapılır; enableSessionForAPIKeys
|
||||
// bilinçli olarak KAPALI — API anahtarı dashboard oturumu yerine geçmez,
|
||||
// cookie tabanlı AuthGuard'ın davranışı değişmez.
|
||||
apiKey({
|
||||
defaultPrefix: "sase_",
|
||||
enableMetadata: true,
|
||||
// Anahtar başına istek tavanı (verify başına sayaç). Günlük decode
|
||||
// kotasından ayrıdır — o, PublicApiQuotaService'te Redis ile tutulur.
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
timeWindow: 60_000, // 1 dakika
|
||||
maxRequests: 120,
|
||||
},
|
||||
}),
|
||||
// Cloudflare Turnstile: yalnızca secret tanımlıysa aktif. sign-in/sign-up
|
||||
// uçları "x-captcha-response" header'ındaki token ile doğrulanır.
|
||||
...(process.env.TURNSTILE_SECRET_KEY
|
||||
@@ -247,3 +262,42 @@ export function getAuth() {
|
||||
}
|
||||
return authInstance;
|
||||
}
|
||||
|
||||
/** apiKey plugin'inin döndürdüğü anahtar kaydı (verify/create cevapları). */
|
||||
export interface ApiKeyRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string | null;
|
||||
start: string | null;
|
||||
prefix: string | null;
|
||||
enabled: boolean;
|
||||
expiresAt: Date | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* apiKey plugin uçları. `authInstance` geniş `ReturnType<typeof betterAuth>`
|
||||
* ile tiplendiği için plugin uçları tipte görünmüyor (runtime'da mevcutlar);
|
||||
* bu erişimci onları dar ve doğru imzalarla expose eder.
|
||||
*/
|
||||
export function getApiKeyApi() {
|
||||
return getAuth().api as unknown as {
|
||||
verifyApiKey: (input: { body: { key: string } }) => Promise<{
|
||||
valid: boolean;
|
||||
error: { message?: string; code: string } | null;
|
||||
key: ApiKeyRecord | null;
|
||||
}>;
|
||||
createApiKey: (input: {
|
||||
body: {
|
||||
userId: string;
|
||||
name?: string;
|
||||
prefix?: string;
|
||||
expiresIn?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
rateLimitEnabled?: boolean;
|
||||
rateLimitTimeWindow?: number;
|
||||
rateLimitMax?: number;
|
||||
};
|
||||
}) => Promise<ApiKeyRecord & { key: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,10 +52,7 @@ export class BlogController {
|
||||
// Automation endpoint — published by the Süper Panel content pipeline via n8n.
|
||||
@Post("posts/internal")
|
||||
@Public()
|
||||
async createInternal(
|
||||
@Headers("authorization") authHeader: string,
|
||||
@Body() body: CreateBlogPost,
|
||||
) {
|
||||
async createInternal(@Headers("authorization") authHeader: string, @Body() body: CreateBlogPost) {
|
||||
assertAutomationToken(authHeader);
|
||||
if (!body?.title || !body?.body_markdown) {
|
||||
throw new UnauthorizedException("title and body_markdown are required");
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { Injectable, Logger, NotFoundException, ServiceUnavailableException } from "@nestjs/common";
|
||||
import { RedisService } from "../redis/redis.service";
|
||||
import type { BlogPost, BlogPostListItem, CreateBlogPost } from "./blog.dto";
|
||||
|
||||
@@ -56,19 +51,31 @@ function resolveCover(row: Pick<DirectusPost, "cover_image" | "cover_file">): st
|
||||
// slug, but we slugify defensively (and to derive one if it's missing).
|
||||
function slugify(input: string): string {
|
||||
const map: Record<string, string> = {
|
||||
ç: "c", ğ: "g", ı: "i", İ: "i", ö: "o", ş: "s", ü: "u",
|
||||
Ç: "c", Ğ: "g", Ö: "o", Ş: "s", Ü: "u",
|
||||
ç: "c",
|
||||
ğ: "g",
|
||||
ı: "i",
|
||||
İ: "i",
|
||||
ö: "o",
|
||||
ş: "s",
|
||||
ü: "u",
|
||||
Ç: "c",
|
||||
Ğ: "g",
|
||||
Ö: "o",
|
||||
Ş: "s",
|
||||
Ü: "u",
|
||||
};
|
||||
return input
|
||||
.split("")
|
||||
.map((c) => map[c] ?? c)
|
||||
.join("")
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 160) || "yazi";
|
||||
return (
|
||||
input
|
||||
.split("")
|
||||
.map((c) => map[c] ?? c)
|
||||
.join("")
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 160) || "yazi"
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -95,7 +102,9 @@ export class BlogService {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => "");
|
||||
throw new Error(`Directus ${init.method ?? "GET"} ${path} -> ${res.status}: ${detail.slice(0, 300)}`);
|
||||
throw new Error(
|
||||
`Directus ${init.method ?? "GET"} ${path} -> ${res.status}: ${detail.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
const json = (await res.json()) as { data: T };
|
||||
return json.data;
|
||||
@@ -136,7 +145,8 @@ export class BlogService {
|
||||
"filter[project][_eq]": DIRECTUS_PROJECT,
|
||||
"filter[status][_eq]": "published",
|
||||
sort: "-published_at",
|
||||
fields: "slug,title,meta_description,tags,cover_image,cover_file.filename_disk,published_at",
|
||||
fields:
|
||||
"slug,title,meta_description,tags,cover_image,cover_file.filename_disk,published_at",
|
||||
limit: "-1",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -81,6 +81,19 @@ export class CatalogController {
|
||||
return this.catalogService.getCategoryTree(id, user.id, body, engine, gearbox, mainGroupsPath);
|
||||
}
|
||||
|
||||
// Unified canonical browse for a catalog vehicle (pl24 flow).
|
||||
@Get("vehicles/:id/canonical")
|
||||
getCanonicalTree(
|
||||
@Param("id") id: string,
|
||||
@Query("body") body: string | undefined,
|
||||
@Query("engine") engine: string | undefined,
|
||||
@Query("gearbox") gearbox: string | undefined,
|
||||
@Query("mgp") mainGroupsPath: string | undefined,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
return this.catalogService.getCanonicalTree(id, user.id, body, engine, gearbox, mainGroupsPath);
|
||||
}
|
||||
|
||||
@Get("vehicles/:id/categories/:categoryId")
|
||||
getCategoryWithParts(
|
||||
@Param("id") id: string,
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { and, eq, inArray, or, sql } from "drizzle-orm";
|
||||
import { and, eq, gte, inArray, isNotNull, isNull, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
buildCanonicalInput,
|
||||
groupCategoriesByCanonical,
|
||||
overlayTemplate,
|
||||
} from "../categories/canonical-grouping";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import {
|
||||
brands,
|
||||
canonicalCategories,
|
||||
canonicalStructureTemplate,
|
||||
canonicalTemplateEntries,
|
||||
catalogVehicles,
|
||||
categories,
|
||||
parts,
|
||||
@@ -11,6 +19,7 @@ import {
|
||||
userBrands,
|
||||
userSubscriptions,
|
||||
} from "../database/schema/core";
|
||||
import { isPl24LeafNode } from "../integrations/pl24/pl24-tree";
|
||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||
import {
|
||||
type PL24DecodedCategory,
|
||||
@@ -349,6 +358,130 @@ export class CatalogService {
|
||||
/**
|
||||
* Get category tree for a catalog vehicle (on-demand fetch from PL24).
|
||||
*/
|
||||
/**
|
||||
* Unified canonical browse for a catalog vehicle (pl24 flow) — the
|
||||
* catalog_vehicle_id counterpart of CategoriesService.getCanonicalTree.
|
||||
* Folds the vehicle's part-holding leaf categories into the 20 canonical
|
||||
* buckets so pl24 brands (VW, Opel, Renault…) navigate through the same
|
||||
* headings as the emex/pcat vehicle_id flow. Part counts are joined by
|
||||
* category_id (pl24 parts often carry vehicle_id, not catalog_vehicle_id).
|
||||
*/
|
||||
async getCanonicalTree(
|
||||
catalogVehicleId: string,
|
||||
userId: string,
|
||||
body = "_all_",
|
||||
engine = "_all_",
|
||||
gearbox = "_all_",
|
||||
mainGroupsPath?: string,
|
||||
) {
|
||||
// Populate the raw source tree (lazy fetch + cache) and enforce brand access.
|
||||
await this.getCategoryTree(catalogVehicleId, userId, body, engine, gearbox, mainGroupsPath);
|
||||
|
||||
const buckets = await this.db
|
||||
.select({
|
||||
id: canonicalCategories.id,
|
||||
slug: canonicalCategories.slug,
|
||||
name: canonicalCategories.name,
|
||||
})
|
||||
.from(canonicalCategories)
|
||||
.where(isNull(canonicalCategories.parentId))
|
||||
.orderBy(canonicalCategories.sortOrder);
|
||||
const subLeaves = await this.db
|
||||
.select({
|
||||
id: canonicalCategories.id,
|
||||
parentId: canonicalCategories.parentId,
|
||||
slug: canonicalCategories.slug,
|
||||
name: canonicalCategories.name,
|
||||
})
|
||||
.from(canonicalCategories)
|
||||
.where(isNotNull(canonicalCategories.parentId))
|
||||
.orderBy(canonicalCategories.sortOrder);
|
||||
|
||||
const cats = await this.db
|
||||
.select({
|
||||
id: categories.id,
|
||||
name: categories.name,
|
||||
source: categories.source,
|
||||
canonicalId: categories.canonicalCategoryId,
|
||||
subId: categories.canonicalSubcategoryId,
|
||||
parentId: categories.parentId,
|
||||
})
|
||||
.from(categories)
|
||||
.where(eq(categories.catalogVehicleId, catalogVehicleId));
|
||||
|
||||
const partCounts = new Map<string, number>();
|
||||
const catIds = cats.map((c) => c.id);
|
||||
if (catIds.length > 0) {
|
||||
const rows = await this.db
|
||||
.select({ categoryId: parts.categoryId, count: sql<number>`count(*)::int` })
|
||||
.from(parts)
|
||||
.where(inArray(parts.categoryId, catIds))
|
||||
.groupBy(parts.categoryId);
|
||||
for (const r of rows) partCounts.set(r.categoryId, r.count);
|
||||
}
|
||||
|
||||
// Show each bucket's natural sub-category level (see buildCanonicalInput).
|
||||
const {
|
||||
input: shown,
|
||||
counts: shownCounts,
|
||||
hasChildren,
|
||||
} = buildCanonicalInput(cats, partCounts, buckets);
|
||||
const groups = groupCategoriesByCanonical(shown, shownCounts, buckets, {
|
||||
requireParts: false,
|
||||
subLeaves,
|
||||
hasChildren,
|
||||
});
|
||||
|
||||
// Overlay the per-brand structure template (see CategoriesService.getCanonicalTree).
|
||||
const cv = await this.db
|
||||
.select({ brand: catalogVehicles.brandName, source: catalogVehicles.source })
|
||||
.from(catalogVehicles)
|
||||
.where(eq(catalogVehicles.id, catalogVehicleId))
|
||||
.limit(1);
|
||||
const brand = cv[0]?.brand;
|
||||
const source = cats[0]?.source ?? cv[0]?.source;
|
||||
if (!brand || !source) return groups;
|
||||
const template = await this.db
|
||||
.select({
|
||||
bucketId: canonicalStructureTemplate.canonicalCategoryId,
|
||||
subId: canonicalStructureTemplate.canonicalSubcategoryId,
|
||||
})
|
||||
.from(canonicalStructureTemplate)
|
||||
.where(
|
||||
and(
|
||||
eq(canonicalStructureTemplate.source, source),
|
||||
eq(canonicalStructureTemplate.brand, brand),
|
||||
gte(canonicalStructureTemplate.vehicleCount, 2),
|
||||
),
|
||||
);
|
||||
const templateEntriesRaw = await this.db
|
||||
.select({
|
||||
bucketId: canonicalTemplateEntries.canonicalCategoryId,
|
||||
subId: canonicalTemplateEntries.canonicalSubcategoryId,
|
||||
name: canonicalTemplateEntries.name,
|
||||
vehicleCount: canonicalTemplateEntries.vehicleCount,
|
||||
})
|
||||
.from(canonicalTemplateEntries)
|
||||
.where(
|
||||
and(
|
||||
eq(canonicalTemplateEntries.source, source),
|
||||
eq(canonicalTemplateEntries.brand, brand),
|
||||
gte(canonicalTemplateEntries.vehicleCount, 2),
|
||||
),
|
||||
);
|
||||
// Cap placeholders at the MOST-SHARED 40 per bucket — some brands carry
|
||||
// hundreds of model-variant names (pl24 BMW elektrik: 500+) that would
|
||||
// drown the view; the widely-shared ones are the real "brand structure".
|
||||
templateEntriesRaw.sort((a, z) => z.vehicleCount - a.vehicleCount);
|
||||
const perBucket = new Map<string, number>();
|
||||
const templateEntries = templateEntriesRaw.filter((e) => {
|
||||
const n = (perBucket.get(e.bucketId) ?? 0) + 1;
|
||||
perBucket.set(e.bucketId, n);
|
||||
return n <= 40;
|
||||
});
|
||||
return overlayTemplate(groups, template, buckets, subLeaves, templateEntries);
|
||||
}
|
||||
|
||||
async getCategoryTree(
|
||||
catalogVehicleId: string,
|
||||
userId: string,
|
||||
@@ -1117,18 +1250,10 @@ export class CatalogService {
|
||||
return this.pl24Service.fetchFordModelConfig(vehicle.serviceName, familyId, mode, upds);
|
||||
}
|
||||
|
||||
/** Shared PL24 leaf classifier — see integrations/pl24/pl24-tree. */
|
||||
private isLeafPath(linkPath: string): boolean {
|
||||
const lower = linkPath.toLowerCase();
|
||||
return (
|
||||
lower.includes("/bom/") ||
|
||||
lower.includes("/bomdetails") ||
|
||||
lower.includes("/partinfo/") ||
|
||||
// PL24 P5 leaf items endpoints — chemicals, servicepart, accessories,
|
||||
// any /extern/<kind>/(vin|mdl)_items combination. These return parts,
|
||||
// not subgroups, so they must short-circuit drill-down.
|
||||
/\/extern\/[^/]+\/(vin_items|mdl_items)\b/.test(lower) ||
|
||||
lower.includes("image-board.action") || // PSA illustration leaf
|
||||
lower.includes("json-vin-bom-detail.action")
|
||||
isPl24LeafNode({ linkPath }) || linkPath.toLowerCase().includes("json-vin-bom-detail.action")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1235,13 +1360,9 @@ export class CatalogService {
|
||||
|
||||
return cats.map((c) => {
|
||||
const dbChildCount = childCountMap.get(c.id) || 0;
|
||||
const isLeaf =
|
||||
c.linkPath?.includes("/bom/") ||
|
||||
c.linkPath?.includes("/bomdetails") ||
|
||||
c.linkPath?.includes("/partinfo/") ||
|
||||
c.linkPath?.includes("/servicepart/vin_items") ||
|
||||
c.linkPath?.includes("image-board.action") || // PSA illustration leaf
|
||||
(!c.linkPath && dbChildCount === 0);
|
||||
const isLeaf = c.linkPath
|
||||
? isPl24LeafNode({ linkPath: c.linkPath, linkWid: c.linkWid })
|
||||
: dbChildCount === 0;
|
||||
return {
|
||||
...c,
|
||||
schemaImageUrl: picMap.get(c.id) || null,
|
||||
|
||||
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 {
|
||||
constructor(private categoriesService: CategoriesService) {}
|
||||
|
||||
// Sources this vehicle has (default/eper first) — drives the source switcher.
|
||||
// Declared before "tree/:vehicleId" so the literal segment wins the match.
|
||||
@Get("tree/:vehicleId/sources")
|
||||
async getCatalogSources(@Param("vehicleId") vehicleId: string) {
|
||||
return this.categoriesService.getCatalogSources(vehicleId);
|
||||
}
|
||||
|
||||
@Get("tree/:vehicleId")
|
||||
async getCategoryTree(@Param("vehicleId") vehicleId: string) {
|
||||
return this.categoriesService.getCategoryTree(vehicleId);
|
||||
async getCategoryTree(@Param("vehicleId") vehicleId: string, @Query("source") source?: string) {
|
||||
return this.categoriesService.getCategoryTree(vehicleId, source);
|
||||
}
|
||||
|
||||
@Get("search/:vehicleId")
|
||||
@@ -15,6 +22,27 @@ export class CategoriesController {
|
||||
return this.categoriesService.searchCatalog(vehicleId, q ?? "");
|
||||
}
|
||||
|
||||
// The 20-bucket unified taxonomy (browse landing / filter chips). Declared
|
||||
// before ":id" so the literal path isn't captured by the id param route.
|
||||
@Get("canonical")
|
||||
async getCanonicalCategories() {
|
||||
return this.categoriesService.getCanonicalCategories();
|
||||
}
|
||||
|
||||
// Full 2-level canonical taxonomy (buckets + sub-leaves + population counts) —
|
||||
// the source-independent structure exposed to B2C API consumers. Declared
|
||||
// before "canonical/:vehicleId" so the literal isn't captured as a vehicleId.
|
||||
@Get("canonical/taxonomy")
|
||||
async getCanonicalTaxonomy() {
|
||||
return this.categoriesService.getCanonicalTaxonomy();
|
||||
}
|
||||
|
||||
// A vehicle's catalog folded into the canonical buckets (unified browse).
|
||||
@Get("canonical/:vehicleId")
|
||||
async getCanonicalTree(@Param("vehicleId") vehicleId: string) {
|
||||
return this.categoriesService.getCanonicalTree(vehicleId);
|
||||
}
|
||||
|
||||
@Get(":id/children")
|
||||
async getChildren(@Param("id") id: string) {
|
||||
return this.categoriesService.getChildren(id);
|
||||
|
||||
@@ -320,6 +320,84 @@ describe("CategoriesService", () => {
|
||||
"drill-load-error",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes a P4 Ford VIN node (json-vin-sub-group.action, positional link_wid) to children, not an empty leaf", async () => {
|
||||
// link_wid "1" is a positional code (not a *Group* table) so the link_wid
|
||||
// group-drill branch misses it; the path is …group.action (not /psa/, not
|
||||
// vin-group.action) so the PSA / Volvo branches miss it too. Before the
|
||||
// dedicated gate these fell through to the leaf parts path → silent empty
|
||||
// panel (Ford/Nissan/Opel analog of the PSA-parent-gate gap).
|
||||
const category = {
|
||||
id: "ford1",
|
||||
name: "elektrik sistemi",
|
||||
nameOriginal: "elektrik sistemi",
|
||||
parentId: null,
|
||||
vehicleId: "v1",
|
||||
source: "pl24",
|
||||
linkPath: "/ford/fordp_parts/json-vin-sub-group.action?mainGroupId=CAP1&vin=WF0FXX",
|
||||
linkWid: "1",
|
||||
hasSubgroups: null,
|
||||
hasParts: null,
|
||||
};
|
||||
let selectCall = 0;
|
||||
const db = {
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
selectCall++;
|
||||
const captured = selectCall;
|
||||
const c: Record<string, any> = {};
|
||||
c.from = vi.fn().mockReturnValue(c);
|
||||
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
|
||||
c.limit = vi.fn().mockReturnValue([category]);
|
||||
return c;
|
||||
}),
|
||||
execute: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
const { service } = createService(db);
|
||||
const discovered = [{ id: "sg1", name: "kablolar", children: undefined }];
|
||||
vi.spyOn(service, "getChildren").mockResolvedValue(discovered as any);
|
||||
|
||||
const result = await service.getCategoryWithParts("ford1");
|
||||
|
||||
expect(service.getChildren).toHaveBeenCalledWith("ford1");
|
||||
expect(result.parts).toEqual([]);
|
||||
expect((result as { children?: unknown }).children).toEqual(discovered);
|
||||
});
|
||||
|
||||
it("surfaces loadError when a P4 group.action drill comes back empty (not a silent leaf)", async () => {
|
||||
const category = {
|
||||
id: "ford2",
|
||||
name: "kaporta",
|
||||
nameOriginal: "kaporta",
|
||||
parentId: null,
|
||||
vehicleId: "v1",
|
||||
source: "pl24",
|
||||
linkPath: "/ford/fordp_parts/json-vin-sub-group.action?mainGroupId=GDB&vin=WF0FXX",
|
||||
linkWid: "2",
|
||||
hasSubgroups: null,
|
||||
hasParts: null,
|
||||
};
|
||||
let selectCall = 0;
|
||||
const db = {
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
selectCall++;
|
||||
const captured = selectCall;
|
||||
const c: Record<string, any> = {};
|
||||
c.from = vi.fn().mockReturnValue(c);
|
||||
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
|
||||
c.limit = vi.fn().mockReturnValue([category]);
|
||||
return c;
|
||||
}),
|
||||
execute: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
const { service } = createService(db);
|
||||
vi.spyOn(service, "getChildren").mockResolvedValue([] as any);
|
||||
|
||||
const result = await service.getCategoryWithParts("ford2");
|
||||
|
||||
expect(service.getChildren).toHaveBeenCalledWith("ford2");
|
||||
expect((result as { loadError?: boolean }).loadError).toBe(true);
|
||||
expect((result as { children?: unknown }).children).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAncestors", () => {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { and, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { and, eq, gte, ilike, inArray, isNotNull, isNull, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
type CatalogDegradationKind,
|
||||
reportCatalogDegradation,
|
||||
} from "../common/catalog-degradation";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
|
||||
import {
|
||||
canonicalCategories,
|
||||
canonicalStructureTemplate,
|
||||
canonicalTemplateEntries,
|
||||
categories,
|
||||
parts,
|
||||
schemaPics,
|
||||
vehicles,
|
||||
} from "../database/schema/core";
|
||||
import { EmexSourceDbService } from "../integrations/catalog-source-db/emex-source-db.service";
|
||||
import { PcatSourceDbService } from "../integrations/catalog-source-db/pcat-source-db.service";
|
||||
import { EmexService } from "../integrations/emex/emex.service";
|
||||
@@ -13,10 +21,17 @@ import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catal
|
||||
import { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
||||
import { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
|
||||
import { PL24PsaService } from "../integrations/pl24/pl24-psa.service";
|
||||
import { isPl24GroupNode, isPl24LeafNode } from "../integrations/pl24/pl24-tree";
|
||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||
import { classifyNode, foldName, mapToCanonical } from "../jobs/canonical-lexicon";
|
||||
import { RedisService } from "../redis/redis.service";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import { TranslationsService } from "../translations/translations.service";
|
||||
import {
|
||||
buildCanonicalInput,
|
||||
groupCategoriesByCanonical,
|
||||
overlayTemplate,
|
||||
} from "./canonical-grouping";
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
@@ -36,8 +51,8 @@ export class CategoriesService {
|
||||
private emexSourceDb: EmexSourceDbService,
|
||||
) {}
|
||||
|
||||
async getCategoryTree(vehicleId: string) {
|
||||
const cacheKey = `cat:tree:${vehicleId}`;
|
||||
async getCategoryTree(vehicleId: string, sourceFilter?: string) {
|
||||
const cacheKey = `cat:tree:${vehicleId}${sourceFilter ? `:${sourceFilter}` : ""}`;
|
||||
const cached = await this.redis.getJson<any[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -120,6 +135,10 @@ export class CategoriesService {
|
||||
externalId: s.code,
|
||||
linkPath: s.linkPath || null,
|
||||
linkWid: null as string | null,
|
||||
// PSA scopes are always parents (they hold main-groups, never direct
|
||||
// parts) — flag them so the tree UI signposts them as folders and the
|
||||
// prefetch worker pre-drills them instead of treating them as leaves.
|
||||
hasSubgroups: true,
|
||||
source: "pl24" as const,
|
||||
}));
|
||||
dbCategories = await this.db
|
||||
@@ -181,6 +200,10 @@ export class CategoriesService {
|
||||
externalId: c.code,
|
||||
linkPath: c.linkPath || null,
|
||||
linkWid: c.linkWid || null,
|
||||
// Mark P4 VIN/legacy group-drill nodes (…group.action) as parents so
|
||||
// the tree UI shows them as folders and prefetch pre-drills them. Leaf
|
||||
// pages (image-board.action/bom) stay null (unknown→leaf downstream).
|
||||
hasSubgroups: c.linkPath?.includes("group.action") ? true : null,
|
||||
source: "pl24" as const,
|
||||
}));
|
||||
|
||||
@@ -435,8 +458,23 @@ export class CategoriesService {
|
||||
partCountRows.map((r) => [r.categoryId, r.count]),
|
||||
);
|
||||
|
||||
// Default catalog source for a multi-source vehicle: prefer eper-pekidi — the
|
||||
// native OEM ePER tree Fiat dealers already know, fully populated with parts +
|
||||
// diagrams — over parts-catalogs' broader-but-lazy (drill-on-demand) folders,
|
||||
// which is where dealers hit gaps (a motor OEM missing under "Motor"). pcat is
|
||||
// never dropped: the ?source override + /sources endpoint keep it one tap away.
|
||||
const presentSources = [...new Set(dbCategories.map((c) => c.source))];
|
||||
const activeSource =
|
||||
sourceFilter && presentSources.includes(sourceFilter)
|
||||
? sourceFilter
|
||||
: presentSources.includes("eper-pekidi")
|
||||
? "eper-pekidi"
|
||||
: null;
|
||||
const forTree = activeSource
|
||||
? dbCategories.filter((c) => c.source === activeSource)
|
||||
: dbCategories;
|
||||
// Build tree
|
||||
const tree = this.buildTree(dbCategories, directPartCounts);
|
||||
const tree = this.buildTree(forTree, directPartCounts);
|
||||
// Cache a populated tree for an hour; an EMPTY tree (transient decode/proxy
|
||||
// failure) only for 60s so a blip doesn't poison the catalog for an hour —
|
||||
// it self-heals on the next request after the source recovers, while still
|
||||
@@ -458,6 +496,210 @@ export class CategoriesService {
|
||||
return tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog sources a decoded vehicle has categories from, ordered with the
|
||||
* default first (eper-pekidi — the dealer-familiar native ePER catalog). Drives
|
||||
* the per-vehicle source switcher (ePER ⇄ Diğer); a single source → no switcher.
|
||||
*/
|
||||
async getCatalogSources(vehicleId: string): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.selectDistinct({ source: categories.source })
|
||||
.from(categories)
|
||||
.where(eq(categories.vehicleId, vehicleId));
|
||||
const present = rows.map((r) => r.source);
|
||||
const order = ["eper-pekidi", "parts-catalogs", "emex", "pl24"];
|
||||
return present.sort((a, b) => {
|
||||
const ia = order.indexOf(a);
|
||||
const ib = order.indexOf(b);
|
||||
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The unified canonical taxonomy (20 top buckets), source/brand-independent.
|
||||
* Used for the browse landing + as the fixed set of headings every vehicle's
|
||||
* catalog folds into.
|
||||
*/
|
||||
async getCanonicalCategories() {
|
||||
return this.db
|
||||
.select({
|
||||
id: canonicalCategories.id,
|
||||
slug: canonicalCategories.slug,
|
||||
name: canonicalCategories.name,
|
||||
})
|
||||
.from(canonicalCategories)
|
||||
.where(isNull(canonicalCategories.parentId))
|
||||
.orderBy(canonicalCategories.sortOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full 2-level canonical taxonomy — the source-independent category structure
|
||||
* a B2C API consumer browses by: 20 top buckets, each with its curated
|
||||
* sub-leaves (Fren → Balata/Disk/Kaliper…). `mappedCategories` shows how many
|
||||
* source categories currently fall in each node (population signal); sub-leaves
|
||||
* with 0 are still listed so the contract is stable. Sub-mapping is completed
|
||||
* over time in the super-panel, so counts only grow.
|
||||
*/
|
||||
async getCanonicalTaxonomy() {
|
||||
const nodes = await this.db
|
||||
.select({
|
||||
id: canonicalCategories.id,
|
||||
parentId: canonicalCategories.parentId,
|
||||
slug: canonicalCategories.slug,
|
||||
name: canonicalCategories.name,
|
||||
sortOrder: canonicalCategories.sortOrder,
|
||||
})
|
||||
.from(canonicalCategories)
|
||||
.orderBy(canonicalCategories.sortOrder);
|
||||
|
||||
// population per node: top buckets count canonical_category_id, sub-leaves
|
||||
// count canonical_subcategory_id. One grouped scan each (both columns indexed).
|
||||
const topCounts = await this.db
|
||||
.select({ id: categories.canonicalCategoryId, n: sql<number>`count(*)::int` })
|
||||
.from(categories)
|
||||
.where(isNotNull(categories.canonicalCategoryId))
|
||||
.groupBy(categories.canonicalCategoryId);
|
||||
const subCounts = await this.db
|
||||
.select({ id: categories.canonicalSubcategoryId, n: sql<number>`count(*)::int` })
|
||||
.from(categories)
|
||||
.where(isNotNull(categories.canonicalSubcategoryId))
|
||||
.groupBy(categories.canonicalSubcategoryId);
|
||||
const countById = new Map<string, number>();
|
||||
for (const r of [...topCounts, ...subCounts]) if (r.id) countById.set(r.id, r.n);
|
||||
|
||||
const subsByParent = new Map<string, typeof nodes>();
|
||||
for (const n of nodes) {
|
||||
if (!n.parentId) continue;
|
||||
const arr = subsByParent.get(n.parentId) ?? [];
|
||||
arr.push(n);
|
||||
subsByParent.set(n.parentId, arr);
|
||||
}
|
||||
return nodes
|
||||
.filter((n) => !n.parentId)
|
||||
.map((b) => ({
|
||||
id: b.id,
|
||||
slug: b.slug,
|
||||
name: b.name,
|
||||
mappedCategories: countById.get(b.id) ?? 0,
|
||||
subcategories: (subsByParent.get(b.id) ?? []).map((s) => ({
|
||||
id: s.id,
|
||||
slug: s.slug,
|
||||
name: s.name,
|
||||
mappedCategories: countById.get(s.id) ?? 0,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified browse: a vehicle's part-holding leaf categories grouped under the
|
||||
* 20 canonical buckets, so VW and Opel (whose raw OEM trees differ wildly) are
|
||||
* navigated through one identical set of headings. Each entry lists the source
|
||||
* leaf categories that fall in that bucket for this vehicle, with part counts;
|
||||
* the UI drills into a leaf via the existing /categories/:id endpoint.
|
||||
*
|
||||
* Bucketing uses the materialized categories.canonical_category_id when set,
|
||||
* falling back to on-the-fly mapping (shared canonical-lexicon) for categories
|
||||
* decoded since the last backfill. Categories with no bucket land under "Diğer"
|
||||
* so nothing is hidden.
|
||||
*/
|
||||
async getCanonicalTree(vehicleId: string) {
|
||||
// Ensure the vehicle's raw source tree is populated (lazy decode + cache).
|
||||
await this.getCategoryTree(vehicleId);
|
||||
|
||||
const buckets = await this.getCanonicalCategories();
|
||||
const subLeaves = await this.db
|
||||
.select({
|
||||
id: canonicalCategories.id,
|
||||
parentId: canonicalCategories.parentId,
|
||||
slug: canonicalCategories.slug,
|
||||
name: canonicalCategories.name,
|
||||
})
|
||||
.from(canonicalCategories)
|
||||
.where(isNotNull(canonicalCategories.parentId))
|
||||
.orderBy(canonicalCategories.sortOrder);
|
||||
|
||||
const cats = await this.db
|
||||
.select({
|
||||
id: categories.id,
|
||||
name: categories.name,
|
||||
source: categories.source,
|
||||
canonicalId: categories.canonicalCategoryId,
|
||||
subId: categories.canonicalSubcategoryId,
|
||||
parentId: categories.parentId,
|
||||
})
|
||||
.from(categories)
|
||||
.where(eq(categories.vehicleId, vehicleId));
|
||||
|
||||
const partCountRows = await this.db
|
||||
.select({ categoryId: parts.categoryId, count: sql<number>`count(*)::int` })
|
||||
.from(parts)
|
||||
.where(eq(parts.vehicleId, vehicleId))
|
||||
.groupBy(parts.categoryId);
|
||||
const partCounts = new Map(partCountRows.map((r) => [r.categoryId, r.count]));
|
||||
|
||||
const {
|
||||
input: shown,
|
||||
counts: shownCounts,
|
||||
hasChildren,
|
||||
} = buildCanonicalInput(cats, partCounts, buckets);
|
||||
const groups = groupCategoriesByCanonical(shown, shownCounts, buckets, {
|
||||
requireParts: false,
|
||||
subLeaves,
|
||||
hasChildren,
|
||||
});
|
||||
|
||||
// Overlay the per-brand structure template: buckets / sub-leaves this brand
|
||||
// is known to have but this vehicle hasn't deep-drilled yet appear as empty
|
||||
// entries, so the FULL canonical structure shows immediately after decode.
|
||||
const veh = await this.db
|
||||
.select({ brand: vehicles.brandName, source: vehicles.source })
|
||||
.from(vehicles)
|
||||
.where(eq(vehicles.id, vehicleId))
|
||||
.limit(1);
|
||||
const brand = veh[0]?.brand;
|
||||
const source = cats[0]?.source ?? veh[0]?.source;
|
||||
if (!brand || !source) return groups;
|
||||
const template = await this.db
|
||||
.select({
|
||||
bucketId: canonicalStructureTemplate.canonicalCategoryId,
|
||||
subId: canonicalStructureTemplate.canonicalSubcategoryId,
|
||||
})
|
||||
.from(canonicalStructureTemplate)
|
||||
.where(
|
||||
and(
|
||||
eq(canonicalStructureTemplate.source, source),
|
||||
eq(canonicalStructureTemplate.brand, brand),
|
||||
gte(canonicalStructureTemplate.vehicleCount, 2),
|
||||
),
|
||||
);
|
||||
const templateEntriesRaw = await this.db
|
||||
.select({
|
||||
bucketId: canonicalTemplateEntries.canonicalCategoryId,
|
||||
subId: canonicalTemplateEntries.canonicalSubcategoryId,
|
||||
name: canonicalTemplateEntries.name,
|
||||
vehicleCount: canonicalTemplateEntries.vehicleCount,
|
||||
})
|
||||
.from(canonicalTemplateEntries)
|
||||
.where(
|
||||
and(
|
||||
eq(canonicalTemplateEntries.source, source),
|
||||
eq(canonicalTemplateEntries.brand, brand),
|
||||
gte(canonicalTemplateEntries.vehicleCount, 2),
|
||||
),
|
||||
);
|
||||
// Cap placeholders at the MOST-SHARED 40 per bucket — some brands carry
|
||||
// hundreds of model-variant names (pl24 BMW elektrik: 500+) that would
|
||||
// drown the view; the widely-shared ones are the real "brand structure".
|
||||
templateEntriesRaw.sort((a, z) => z.vehicleCount - a.vehicleCount);
|
||||
const perBucket = new Map<string, number>();
|
||||
const templateEntries = templateEntriesRaw.filter((e) => {
|
||||
const n = (perBucket.get(e.bucketId) ?? 0) + 1;
|
||||
perBucket.set(e.bucketId, n);
|
||||
return n <= 40;
|
||||
});
|
||||
return overlayTemplate(groups, template, buckets, subLeaves, templateEntries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sub-categories (sub-groups) for a category.
|
||||
* Fetches from PL24 on-demand if not cached.
|
||||
@@ -655,17 +897,13 @@ export class CategoriesService {
|
||||
// subgroups. Drilling into them used to insert per-part endpoints as
|
||||
// ghost child categories — keep the regex wide so any /extern/{kind}/
|
||||
// (vin|mdl)_items endpoint is recognised, not just /servicepart/.
|
||||
const lp = linkPath.toLowerCase();
|
||||
// One shared classifier (integrations/pl24/pl24-tree) — the inline lists here,
|
||||
// in catalog.service and in the prefetch worker used to disagree, which is how
|
||||
// p5psa/p5volvo camelCase `bomDetails` leaves and `illusTable` group levels
|
||||
// ended up on the wrong side (silent empty panels / unfetched parts).
|
||||
if (
|
||||
lp.includes("/bom/") ||
|
||||
lp.includes("/bomdetails") ||
|
||||
lp.includes("/partinfo/") ||
|
||||
/\/extern\/[^/]+\/(vin_items|mdl_items)\b/.test(lp) ||
|
||||
// PSA / Hyundai / Opel / Volvo image-board pages and the Ford VIN
|
||||
// vin-image-board.action equivalent. Drilling into them yields BOM rows,
|
||||
// not sub-groups — let getCategoryWithParts handle those as parts.
|
||||
lp.includes("image-board.action") ||
|
||||
lp.includes("json-vin-bom-detail.action")
|
||||
isPl24LeafNode({ linkPath }) ||
|
||||
linkPath.toLowerCase().includes("json-vin-bom-detail.action")
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
@@ -1075,9 +1313,13 @@ export class CategoriesService {
|
||||
// AND lowercase (groupReferenceTable, groupTable, groupsTable). The old
|
||||
// case-sensitive includes("Group") missed the lowercase ones (~1157 nodes),
|
||||
// so they skipped this group-drill branch and fell to the parts path.
|
||||
// A PL24 node is a parent when the shared classifier says it is not a parts
|
||||
// leaf. The old `linkWid.includes("group")` test missed p5psa `illusTable`
|
||||
// and p5volvo/p5subaru `illustrationsTable`, so those levels were fetched as
|
||||
// parts, parsed to zero rows and rendered as an empty panel with no error.
|
||||
if (
|
||||
category.source === "pl24" &&
|
||||
category.linkWid?.toLowerCase().includes("group") &&
|
||||
isPl24GroupNode({ linkPath: category.linkPath, linkWid: category.linkWid }) &&
|
||||
category.vehicleId
|
||||
) {
|
||||
const groupChildren = await this.getChildren(categoryId);
|
||||
@@ -1123,6 +1365,39 @@ export class CategoriesService {
|
||||
// No subgroups → leaf; fall through to the parts path below.
|
||||
}
|
||||
|
||||
// PL24 P4 VIN / legacy group node whose link_wid is a positional code
|
||||
// ("1","2","CAP1"…) rather than a *Group* table, so the link_wid branch above
|
||||
// misses it, and which is neither PSA- nor Volvo-special. Its linkPath is a
|
||||
// sub-group / main-group drill action (…group.action — never a parts leaf like
|
||||
// image-board.action). This is the Ford / Nissan / Opel / Hyundai-Kia analog of
|
||||
// the PSA-parent-gate gap: without it these VIN scopes ("mekanik", "kaporta",
|
||||
// "Elektrikli aksam"…) fell through every parent branch to the leaf parts path,
|
||||
// fetched no parts and rendered a silent empty panel — confirmed the dominant
|
||||
// current "0 parça" cluster. Volvo's vin-group.action keeps its own branch above
|
||||
// (it may legitimately bottom out in parts), so exclude it here. Drill on demand:
|
||||
// subgroups → parent; an empty drill is a transient upstream failure → retryable
|
||||
// load error (a group node is never legitimately a parts leaf).
|
||||
if (
|
||||
category.source === "pl24" &&
|
||||
category.linkPath?.includes("group.action") &&
|
||||
!category.linkPath.includes("vin-group.action") &&
|
||||
category.vehicleId
|
||||
) {
|
||||
const p4Children = await this.getChildren(categoryId);
|
||||
const base = {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
description: category.nameOriginal || null,
|
||||
parentId: category.parentId || null,
|
||||
parts: [],
|
||||
schemaPics: [],
|
||||
hotspots: [],
|
||||
};
|
||||
return p4Children.length > 0
|
||||
? { ...base, children: p4Children }
|
||||
: { ...base, loadError: true };
|
||||
}
|
||||
|
||||
// EMEX Vehicle.aspx group node (linkWid="emex-group") — a parent, never a
|
||||
// parts leaf. Return its children (seeded sub-groups, or Unit leaves drilled
|
||||
// on demand by getChildren). Mirrors the pl24 group-node branch above; an
|
||||
@@ -1899,15 +2174,8 @@ export class CategoriesService {
|
||||
return !!c.linkPath?.startsWith("pcat:"); // unknown → lazy-leaf heuristic
|
||||
})()
|
||||
: (() => {
|
||||
const lp = c.linkPath?.toLowerCase() ?? "";
|
||||
return (
|
||||
lp.includes("/bom/") ||
|
||||
lp.includes("/bomdetails") ||
|
||||
lp.includes("/partinfo/") ||
|
||||
/\/extern\/[^/]+\/(vin_items|mdl_items)\b/.test(lp) ||
|
||||
lp.includes("image-board.action") ||
|
||||
(!c.linkPath && dbChildCount === 0)
|
||||
);
|
||||
if (!c.linkPath) return dbChildCount === 0;
|
||||
return isPl24LeafNode({ linkPath: c.linkPath, linkWid: c.linkWid });
|
||||
})();
|
||||
return {
|
||||
...c,
|
||||
|
||||
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 {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
@Injectable()
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,14 @@ export default () => ({
|
||||
enabled: process.env.P_DB_ENABLED === "true",
|
||||
url: process.env.P_DB_URL,
|
||||
},
|
||||
supplierPrices: {
|
||||
// Tedarikçi fiyat geçmişi (takip MySQL, Tailscale). Kapalı/boş URL →
|
||||
// part-prices uçları { matched: false } / boş batch döner; UI fiyat
|
||||
// bölümünü hiç render etmez. Tedarikçi kimliği API cevabına asla çıkmaz —
|
||||
// yalnızca stoktaki tekliflerin p50/p95/p99 dağılımı servis edilir.
|
||||
enabled: process.env.SUPPLIER_PRICE_DB_ENABLED === "true",
|
||||
url: process.env.SUPPLIER_PRICE_DB_URL,
|
||||
},
|
||||
otel: {
|
||||
enabled: process.env.OTEL_ENABLED === "true",
|
||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
boolean,
|
||||
date,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
numeric,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
@@ -37,6 +39,10 @@ export const users = pgTable(
|
||||
// subscription yet (referrer had no active/trial sub at grant time).
|
||||
// Consumed when the user next starts a trial or activates a subscription.
|
||||
referralCreditDays: integer("referral_credit_days").default(0).notNull(),
|
||||
// Stripe Customer backing this user's recurring billing. Set on the first
|
||||
// completed checkout and reused on later checkouts so saved cards and
|
||||
// invoices stay on one customer record.
|
||||
stripeCustomerId: text("stripe_customer_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
@@ -110,6 +116,46 @@ export const verifications = pgTable(
|
||||
(table) => [index("verifications_identifier_idx").on(table.identifier)],
|
||||
);
|
||||
|
||||
// ─── Better Auth: API Keys (public API) ──────────────
|
||||
// Backing table for the better-auth `apiKey` plugin (model "apikey", plural).
|
||||
// Column set mirrors the plugin's schema exactly — the drizzle adapter matches
|
||||
// by property name, so renaming a property here breaks key verification.
|
||||
// `key` holds the SHA256 hash of the secret; the plaintext is shown once at
|
||||
// creation. `metadata` is a JSON string (enableMetadata) carrying
|
||||
// per-key overrides such as dailyDecodeLimit.
|
||||
export const apikeys = pgTable(
|
||||
"apikeys",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name"),
|
||||
start: text("start"),
|
||||
prefix: text("prefix"),
|
||||
key: text("key").notNull(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
refillInterval: integer("refill_interval"),
|
||||
refillAmount: integer("refill_amount"),
|
||||
lastRefillAt: timestamp("last_refill_at", { withTimezone: true }),
|
||||
enabled: boolean("enabled").default(true).notNull(),
|
||||
rateLimitEnabled: boolean("rate_limit_enabled").default(true).notNull(),
|
||||
rateLimitTimeWindow: integer("rate_limit_time_window"),
|
||||
rateLimitMax: integer("rate_limit_max"),
|
||||
requestCount: integer("request_count").default(0).notNull(),
|
||||
remaining: integer("remaining"),
|
||||
lastRequest: timestamp("last_request", { withTimezone: true }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
permissions: text("permissions"),
|
||||
metadata: text("metadata"),
|
||||
},
|
||||
(table) => [
|
||||
index("apikeys_key_idx").on(table.key),
|
||||
index("apikeys_user_id_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Brands ─────────────────────────────────────────
|
||||
export const brands = pgTable(
|
||||
"brands",
|
||||
@@ -153,12 +199,23 @@ export const userSubscriptions = pgTable(
|
||||
startDate: timestamp("start_date", { withTimezone: true }),
|
||||
endDate: timestamp("end_date", { withTimezone: true }),
|
||||
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
|
||||
// Stripe Subscription id for recurring (mode:"subscription") billing.
|
||||
// NULL for trials and legacy one-time purchases. Renewal invoices resolve
|
||||
// our row through this.
|
||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||
// Dunning state — set on the first failed renewal charge, cleared when the
|
||||
// invoice recovers (invoice.paid) or the sub is finally cancelled. The URL
|
||||
// is Stripe's hosted invoice page: the ONLY self-serve surface where the
|
||||
// user can pay the open invoice / enter a new card / pass 3DS.
|
||||
dunningSince: timestamp("dunning_since", { withTimezone: true }),
|
||||
dunningInvoiceUrl: text("dunning_invoice_url"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("user_subscriptions_user_id_idx").on(table.userId),
|
||||
index("user_subscriptions_status_idx").on(table.status),
|
||||
index("user_subscriptions_stripe_sub_idx").on(table.stripeSubscriptionId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -227,6 +284,9 @@ export const payments = pgTable(
|
||||
iyzicoPaymentId: text("iyzico_payment_id"),
|
||||
stripeSessionId: text("stripe_session_id"),
|
||||
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
||||
// Stripe Invoice behind a recurring charge (renewals). Dedupe key against
|
||||
// invoice.paid webhook retries.
|
||||
stripeInvoiceId: text("stripe_invoice_id"),
|
||||
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
|
||||
bankAccountId: uuid("bank_account_id").references(() => bankAccounts.id),
|
||||
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
|
||||
@@ -239,6 +299,7 @@ export const payments = pgTable(
|
||||
index("payments_user_id_idx").on(table.userId),
|
||||
index("payments_status_idx").on(table.status),
|
||||
index("payments_stripe_session_id_idx").on(table.stripeSessionId),
|
||||
index("payments_stripe_invoice_id_idx").on(table.stripeInvoiceId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -322,6 +383,32 @@ export const catalogVehicles = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Vinpin decodes (ePER decode-oracle cache — one row per VIN) ──
|
||||
// Feature-flagged (VINPIN_ENABLED). Populated by the vinpin-decode BullMQ job
|
||||
// which drives the Vinpin ePER web catalog for Fiat VINs the normal decode chain
|
||||
// (PL24/pcat/emex) can't identify. On success the decoded model is matched to an
|
||||
// EXISTING PL24 catalog_vehicle (catalog_vehicle_id) and parts are served from
|
||||
// there — Vinpin is ONLY a decode oracle, never a parts source.
|
||||
export const vinpinDecodes = pgTable("vinpin_decodes", {
|
||||
vin: text("vin").primaryKey(),
|
||||
// 'pending' | 'decoded' | 'not_found' | 'failed'
|
||||
status: text("status").notNull().default("pending"),
|
||||
brandName: text("brand_name"),
|
||||
model: text("model"),
|
||||
sincom: text("sincom"),
|
||||
trim: text("trim"),
|
||||
modelYear: text("model_year"),
|
||||
engine: text("engine"),
|
||||
catalogVehicleId: uuid("catalog_vehicle_id").references(() => catalogVehicles.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
raw: jsonb("raw"),
|
||||
attempts: integer("attempts").notNull().default(0),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
decodedAt: timestamp("decoded_at", { withTimezone: true }),
|
||||
});
|
||||
|
||||
// ─── Vehicles (shared config — one record per VIN) ──
|
||||
export const vehicles = pgTable(
|
||||
"vehicles",
|
||||
@@ -338,10 +425,20 @@ export const vehicles = pgTable(
|
||||
market: varchar("market", { length: 100 }),
|
||||
rawData: jsonb("raw_data"),
|
||||
source: varchar("source", { length: 20 }).default("pl24").notNull(),
|
||||
// Durable completeness marker: set true when the prefetch chain finishes with
|
||||
// parts (see PrefetchWorkerService.incrementCompleted). Unlike the ephemeral
|
||||
// Redis `prefetch:complete:*` marker (21-day TTL, operational skip logic) this
|
||||
// never expires — it's the reliable "% fully fetched" measurement. `_at` tracks
|
||||
// the last time completion was confirmed (re-set on each re-drill completion).
|
||||
fullyFetched: boolean("fully_fetched").default(false).notNull(),
|
||||
fullyFetchedAt: timestamp("fully_fetched_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex("vehicles_vin_unique_idx").on(table.vin)],
|
||||
(table) => [
|
||||
uniqueIndex("vehicles_vin_unique_idx").on(table.vin),
|
||||
index("vehicles_fully_fetched_idx").on(table.fullyFetched),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── User Vehicles (junction — user ↔ shared vehicle) ─
|
||||
@@ -390,12 +487,27 @@ export const categories = pgTable(
|
||||
hasParts: boolean("has_parts"),
|
||||
unavailable: boolean("unavailable").default(false).notNull(),
|
||||
source: varchar("source", { length: 20 }).default("pl24").notNull(),
|
||||
// Materialized result of the name-level canonical mapping (see
|
||||
// category_canonical_map). Nullable: null = not yet mapped / unmappable.
|
||||
// Lets callers filter/browse this raw node under the unified taxonomy
|
||||
// without walking the map table per row.
|
||||
canonicalCategoryId: uuid("canonical_category_id"),
|
||||
// Second canonical level: a canonical_categories row whose parentId ==
|
||||
// canonicalCategoryId (e.g. Fren → Balata). Nullable: null = mapped to the
|
||||
// top bucket only / no sub-leaf matched → SP manual review completes these.
|
||||
canonicalSubcategoryId: uuid("canonical_subcategory_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("categories_vehicle_id_idx").on(table.vehicleId),
|
||||
index("categories_catalog_vehicle_id_idx").on(table.catalogVehicleId),
|
||||
index("categories_parent_id_idx").on(table.parentId),
|
||||
index("categories_canonical_category_id_idx").on(table.canonicalCategoryId),
|
||||
index("categories_canonical_subcategory_id_idx").on(table.canonicalSubcategoryId),
|
||||
// NULLS NOT DISTINCT since migration 0030 (raw SQL — drizzle's uniqueIndex
|
||||
// can't express it): emex/pcat rows have NULL catalog_vehicle_id (pl24 rows
|
||||
// NULL vehicle_id), and with default NULLs-distinct semantics decode retries
|
||||
// slipped past onConflictDoNothing and duplicated whole category trees.
|
||||
uniqueIndex("categories_vehicle_name_source_idx").on(
|
||||
table.vehicleId,
|
||||
table.catalogVehicleId,
|
||||
@@ -655,3 +767,301 @@ export const proxyLogs = pgTable(
|
||||
index("proxy_logs_banned_created_at_idx").on(table.banned, table.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── OEM Votes (community compatibility verdicts) ─────
|
||||
// One row per (user, oemCode): a parts seller's uyumlu/uyumsuz verdict on an
|
||||
// OEM code, cast from the catalog part rows. Votes pool globally per code —
|
||||
// the part/vehicle/category columns are analytics breadcrumbs only (mirrors
|
||||
// oem_code_copies: catalog routes pass catalog_vehicles ids here, so no FK).
|
||||
// Re-voting updates `vote` in place; points are only ever granted on the
|
||||
// first insert (see oem_vote_points).
|
||||
export const oemVotes = pgTable(
|
||||
"oem_votes",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
oemCode: varchar("oem_code", { length: 100 }).notNull(),
|
||||
// compatible | incompatible
|
||||
vote: varchar("vote", { length: 12 }).notNull(),
|
||||
partId: uuid("part_id"),
|
||||
vehicleId: uuid("vehicle_id"),
|
||||
categoryId: uuid("category_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("oem_votes_user_oem_idx").on(table.userId, table.oemCode),
|
||||
index("oem_votes_oem_code_idx").on(table.oemCode),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── OEM Vote Points (gamification ledger) ────────────
|
||||
// Append-only: exactly one row per oem_votes row (vote_id unique), written in
|
||||
// the same transaction as the vote insert. `points` = 1 for voting +2 when
|
||||
// the vote agreed with the strict majority at cast time (first voter on a
|
||||
// code always agrees with themselves → 3). Awards are never retro-adjusted
|
||||
// when the majority later flips. Leaderboard = SUM(points) per user.
|
||||
export const oemVotePoints = pgTable(
|
||||
"oem_vote_points",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
voteId: uuid("vote_id")
|
||||
.notNull()
|
||||
.references(() => oemVotes.id, { onDelete: "cascade" }),
|
||||
oemCode: varchar("oem_code", { length: 100 }).notNull(),
|
||||
points: integer("points").notNull(),
|
||||
correct: boolean("correct").default(false).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("oem_vote_points_vote_id_idx").on(table.voteId),
|
||||
index("oem_vote_points_user_id_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── OEM Suggestions (community cross-references) ─────
|
||||
// Crowd-sourced equivalents for an OEM code: "this code's compatible part is
|
||||
// <brand> <suggestedCode>", collected on the OEM detail page — primarily for
|
||||
// codes the P snapshot can't match. suggestedCodeNorm (upper, alphanumerics
|
||||
// only) powers grouping and the per-user dedup constraint; display keeps the
|
||||
// submitted casing. `status` is a moderation hook (active|hidden) — no rows
|
||||
// are hidden automatically today.
|
||||
export const oemSuggestions = pgTable(
|
||||
"oem_suggestions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
oemCode: varchar("oem_code", { length: 100 }).notNull(),
|
||||
brand: varchar("brand", { length: 80 }).notNull(),
|
||||
suggestedCode: varchar("suggested_code", { length: 100 }).notNull(),
|
||||
suggestedCodeNorm: varchar("suggested_code_norm", { length: 100 }).notNull(),
|
||||
status: varchar("status", { length: 16 }).default("active").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("oem_suggestions_user_oem_code_idx").on(
|
||||
table.userId,
|
||||
table.oemCode,
|
||||
table.suggestedCodeNorm,
|
||||
),
|
||||
index("oem_suggestions_oem_code_idx").on(table.oemCode),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── OEM Expert Rewards (monthly leaderboard prizes) ──
|
||||
// One row per (TR-month, rank): the expert-rewards cron (1st of month 00:00
|
||||
// Europe/Istanbul) closes the finished season, e-mails the top 3 voters to
|
||||
// admin@sase.tr (name/e-mail/points + prize 30/15/7 days — EXPERT_REWARD_
|
||||
// LADDER) and records them here. Subscription days are NOT granted
|
||||
// automatically — the admin applies them manually from the mail. The unique
|
||||
// index doubles as the run-once guard: a second run for the same period
|
||||
// inserts nothing and sends no second mail.
|
||||
export const oemExpertRewards = pgTable(
|
||||
"oem_expert_rewards",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
// TR-ayının başlangıcı (UTC timestamptz) — ödüllendirilen sezon.
|
||||
periodStart: timestamp("period_start", { withTimezone: true }).notNull(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
rank: integer("rank").notNull(),
|
||||
points: integer("points").notNull(),
|
||||
rewardDays: integer("reward_days").notNull(),
|
||||
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("oem_expert_rewards_period_rank_idx").on(table.periodStart, table.rank),
|
||||
index("oem_expert_rewards_user_id_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Part Price History (tedarikçi fiyat verisi) ──────
|
||||
// Parça bazlı piyasa fiyat geçmişi; kaynak takip MySQL'i (Tailscale). Kimlik
|
||||
// (kod, marka): kısa sayısal kodlar markalar arası çakışır (FEBI 27155 ≠
|
||||
// GROS 27155) — marka olmadan percentile anlamsızlaşır; markasız ('' brand)
|
||||
// izleme yalnızca uzun/benzersiz kodlar içindir (OEM detayın ana kodu).
|
||||
// tracks = izlenen parçalar: ilk seriyi API lazy-backfill yazar, günlük cron
|
||||
// yalnızca buradakilere bugünün satırını ekler. daily = (kod, marka, kaynak,
|
||||
// gün) başına stoktaki tekliflerin p50/p95/p99 + teklif sayısı; sıfır-teklif
|
||||
// günleri null fiyat + offerCount 0 ile saklanır. source şimdilik hep
|
||||
// 'supplier'; perakende geldiğinde aynı tabloya 'retail' girer. Tedarikçi
|
||||
// kimliği bilinçli olarak hiçbir yerde tutulmaz.
|
||||
export const partPriceTracks = pgTable(
|
||||
"part_price_tracks",
|
||||
{
|
||||
codeNorm: varchar("code_norm", { length: 64 }).notNull(),
|
||||
brandNorm: varchar("brand_norm", { length: 64 }).default("").notNull(),
|
||||
firstSeenAt: timestamp("first_seen_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
backfilledAt: timestamp("backfilled_at", { withTimezone: true }),
|
||||
lastRefreshedAt: timestamp("last_refreshed_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.codeNorm, table.brandNorm] })],
|
||||
);
|
||||
|
||||
export const partPriceDaily = pgTable(
|
||||
"part_price_daily",
|
||||
{
|
||||
codeNorm: varchar("code_norm", { length: 64 }).notNull(),
|
||||
brandNorm: varchar("brand_norm", { length: 64 }).default("").notNull(),
|
||||
source: varchar("source", { length: 16 }).default("supplier").notNull(),
|
||||
date: date("date").notNull(),
|
||||
p50: numeric("p50", { precision: 15, scale: 4 }),
|
||||
p95: numeric("p95", { precision: 15, scale: 4 }),
|
||||
p99: numeric("p99", { precision: 15, scale: 4 }),
|
||||
offerCount: integer("offer_count").default(0).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.codeNorm, table.brandNorm, table.source, table.date] }),
|
||||
index("part_price_daily_code_date_idx").on(table.codeNorm, table.brandNorm, table.date),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Discount Codes (Stripe-backed checkout promotions) ───────────────────
|
||||
// Admin-generated promo codes. Each row MIRRORS a Stripe Coupon + Promotion
|
||||
// Code pair: Stripe is the source of truth for redemption (it enforces the cap
|
||||
// + expiry at checkout and owns the canonical times_redeemed), while this row
|
||||
// lets the admin panel list/track codes and lets us preview/validate a code at
|
||||
// checkout without a per-render Stripe call. Amounts are in kuruş (TRY smallest
|
||||
// unit), matching plans.price* / payments.amount.
|
||||
export const discountCodes = pgTable(
|
||||
"discount_codes",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
// Customer-facing code, stored UPPERCASE; matched case-insensitively.
|
||||
code: varchar("code", { length: 50 }).notNull(),
|
||||
// Stripe linkage — both set together at creation. Checkout attaches the
|
||||
// promotion code id; deactivation archives it on Stripe too.
|
||||
stripeCouponId: text("stripe_coupon_id"),
|
||||
stripePromotionCodeId: text("stripe_promotion_code_id"),
|
||||
// 'percentage' (percentOff 1..100) | 'fixed' (amountOff in kuruş, TRY).
|
||||
discountType: varchar("discount_type", { length: 12 }).notNull(),
|
||||
percentOff: integer("percent_off"),
|
||||
amountOff: integer("amount_off"),
|
||||
// How long the discount rides a subscription, mirroring Stripe Coupon.duration:
|
||||
// 'once' (first invoice only), 'repeating' (durationInMonths invoices),
|
||||
// 'forever' (every renewal).
|
||||
duration: varchar("duration", { length: 12 }).default("once").notNull(),
|
||||
durationInMonths: integer("duration_in_months"),
|
||||
// Total redemption cap across all users (null = unlimited). Stripe enforces.
|
||||
maxRedemptions: integer("max_redemptions"),
|
||||
// Mirror of Stripe's times_redeemed; bumped from the checkout webhook and
|
||||
// reconcilable from Stripe.
|
||||
timesRedeemed: integer("times_redeemed").default(0).notNull(),
|
||||
// Code stops working after this instant (null = no expiry). Stripe enforces.
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
// Admin soft-toggle. Deactivating also archives the Stripe promotion code.
|
||||
isActive: boolean("is_active").default(true).notNull(),
|
||||
// Free-text admin note (campaign name, who it's for…).
|
||||
note: text("note"),
|
||||
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("discount_codes_code_idx").on(table.code),
|
||||
index("discount_codes_active_idx").on(table.isActive),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Canonical Categories (unified cross-source taxonomy) ──────────────────
|
||||
// Single manufacturer-independent parts taxonomy that every source's OEM
|
||||
// microfiche tree maps INTO. The raw per-source trees (`categories.parent_id`
|
||||
// chains, pl24/emex/pcat mirrors) stay untouched; this is an orthogonal spine
|
||||
// so VW, Opel, Ford etc. can be browsed/searched under one set of headings.
|
||||
// Seeded from the Turkish retail taxonomy (aloparca 17 tops) extended with
|
||||
// OEM-only tops the retail sites don't sell (interior trim/koltuk, body).
|
||||
// Two levels: parentId NULL = top bucket, else a sub-category under a top.
|
||||
export const canonicalCategories = pgTable(
|
||||
"canonical_categories",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
parentId: uuid("parent_id"),
|
||||
slug: varchar("slug", { length: 100 }).notNull(),
|
||||
name: varchar("name", { length: 200 }).notNull(),
|
||||
sortOrder: integer("sort_order").default(0).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex("canonical_categories_slug_idx").on(table.slug)],
|
||||
);
|
||||
|
||||
// ─── Category → Canonical map (NAME-level, not row-level) ──────────────────
|
||||
// Mapping key is (source, normalized_name), NOT category.id — because the same
|
||||
// category name repeats across thousands of vehicles (704k rows collapse to
|
||||
// ~111k distinct names), so mapping at the name level is ~6× cheaper and a
|
||||
// single human decision fans out to every matching row. `method` records how
|
||||
// the row was produced (keyword | fuzzy | embedding | manual) and `confidence`
|
||||
// gates auto-apply vs review queue; `isManual` protects human-approved rows
|
||||
// from being overwritten by re-runs (same pattern as emex_category_translations).
|
||||
export const categoryCanonicalMap = pgTable(
|
||||
"category_canonical_map",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
source: varchar("source", { length: 20 }).notNull(),
|
||||
normalizedName: varchar("normalized_name", { length: 500 }).notNull(),
|
||||
canonicalCategoryId: uuid("canonical_category_id")
|
||||
.notNull()
|
||||
.references(() => canonicalCategories.id, { onDelete: "cascade" }),
|
||||
// Second level materialized alongside the top bucket (name→bucket→sub is
|
||||
// deterministic). Nullable: no sub-leaf matched → served at top level.
|
||||
canonicalSubcategoryId: uuid("canonical_subcategory_id"),
|
||||
method: varchar("method", { length: 20 }).default("keyword").notNull(),
|
||||
confidence: numeric("confidence", { precision: 4, scale: 3 }),
|
||||
isManual: boolean("is_manual").default(false).notNull(),
|
||||
ambiguous: boolean("ambiguous").default(false).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("category_canonical_map_source_name_idx").on(table.source, table.normalizedName),
|
||||
index("category_canonical_map_canonical_idx").on(table.canonicalCategoryId),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Canonical structure template (per source+brand) ──────────────────────
|
||||
// The canonical STRUCTURE (which buckets/sub-leaves a platform has) is shared
|
||||
// across vehicles of the same brand — only the parts differ. This table records,
|
||||
// per (source, brand), each (bucket, sub-leaf) seen across already-drilled
|
||||
// vehicles with a vehicle_count (support). On decode the serving overlays the
|
||||
// brand template so the user sees the FULL canonical structure instantly, before
|
||||
// this vehicle's own tree is deep-drilled by the prefetch backfill. Rebuilt at
|
||||
// the end of each global canonical-backfill run (derived, so full-refresh).
|
||||
// Entry-level companion of canonicalStructureTemplate: the browsable source-
|
||||
// category NAMES every vehicle of (source, brand) shares under a bucket/sub.
|
||||
// A freshly-decoded vehicle renders these immediately (placeholder until its
|
||||
// own row exists) — only parts/schemas are vehicle-specific.
|
||||
export const canonicalTemplateEntries = pgTable(
|
||||
"canonical_template_entries",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
source: varchar("source", { length: 20 }).notNull(),
|
||||
brand: varchar("brand", { length: 120 }).notNull(),
|
||||
canonicalCategoryId: uuid("canonical_category_id").notNull(),
|
||||
canonicalSubcategoryId: uuid("canonical_subcategory_id"),
|
||||
name: varchar("name", { length: 500 }).notNull(),
|
||||
vehicleCount: integer("vehicle_count").default(0).notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index("canonical_template_entries_sb_idx").on(table.source, table.brand)],
|
||||
);
|
||||
|
||||
export const canonicalStructureTemplate = pgTable(
|
||||
"canonical_structure_template",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
source: varchar("source", { length: 20 }).notNull(),
|
||||
brand: varchar("brand", { length: 120 }).notNull(),
|
||||
canonicalCategoryId: uuid("canonical_category_id").notNull(),
|
||||
canonicalSubcategoryId: uuid("canonical_subcategory_id"),
|
||||
vehicleCount: integer("vehicle_count").default(0).notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index("canonical_structure_template_source_brand_idx").on(table.source, table.brand)],
|
||||
);
|
||||
|
||||
58
apps/api/src/discount-codes/discount-codes.controller.ts
Normal file
58
apps/api/src/discount-codes/discount-codes.controller.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { BadRequestException, Body, Controller, Get, Param, Patch, Post } from "@nestjs/common";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { Roles } from "../common/decorators/roles.decorator";
|
||||
import { type CreateDiscountInput, DiscountCodesService } from "./discount-codes.service";
|
||||
|
||||
interface AuthedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Controller("discount-codes")
|
||||
export class DiscountCodesController {
|
||||
constructor(private discountCodes: DiscountCodesService) {}
|
||||
|
||||
// ── Any logged-in user: validate + price a code on the checkout page ──
|
||||
// No @Roles → only the global AuthGuard applies (must be signed in).
|
||||
@Post("validate")
|
||||
async validate(
|
||||
@Body() body: { code: string; planKey: string; billingPeriod: "monthly" | "yearly" },
|
||||
) {
|
||||
if (!body?.code || !body?.planKey || !body?.billingPeriod) {
|
||||
throw new BadRequestException("code, planKey ve billingPeriod zorunlu");
|
||||
}
|
||||
return this.discountCodes.preview(body.code, body.planKey, body.billingPeriod);
|
||||
}
|
||||
|
||||
// ── Admin-only management ──
|
||||
@Roles("admin")
|
||||
@Get()
|
||||
async list() {
|
||||
return this.discountCodes.list();
|
||||
}
|
||||
|
||||
@Roles("admin")
|
||||
@Get(":id")
|
||||
async getOne(@Param("id") id: string) {
|
||||
return this.discountCodes.getOne(id);
|
||||
}
|
||||
|
||||
@Roles("admin")
|
||||
@Post()
|
||||
async create(@CurrentUser() user: AuthedUser, @Body() body: CreateDiscountInput) {
|
||||
return this.discountCodes.create(body, user.id);
|
||||
}
|
||||
|
||||
@Roles("admin")
|
||||
@Patch(":id/deactivate")
|
||||
async deactivate(@Param("id") id: string) {
|
||||
return this.discountCodes.deactivate(id);
|
||||
}
|
||||
|
||||
@Roles("admin")
|
||||
@Post("sync")
|
||||
async sync() {
|
||||
return this.discountCodes.syncRedemptions();
|
||||
}
|
||||
}
|
||||
14
apps/api/src/discount-codes/discount-codes.module.ts
Normal file
14
apps/api/src/discount-codes/discount-codes.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { StripeModule } from "../payments/stripe/stripe.module";
|
||||
import { DiscountCodesController } from "./discount-codes.controller";
|
||||
import { DiscountCodesService } from "./discount-codes.service";
|
||||
|
||||
// One-way dependency on StripeModule (StripeService creates the Stripe
|
||||
// Coupon/Promotion objects + owns checkout-time preview). StripeModule does NOT
|
||||
// import us back, so there is no cycle.
|
||||
@Module({
|
||||
imports: [StripeModule],
|
||||
controllers: [DiscountCodesController],
|
||||
providers: [DiscountCodesService],
|
||||
})
|
||||
export class DiscountCodesModule {}
|
||||
237
apps/api/src/discount-codes/discount-codes.service.ts
Normal file
237
apps/api/src/discount-codes/discount-codes.service.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { discountCodes } from "../database/schema/core";
|
||||
import { StripeService } from "../payments/stripe/stripe.service";
|
||||
|
||||
export type DiscountType = "percentage" | "fixed";
|
||||
export type DiscountDuration = "once" | "repeating" | "forever";
|
||||
|
||||
export interface CreateDiscountInput {
|
||||
/** Optional custom code; auto-generated when omitted. */
|
||||
code?: string;
|
||||
discountType: DiscountType;
|
||||
/** 1..100, required for `percentage`. */
|
||||
percentOff?: number;
|
||||
/** kuruş (TRY smallest unit), required for `fixed`. */
|
||||
amountOff?: number;
|
||||
duration?: DiscountDuration;
|
||||
/** required when duration === "repeating" */
|
||||
durationInMonths?: number;
|
||||
/** total redemption cap (null/omitted = unlimited) */
|
||||
maxRedemptions?: number;
|
||||
/** ISO date string (null/omitted = no expiry) */
|
||||
expiresAt?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
type DiscountRow = typeof discountCodes.$inferSelect;
|
||||
|
||||
// 3..50 chars, starts alphanumeric, then alphanumeric / dash / underscore.
|
||||
const CODE_RE = /^[A-Z0-9][A-Z0-9_-]{2,49}$/;
|
||||
// Crockford-ish alphabet — no 0/O/1/I confusion in shared codes.
|
||||
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
@Injectable()
|
||||
export class DiscountCodesService {
|
||||
private readonly logger = new Logger(DiscountCodesService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private stripe: StripeService,
|
||||
) {}
|
||||
|
||||
private generateCode(): string {
|
||||
let s = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
s += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)];
|
||||
}
|
||||
return `SASE${s}`;
|
||||
}
|
||||
|
||||
private computeStatus(row: DiscountRow): "active" | "inactive" | "expired" | "exhausted" {
|
||||
if (!row.isActive) return "inactive";
|
||||
if (row.expiresAt && row.expiresAt.getTime() < Date.now()) return "expired";
|
||||
if (row.maxRedemptions != null && row.timesRedeemed >= row.maxRedemptions) return "exhausted";
|
||||
return "active";
|
||||
}
|
||||
|
||||
async create(input: CreateDiscountInput, createdBy: string | null) {
|
||||
const discountType = input.discountType;
|
||||
if (discountType !== "percentage" && discountType !== "fixed") {
|
||||
throw new BadRequestException("discountType 'percentage' veya 'fixed' olmalı");
|
||||
}
|
||||
|
||||
let percentOff: number | null = null;
|
||||
let amountOff: number | null = null;
|
||||
if (discountType === "percentage") {
|
||||
percentOff = Number(input.percentOff);
|
||||
if (!Number.isInteger(percentOff) || percentOff < 1 || percentOff > 100) {
|
||||
throw new BadRequestException("Yüzde indirim 1-100 arası tam sayı olmalı");
|
||||
}
|
||||
} else {
|
||||
amountOff = Number(input.amountOff);
|
||||
if (!Number.isInteger(amountOff) || amountOff < 1) {
|
||||
throw new BadRequestException("Tutar indirimi (kuruş) 1'den büyük tam sayı olmalı");
|
||||
}
|
||||
}
|
||||
|
||||
const duration: DiscountDuration = input.duration ?? "once";
|
||||
if (duration !== "once" && duration !== "repeating" && duration !== "forever") {
|
||||
throw new BadRequestException("Geçersiz süre türü");
|
||||
}
|
||||
let durationInMonths: number | null = null;
|
||||
if (duration === "repeating") {
|
||||
durationInMonths = Number(input.durationInMonths);
|
||||
if (!Number.isInteger(durationInMonths) || durationInMonths < 1 || durationInMonths > 36) {
|
||||
throw new BadRequestException("Tekrar eden indirimde ay sayısı 1-36 arası olmalı");
|
||||
}
|
||||
}
|
||||
|
||||
let maxRedemptions: number | null = null;
|
||||
if (
|
||||
input.maxRedemptions !== undefined &&
|
||||
input.maxRedemptions !== null &&
|
||||
String(input.maxRedemptions) !== ""
|
||||
) {
|
||||
maxRedemptions = Number(input.maxRedemptions);
|
||||
if (!Number.isInteger(maxRedemptions) || maxRedemptions < 1) {
|
||||
throw new BadRequestException("Kullanım limiti 1'den büyük tam sayı olmalı");
|
||||
}
|
||||
}
|
||||
|
||||
let expiresAt: Date | null = null;
|
||||
if (input.expiresAt) {
|
||||
const d = new Date(input.expiresAt);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
throw new BadRequestException("Geçersiz son kullanma tarihi");
|
||||
}
|
||||
if (d.getTime() < Date.now()) {
|
||||
throw new BadRequestException("Son kullanma tarihi gelecekte olmalı");
|
||||
}
|
||||
expiresAt = d;
|
||||
}
|
||||
|
||||
let code = (input.code ?? "").trim().toUpperCase();
|
||||
if (code) {
|
||||
if (!CODE_RE.test(code)) {
|
||||
throw new BadRequestException("Kod 3-50 karakter olmalı; harf, rakam, - ve _ içerebilir");
|
||||
}
|
||||
} else {
|
||||
code = this.generateCode();
|
||||
}
|
||||
|
||||
// Local uniqueness check BEFORE creating Stripe objects, so a duplicate code
|
||||
// doesn't leave an orphan Stripe coupon behind a failing row insert.
|
||||
const [existing] = await this.db
|
||||
.select({ id: discountCodes.id })
|
||||
.from(discountCodes)
|
||||
.where(eq(discountCodes.code, code))
|
||||
.limit(1);
|
||||
if (existing) throw new BadRequestException(`'${code}' kodu zaten mevcut`);
|
||||
|
||||
let couponId: string;
|
||||
let promotionCodeId: string;
|
||||
try {
|
||||
const created = await this.stripe.createPromotion({
|
||||
code,
|
||||
discountType,
|
||||
percentOff: percentOff ?? undefined,
|
||||
amountOff: amountOff ?? undefined,
|
||||
duration,
|
||||
durationInMonths: durationInMonths ?? undefined,
|
||||
maxRedemptions: maxRedemptions ?? undefined,
|
||||
expiresAt: expiresAt ?? undefined,
|
||||
});
|
||||
couponId = created.couponId;
|
||||
promotionCodeId = created.promotionCodeId;
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message || String(err);
|
||||
if (msg.toLowerCase().includes("already exists")) {
|
||||
throw new BadRequestException(`'${code}' kodu Stripe'da zaten kullanımda`);
|
||||
}
|
||||
this.logger.error(`Stripe promotion create failed for ${code}: ${msg}`);
|
||||
throw new BadRequestException(`İndirim kodu oluşturulamadı: ${msg}`);
|
||||
}
|
||||
|
||||
const [row] = await this.db
|
||||
.insert(discountCodes)
|
||||
.values({
|
||||
code,
|
||||
stripeCouponId: couponId,
|
||||
stripePromotionCodeId: promotionCodeId,
|
||||
discountType,
|
||||
percentOff,
|
||||
amountOff,
|
||||
duration,
|
||||
durationInMonths,
|
||||
maxRedemptions,
|
||||
expiresAt,
|
||||
note: input.note?.slice(0, 500) ?? null,
|
||||
createdBy,
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Discount code created: ${code} (${discountType}) by ${createdBy ?? "?"}`);
|
||||
return { ...row, status: this.computeStatus(row) };
|
||||
}
|
||||
|
||||
async list() {
|
||||
const rows = await this.db.select().from(discountCodes).orderBy(desc(discountCodes.createdAt));
|
||||
return rows.map((r) => ({ ...r, status: this.computeStatus(r) }));
|
||||
}
|
||||
|
||||
async getOne(id: string) {
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(discountCodes)
|
||||
.where(eq(discountCodes.id, id))
|
||||
.limit(1);
|
||||
if (!row) throw new NotFoundException("İndirim kodu bulunamadı");
|
||||
return { ...row, status: this.computeStatus(row) };
|
||||
}
|
||||
|
||||
async deactivate(id: string) {
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(discountCodes)
|
||||
.where(eq(discountCodes.id, id))
|
||||
.limit(1);
|
||||
if (!row) throw new NotFoundException("İndirim kodu bulunamadı");
|
||||
|
||||
// Must archive on Stripe too: checkout resolves the live promotion code, so
|
||||
// a code left active there would keep working after a local-only deactivate.
|
||||
if (row.stripePromotionCodeId) {
|
||||
await this.stripe.deactivatePromotion(row.stripePromotionCodeId);
|
||||
}
|
||||
await this.db
|
||||
.update(discountCodes)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(discountCodes.id, id));
|
||||
this.logger.log(`Discount code deactivated: ${row.code}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Reconcile mirrored redemption counts from Stripe (panel "sync" button). */
|
||||
async syncRedemptions() {
|
||||
const rows = await this.db.select().from(discountCodes);
|
||||
let updated = 0;
|
||||
for (const row of rows) {
|
||||
if (!row.stripePromotionCodeId) continue;
|
||||
const count = await this.stripe.getPromotionRedemptions(row.stripePromotionCodeId);
|
||||
if (count != null && count !== row.timesRedeemed) {
|
||||
await this.db
|
||||
.update(discountCodes)
|
||||
.set({ timesRedeemed: count, updatedAt: new Date() })
|
||||
.where(eq(discountCodes.id, row.id));
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
return { synced: rows.length, updated };
|
||||
}
|
||||
|
||||
/** Validate + price a code for the checkout page. */
|
||||
preview(code: string, planKey: string, billingPeriod: "monthly" | "yearly") {
|
||||
return this.stripe.previewDiscount(code, planKey, billingPeriod);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,11 @@ export class CorgiService {
|
||||
}
|
||||
|
||||
private extractYear(vin: string): number | null {
|
||||
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 {
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Browser, BrowserContext, Page } from "playwright";
|
||||
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
|
||||
import {
|
||||
classifyTransportError,
|
||||
isProxyConnectFailure,
|
||||
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||
|
||||
const SESSION_TTL_MS = 25 * 60 * 1000; // 25 minutes
|
||||
const MAX_CONCURRENT_PAGES = 3;
|
||||
@@ -76,19 +81,29 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly floxyPass: string;
|
||||
private readonly floxyLifetime: number;
|
||||
private startedAt = 0;
|
||||
// Provider the live browser was actually launched with. Tracks failover: when
|
||||
// the configured provider is "floxy" but the shared health gate is tripped,
|
||||
// (re)launch picks "dataimpulse" instead. Whole-browser scope because Playwright
|
||||
// binds the proxy at launch — a relaunch is what swaps the exit.
|
||||
private activeProvider: "floxy" | "dataimpulse" | "none" = "none";
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private readonly proxyHealth: ProxyHealthService,
|
||||
) {
|
||||
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
|
||||
|
||||
this.useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
|
||||
// Default dataimpulse: Floxy retired 2026-07 (permanently down). Set
|
||||
// EMEX_PROXY_PROVIDER=floxy to re-enable it.
|
||||
const rawProvider = this.configService
|
||||
.get<string>("EMEX_PROXY_PROVIDER", "floxy")
|
||||
.get<string>("EMEX_PROXY_PROVIDER", "dataimpulse")
|
||||
.toLowerCase();
|
||||
this.proxyProvider = !this.useProxy
|
||||
? "none"
|
||||
: rawProvider === "dataimpulse" || rawProvider === "none"
|
||||
? (rawProvider as "dataimpulse" | "none")
|
||||
: "floxy";
|
||||
: rawProvider === "floxy" || rawProvider === "none"
|
||||
? (rawProvider as "floxy" | "none")
|
||||
: "dataimpulse";
|
||||
this.proxyHost = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81");
|
||||
this.proxyPortStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001);
|
||||
this.proxyPortEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099);
|
||||
@@ -206,7 +221,11 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||
if (executablePath) launchOptions.executablePath = executablePath;
|
||||
|
||||
if (this.proxyProvider === "floxy") {
|
||||
// Pick the provider for THIS launch: honour the configured one, but while the
|
||||
// shared Floxy-health gate is tripped, a "floxy" config launches on DataImpulse.
|
||||
this.activeProvider = this.pickLaunchProvider();
|
||||
|
||||
if (this.activeProvider === "floxy") {
|
||||
// Sticky Floxy session for the browser's lifetime → one residential exit IP
|
||||
// for the whole chained scrape; a fresh id is minted on each (re)launch.
|
||||
const sid = Math.random().toString(36).slice(2, 10);
|
||||
@@ -222,14 +241,18 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
this.logger.log(
|
||||
`Using Floxy residential proxy: ${this.floxyHost}:${this.floxyPort} (sticky ${this.floxyLifetime}s)`,
|
||||
);
|
||||
} else if (this.proxyProvider === "dataimpulse") {
|
||||
} else if (this.activeProvider === "dataimpulse") {
|
||||
const port = this.randomProxyPort();
|
||||
launchOptions.proxy = {
|
||||
server: `http://${this.proxyHost}:${port}`,
|
||||
username: this.proxyUsername,
|
||||
password: this.proxyPassword,
|
||||
};
|
||||
this.logger.log(`Using DataImpulse proxy: ${this.proxyHost}:${port}`);
|
||||
this.logger.log(
|
||||
`Using DataImpulse proxy: ${this.proxyHost}:${port}${
|
||||
this.proxyProvider === "floxy" ? " (Floxy failover)" : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.browser = await chromium.launch(launchOptions);
|
||||
@@ -239,6 +262,42 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
});
|
||||
|
||||
// Block heavy resources to save proxy bandwidth. Scraping reads HTML/DOM
|
||||
// only — schema-image URLs are parsed from the markup and their dims come
|
||||
// from a separate Range GET, so the browser never needs images/fonts/CSS.
|
||||
// Context-level route covers every tab (session establish + acquirePage).
|
||||
await this.context.route("**/*", (route) => {
|
||||
const url = route.request().url();
|
||||
const type = route.request().resourceType();
|
||||
|
||||
if (["image", "font", "stylesheet", "media"].includes(type)) {
|
||||
return route.abort();
|
||||
}
|
||||
|
||||
// Third-party junk seen in proxy usage: analytics, ad networks, chat
|
||||
// widgets, Google/Yandex assets, and our own ipify egress probe.
|
||||
if (
|
||||
url.includes("google-analytics.com") ||
|
||||
url.includes("googletagmanager.com") ||
|
||||
url.includes("mc.yandex.ru") ||
|
||||
url.includes("yastatic.net") ||
|
||||
url.includes("fonts.googleapis.com") ||
|
||||
url.includes("fonts.gstatic.com") ||
|
||||
url.includes("content-autofill.googleapis.com") ||
|
||||
url.includes("facebook.net") ||
|
||||
url.includes("doubleclick.net") ||
|
||||
url.includes("hotjar.com") ||
|
||||
url.includes("adsco.re") ||
|
||||
url.includes("displayvertising.com") ||
|
||||
url.includes("tidio.co") ||
|
||||
url.includes("api.ipify.org")
|
||||
) {
|
||||
return route.abort();
|
||||
}
|
||||
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
this.startedAt = Date.now();
|
||||
this.sessionExpiry = 0; // force session establish on first acquirePage
|
||||
|
||||
@@ -270,34 +329,94 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
await this.launchBrowser();
|
||||
}
|
||||
|
||||
/** Provider for the next launch: DataImpulse while the Floxy gate is tripped. */
|
||||
private pickLaunchProvider(): "floxy" | "dataimpulse" | "none" {
|
||||
if (this.proxyProvider === "floxy" && this.proxyHealth.isFloxyDown()) {
|
||||
return "dataimpulse"; // DI creds are always present (env defaults) when proxy on
|
||||
}
|
||||
return this.proxyProvider;
|
||||
}
|
||||
|
||||
private async relaunchBrowser(): Promise<void> {
|
||||
await this.closeBrowser();
|
||||
await this.launchBrowser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit baseUrl to establish/refresh ASP.NET session cookie
|
||||
* Trip Floxy failover from a scrape-level transport failure (a connect error
|
||||
* that surfaced AFTER the session was established, so ensureSession's own gate
|
||||
* didn't catch it). Opens the shared cooldown and forces the next acquirePage
|
||||
* to re-establish the session — which switches the browser to DataImpulse.
|
||||
*/
|
||||
async tripFloxyFailover(reason?: string): Promise<void> {
|
||||
this.proxyHealth.reportFloxyFailure(reason);
|
||||
if (this.activeProvider === "floxy") {
|
||||
this.sessionExpiry = 0; // force ensureSession → provider switch on next acquire
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit baseUrl to establish/refresh ASP.NET session cookie. Doubles as the
|
||||
* Floxy health gate for the browser path: only at a session boundary, switch
|
||||
* the browser's proxy to match pickLaunchProvider() (fail over to DataImpulse
|
||||
* when Floxy is down, probe Floxy again once the gate clears). A connect
|
||||
* failure during the probe trips the gate and retries once on DataImpulse.
|
||||
*/
|
||||
private async ensureSession(): Promise<void> {
|
||||
// Only re-evaluate the provider when establishing a fresh session, so an
|
||||
// in-flight scrape on a working browser is never interrupted mid-flow.
|
||||
if (Date.now() >= this.sessionExpiry && this.proxyProvider !== "none") {
|
||||
const desired = this.pickLaunchProvider();
|
||||
if (this.browser?.isConnected() && desired !== this.activeProvider) {
|
||||
this.logger.log(`EMEX switching browser proxy: ${this.activeProvider} → ${desired}`);
|
||||
await this.relaunchBrowser();
|
||||
}
|
||||
}
|
||||
|
||||
if (Date.now() < this.sessionExpiry) return;
|
||||
|
||||
this.logger.log("Establishing EMEX session...");
|
||||
if (!this.context) throw new Error("EMEX browser context not initialized");
|
||||
const ctx = this.context;
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await page.goto(EMEX_BASE_URL, {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 30000,
|
||||
});
|
||||
// Up to 2 attempts: a Floxy tunnel failure trips the gate, relaunches on
|
||||
// DataImpulse, and tries once more so the caller still gets a live session.
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
if (!this.context) throw new Error("EMEX browser context not initialized");
|
||||
const ctx = this.context;
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
this.logger.log(`Establishing EMEX session (${this.activeProvider})...`);
|
||||
await page.goto(EMEX_BASE_URL, {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const cookies = await ctx.cookies();
|
||||
const session = cookies.find((c) => c.name === "ASP.NET_SessionId");
|
||||
if (session) {
|
||||
this.sessionExpiry = Date.now() + SESSION_TTL_MS;
|
||||
this.logger.log("Session established, TTL 25 min");
|
||||
} else {
|
||||
this.logger.warn("No session cookie found after visiting baseUrl");
|
||||
// Still set a short TTL to avoid hammering
|
||||
this.sessionExpiry = Date.now() + 60_000;
|
||||
if (this.activeProvider === "floxy") this.proxyHealth.reportFloxySuccess();
|
||||
|
||||
const cookies = await ctx.cookies();
|
||||
const session = cookies.find((c) => c.name === "ASP.NET_SessionId");
|
||||
if (session) {
|
||||
this.sessionExpiry = Date.now() + SESSION_TTL_MS;
|
||||
this.logger.log("Session established, TTL 25 min");
|
||||
} else {
|
||||
this.logger.warn("No session cookie found after visiting baseUrl");
|
||||
// Still set a short TTL to avoid hammering
|
||||
this.sessionExpiry = Date.now() + 60_000;
|
||||
}
|
||||
return;
|
||||
} catch (err) {
|
||||
await page.close().catch(() => {});
|
||||
const canFailover =
|
||||
attempt < 2 &&
|
||||
this.proxyProvider === "floxy" &&
|
||||
this.activeProvider === "floxy" &&
|
||||
isProxyConnectFailure(err);
|
||||
if (canFailover) {
|
||||
this.proxyHealth.reportFloxyFailure(`emex session: ${classifyTransportError(err)}`);
|
||||
await this.relaunchBrowser(); // gate now tripped → relaunch picks DataImpulse
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ const browser = {} as never;
|
||||
const redis = { set: vi.fn() } as never;
|
||||
const posthog = {} as never;
|
||||
const proxyTelemetry = { record: vi.fn() } as never;
|
||||
const proxyHealth = {
|
||||
isFloxyDown: vi.fn(() => false),
|
||||
reportFloxyFailure: vi.fn(),
|
||||
reportFloxySuccess: vi.fn(),
|
||||
} as never;
|
||||
|
||||
describe("EmexService proxy-port coercion (regression)", () => {
|
||||
it("does not throw 'Invalid URL' when ports arrive as strings over a real range", () => {
|
||||
@@ -29,7 +34,9 @@ describe("EmexService proxy-port coercion (regression)", () => {
|
||||
EMEX_PROXY_PORT_END: "10099",
|
||||
});
|
||||
// Pre-fix: threw "Invalid URL" here (port "4510001" > 65535).
|
||||
expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
|
||||
expect(
|
||||
() => new EmexService(config, browser, redis, posthog, proxyTelemetry, proxyHealth),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("constructs with a single-port range (string env)", () => {
|
||||
@@ -38,7 +45,9 @@ describe("EmexService proxy-port coercion (regression)", () => {
|
||||
EMEX_PROXY_PORT_START: "823",
|
||||
EMEX_PROXY_PORT_END: "823",
|
||||
});
|
||||
expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
|
||||
expect(
|
||||
() => new EmexService(config, browser, redis, posthog, proxyTelemetry, proxyHealth),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("falls back to a valid default when the port env is garbage", () => {
|
||||
@@ -47,11 +56,15 @@ describe("EmexService proxy-port coercion (regression)", () => {
|
||||
EMEX_PROXY_PORT_START: "not-a-number",
|
||||
EMEX_PROXY_PORT_END: "999999",
|
||||
});
|
||||
expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
|
||||
expect(
|
||||
() => new EmexService(config, browser, redis, posthog, proxyTelemetry, proxyHealth),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("constructs cleanly with the proxy disabled", () => {
|
||||
const config = makeConfig({ EMEX_USE_PROXY: "false" });
|
||||
expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
|
||||
expect(
|
||||
() => new EmexService(config, browser, redis, posthog, proxyTelemetry, proxyHealth),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,9 +23,11 @@ import { ProxyAgent } from "undici";
|
||||
import { isBackfillContext } from "../../jobs/prefetch-context";
|
||||
import { PostHogService } from "../../posthog/posthog.service";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
|
||||
import {
|
||||
ProxyTelemetryService,
|
||||
classifyTransportError,
|
||||
isProxyConnectFailure,
|
||||
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||
import { parseUnitLeaves, parseVehicleTree } from "./emex-tree.parser";
|
||||
import { EmexBrowserService } from "./emex.browser";
|
||||
@@ -147,6 +149,7 @@ export class EmexService {
|
||||
private redis: RedisService,
|
||||
private posthog: PostHogService,
|
||||
private proxyTelemetry: ProxyTelemetryService,
|
||||
private proxyHealth: ProxyHealthService,
|
||||
) {
|
||||
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
|
||||
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
|
||||
@@ -212,11 +215,15 @@ export class EmexService {
|
||||
// random-port-per-call primary proved that), but a sticky Floxy IP across the
|
||||
// chained flow is still the safest choice — and avoids DataImpulse's ~50% dead
|
||||
// ports that were eating the decode budget before reaching the Floxy fallback.
|
||||
// Default dataimpulse: Floxy retired 2026-07 (permanently down). Set
|
||||
// EMEX_PROXY_PROVIDER=floxy to re-enable it.
|
||||
this.emexProxyProvider = !useProxy
|
||||
? "none"
|
||||
: (() => {
|
||||
const p = this.configService.get<string>("EMEX_PROXY_PROVIDER", "floxy").toLowerCase();
|
||||
return p === "dataimpulse" || p === "none" ? p : "floxy";
|
||||
const p = this.configService
|
||||
.get<string>("EMEX_PROXY_PROVIDER", "dataimpulse")
|
||||
.toLowerCase();
|
||||
return p === "floxy" || p === "none" ? p : "dataimpulse";
|
||||
})();
|
||||
// Default agent for the low-stakes image-dims path; fetchEmexHtml builds a
|
||||
// fresh agent per request so a flaky exit can't pin every call.
|
||||
@@ -383,8 +390,14 @@ export class EmexService {
|
||||
// and a different proxy IP must not "retry" it away.
|
||||
const schedule: Array<"dataimpulse" | "floxy"> = [];
|
||||
if (this.emexProxyProvider === "floxy") {
|
||||
if (this.emexFloxy) schedule.push("floxy", "floxy");
|
||||
if (this.emexProxy) schedule.push("dataimpulse");
|
||||
if (this.emexFloxy && this.proxyHealth.isFloxyDown() && this.emexProxy) {
|
||||
// Floxy gate tripped → DataImpulse-first (rotating ports cover its ~50%
|
||||
// dead ports), with one Floxy probe last so a recovery is still detected.
|
||||
schedule.push("dataimpulse", "dataimpulse", "floxy");
|
||||
} else {
|
||||
if (this.emexFloxy) schedule.push("floxy", "floxy");
|
||||
if (this.emexProxy) schedule.push("dataimpulse");
|
||||
}
|
||||
} else if (this.emexProxyProvider === "dataimpulse") {
|
||||
if (this.emexProxy) schedule.push("dataimpulse", "dataimpulse", "dataimpulse");
|
||||
if (this.emexFloxy) schedule.push("floxy", "floxy");
|
||||
@@ -417,6 +430,7 @@ export class EmexService {
|
||||
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
|
||||
}
|
||||
if (provider === "floxy") {
|
||||
this.proxyHealth.reportFloxySuccess(); // Floxy answered — clear cooldown
|
||||
this.logger.log(`EMEX fetch via Floxy fallback succeeded for ${url}`);
|
||||
}
|
||||
return await res.text();
|
||||
@@ -446,6 +460,11 @@ export class EmexService {
|
||||
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|other side closed|terminated|UND_ERR/i.test(
|
||||
`${e.message} ${String(e.cause ?? "")}`,
|
||||
));
|
||||
// A Floxy connect failure (dead exit / tunnel refused) trips the shared
|
||||
// gate so EMEX + pcat fail over to DataImpulse for the cooldown window.
|
||||
if (provider === "floxy" && isProxyConnectFailure(e)) {
|
||||
this.proxyHealth.reportFloxyFailure(`emex http: ${classifyTransportError(e)}`);
|
||||
}
|
||||
if (!transient || attempt === maxAttempts) break;
|
||||
// A Floxy transport failure means the current sticky exit IP is dead —
|
||||
// roll to a fresh session so the next Floxy attempt gets a new IP.
|
||||
@@ -909,6 +928,12 @@ export class EmexService {
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
|
||||
// Floxy tunnel dead mid-scrape → trip failover so the next acquirePage
|
||||
// re-establishes the session on DataImpulse.
|
||||
if (isProxyConnectFailure(err)) {
|
||||
void this.browserService.tripFloxyFailover(`emex decode: ${classifyTransportError(err)}`);
|
||||
}
|
||||
|
||||
if (
|
||||
err instanceof BadRequestException ||
|
||||
err instanceof ServiceUnavailableException ||
|
||||
@@ -1052,6 +1077,10 @@ export class EmexService {
|
||||
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
// Floxy tunnel dead mid-scrape → trip failover for the next acquirePage.
|
||||
if (isProxyConnectFailure(err)) {
|
||||
void this.browserService.tripFloxyFailover(`emex parts: ${classifyTransportError(err)}`);
|
||||
}
|
||||
this.logger.error(`Failed to fetch category parts: ${err.message}`);
|
||||
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
|
||||
} finally {
|
||||
|
||||
@@ -135,8 +135,14 @@ export class PSourceDbService implements OnModuleInit, OnModuleDestroy {
|
||||
SELECT
|
||||
a.id::text AS id,
|
||||
b.name AS brand,
|
||||
a.article_number AS article_number,
|
||||
a.name AS name,
|
||||
-- Snapshot'ta kolonlar şaşırtmacalı: articles.article_number %96
|
||||
-- upstream'in sayısal scrape-ID'si, GERÇEK üretici parça kodu ise
|
||||
-- articles.name kolonunda ("0 092 S40 300", "JTE2311"; 1,1M satırda
|
||||
-- yalnızca 176 istisna). Kullanıcıya kopyalanabilir gerçek kodu servis
|
||||
-- et; name'i ayrıca döndürme (UI'daki ikincil satır zaten kod ile
|
||||
-- aynıysa gizleniyordu).
|
||||
COALESCE(NULLIF(a.name, ''), a.article_number) AS article_number,
|
||||
NULL AS name,
|
||||
a.spare_info AS spare_info,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('brand', o.brand, 'code', o.code))
|
||||
@@ -167,7 +173,7 @@ export class PSourceDbService implements OnModuleInit, OnModuleDestroy {
|
||||
FROM hit
|
||||
JOIN articles a ON a.id = hit.article_id
|
||||
JOIN article_brands b ON b.id = a.brand_id
|
||||
ORDER BY b.name, a.article_number
|
||||
ORDER BY b.name, COALESCE(NULLIF(a.name, ''), a.article_number)
|
||||
`;
|
||||
|
||||
if (rows.length === 0) return miss;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,14 @@ import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@ne
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Browser, BrowserContext } from "playwright";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
|
||||
import {
|
||||
ProxyTelemetryService,
|
||||
classifyTransportError,
|
||||
isProxyConnectFailure,
|
||||
resolveExitIp,
|
||||
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||
import { PcatAssetCache } from "./parts-catalogs-asset-cache";
|
||||
import { JwtSlot, PcatJwtToken, PcatSession } from "./parts-catalogs.types";
|
||||
|
||||
// Shared single-slot cache so dev + prod (and any restarted container) can
|
||||
@@ -185,11 +188,27 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly diHost: string;
|
||||
private readonly diUser: string;
|
||||
private readonly diPass: string;
|
||||
// When a proxied capture dies on a tunnel/connect failure (residential balance
|
||||
// exhausted, DataImpulse ports dead, both providers down), retry that capture
|
||||
// DIRECT from the server IP instead of starving the warm pool. Safe because
|
||||
// the TWS- token is not IP-bound and capture is low-frequency (≤ MAX_POOL_SIZE
|
||||
// refreshes / ~8.5min). This is the survival path for a full proxy outage —
|
||||
// the exact failure seen 2026-06-11 (Floxy 402) and 2026-07-03 (Floxy tunnel
|
||||
// down). Disable with PCAT_CAPTURE_ALLOW_DIRECT=false.
|
||||
private readonly captureAllowDirect: boolean;
|
||||
// Exit-IP telemetry probe (one ipify call per capture). Off by default: it
|
||||
// added ~54k billed proxy requests over 15 days for banned-IP telemetry only.
|
||||
private readonly exitIpProbe: boolean;
|
||||
// Cross-capture cache for the widget's static JS/CSS so each capture stops
|
||||
// re-downloading ~2 MB of identical assets through the residential proxy.
|
||||
// Null when disabled via PCAT_ASSET_CACHE=false (kill switch, no redeploy).
|
||||
private readonly assetCache: PcatAssetCache | null;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly proxyTelemetry: ProxyTelemetryService,
|
||||
private readonly proxyHealth: ProxyHealthService,
|
||||
) {
|
||||
const cfg = this.configService;
|
||||
|
||||
@@ -205,13 +224,16 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
this.semaphore = new Semaphore(this.jwtSites.length);
|
||||
|
||||
// Provider selection. Back-compat: PCAT_USE_PROXY=false still forces direct.
|
||||
const rawProvider = cfg.get<string>("PCAT_PROXY_PROVIDER", "floxy").toLowerCase();
|
||||
// Default is dataimpulse: Floxy was retired 2026-07 (permanently down —
|
||||
// balance/tunnel dead), so floxy-primary just burned a failed attempt per
|
||||
// call before failing over. Set PCAT_PROXY_PROVIDER=floxy to re-enable it.
|
||||
const rawProvider = cfg.get<string>("PCAT_PROXY_PROVIDER", "dataimpulse").toLowerCase();
|
||||
const useProxy = cfg.get<string>("PCAT_USE_PROXY", "true") === "true";
|
||||
this.proxyProvider = !useProxy
|
||||
? "none"
|
||||
: rawProvider === "dataimpulse" || rawProvider === "none"
|
||||
: rawProvider === "floxy" || rawProvider === "none"
|
||||
? (rawProvider as ProxyProvider)
|
||||
: "floxy";
|
||||
: "dataimpulse";
|
||||
|
||||
const toInt = (key: string, def: number): number => {
|
||||
const n = Number(cfg.get(key, def));
|
||||
@@ -231,6 +253,13 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
this.diHost = cfg.get<string>("PCAT_PROXY_HOST", DI_HOST);
|
||||
this.diUser = cfg.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
|
||||
this.diPass = cfg.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS);
|
||||
this.exitIpProbe = cfg.get<string>("PCAT_EXIT_IP_PROBE", "false") === "true";
|
||||
this.assetCache =
|
||||
cfg.get<string>("PCAT_ASSET_CACHE", "true") === "true" ? new PcatAssetCache() : null;
|
||||
|
||||
this.captureAllowDirect =
|
||||
cfg.get<string>("PCAT_CAPTURE_ALLOW_DIRECT", "true") !== "false" &&
|
||||
this.proxyProvider !== "none";
|
||||
|
||||
this.logger.log(
|
||||
this.proxyProvider === "floxy"
|
||||
@@ -254,17 +283,29 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
// Telemetry identity: sticky Floxy session id or DataImpulse port. Null on
|
||||
// rotating legs (exit IP changes per request — nothing stable to attribute).
|
||||
sessionKey: string | null;
|
||||
// The provider actually used for this leg (may differ from the configured
|
||||
// provider when Floxy is in failover). Drives accurate telemetry + which
|
||||
// provider's health to report success/failure against.
|
||||
provider: ProxyProvider;
|
||||
} {
|
||||
if (this.proxyProvider === "none")
|
||||
return { proxyUrl: null, proxyConfig: null, sessionKey: null };
|
||||
return { proxyUrl: null, proxyConfig: null, sessionKey: null, provider: "none" };
|
||||
|
||||
if (this.proxyProvider === "dataimpulse") {
|
||||
// Floxy-primary but the shared health gate is tripped (Floxy down — balance
|
||||
// or tunnel) → fail over to the DataImpulse datacenter pool for this leg so
|
||||
// captures/calls keep landing instead of dying on a dead residential proxy.
|
||||
const useDataImpulse =
|
||||
this.proxyProvider === "dataimpulse" ||
|
||||
(this.proxyProvider === "floxy" && this.proxyHealth.isFloxyDown());
|
||||
|
||||
if (useDataImpulse) {
|
||||
const port = this.allocatePort();
|
||||
const server = `http://${this.diHost}:${port}`;
|
||||
return {
|
||||
proxyUrl: `http://${this.diUser}:${this.diPass}@${this.diHost}:${port}`,
|
||||
proxyConfig: { server, username: this.diUser, password: this.diPass },
|
||||
sessionKey: `di:${port}`,
|
||||
provider: "dataimpulse",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -281,6 +322,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
proxyUrl: `http://${this.floxyUser}:${password}@${this.floxyHost}:${this.floxyPort}`,
|
||||
proxyConfig: { server, username: this.floxyUser, password },
|
||||
sessionKey,
|
||||
provider: "floxy",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -387,6 +429,20 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
// ─── Pool management ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Trim the pool to MAX_POOL_SIZE, evicting the oldest slots (by capturedAt) and
|
||||
* clearing their refresh timers. Idempotent; a no-op when at/under the cap.
|
||||
*/
|
||||
private enforcePoolCap(): void {
|
||||
if (this.pool.length <= MAX_POOL_SIZE) return;
|
||||
this.pool.sort((a, b) => a.capturedAt - b.capturedAt); // oldest first
|
||||
const evicted = this.pool.splice(0, this.pool.length - MAX_POOL_SIZE);
|
||||
for (const s of evicted) this.clearSlotTimer(s);
|
||||
this.logger.warn(
|
||||
`JWT pool: over cap, evicted ${evicted.length} oldest slot(s), size now ${this.pool.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
private pickValidSlot(): JwtSlot | null {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// Remove expired slots
|
||||
@@ -461,8 +517,15 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
// Mark site as used
|
||||
this.siteLastUsedAt.set(siteUrl, Date.now());
|
||||
|
||||
// Add to pool
|
||||
// Add to pool, then enforce the hard cap. A slot-accounting race in the
|
||||
// refresh path (concurrent captures via the semaphore, or a refresh
|
||||
// whose old slot was already removed) could otherwise let the pool grow
|
||||
// past MAX_POOL_SIZE — observed at 32-39 on prod, which multiplies
|
||||
// capture load and partner-site ban exposure for no benefit. Evicting
|
||||
// the oldest slots keeps the freshest MAX_POOL_SIZE and makes the
|
||||
// documented invariant hold regardless of the race.
|
||||
this.pool.push(slot);
|
||||
this.enforcePoolCap();
|
||||
|
||||
// Publish to Redis so other containers can hydrate from this slot.
|
||||
this.persistToRedis(slot).catch(() => {});
|
||||
@@ -731,23 +794,41 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
// ─── 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;
|
||||
const startTime = Date.now();
|
||||
|
||||
// Sticky capture proxy: one residential exit IP pinned for the lifetime of
|
||||
// this capture so the partner site + widget see a coherent session.
|
||||
const { proxyConfig, proxyUrl, sessionKey } = 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 capture will use — that's what makes banned-IP tracking concrete.
|
||||
// Resolves in ~1-2s while the capture itself takes 7s+; never throws.
|
||||
const exitIpPromise = proxyUrl ? resolveExitIp(proxyUrl) : Promise.resolve(null);
|
||||
// Gated off by default (PCAT_EXIT_IP_PROBE): the probe is billed proxy
|
||||
// traffic and only feeds exit-IP telemetry, not the capture itself.
|
||||
const exitIpPromise =
|
||||
this.exitIpProbe && proxyUrl ? resolveExitIp(proxyUrl) : Promise.resolve(null);
|
||||
const logCapture = (ok: boolean, errorKind?: string): void => {
|
||||
void exitIpPromise.then((exitIp) =>
|
||||
this.proxyTelemetry.record({
|
||||
service: "pcat_capture",
|
||||
provider: this.proxyProvider,
|
||||
provider,
|
||||
sessionKey,
|
||||
exitIp,
|
||||
targetHost: new URL(siteUrl).hostname,
|
||||
@@ -792,8 +873,15 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
this.logger.debug(`Token intercepted (key=${apiKey.slice(0, 16)}...)`);
|
||||
});
|
||||
|
||||
// Block heavy resources to save proxy bandwidth
|
||||
await page.route("**/*", (route) => {
|
||||
// Block heavy resources to save proxy bandwidth. Kept intentionally
|
||||
// minimal: an aggressive block (stylesheet + yastatic/font/ad domains)
|
||||
// shipped 2026-07-16 killed token capture on prod (dataimpulse 425→0
|
||||
// captures/h at deploy time — the RU catalog widget needs its CSS and
|
||||
// Yandex-hosted runtime to init and fire the /v3/api/proxy XHR). Only
|
||||
// block what the widget provably never needs: images, fonts, media, and
|
||||
// a few pure analytics/ad domains.
|
||||
const assetCache = this.assetCache;
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = route.request().url();
|
||||
const type = route.request().resourceType();
|
||||
|
||||
@@ -814,6 +902,29 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
return route.abort();
|
||||
}
|
||||
|
||||
// Serve versioned static JS/CSS from the cross-capture cache; a hit
|
||||
// costs zero proxy bytes. On miss, fetch once through the context proxy,
|
||||
// store the decoded body, and serve it. Everything else (HTML document,
|
||||
// the /v3/api/proxy token XHR, dynamic fetches) always goes live.
|
||||
if (assetCache && PcatAssetCache.isCacheable(type, url)) {
|
||||
const hit = await assetCache.get(url);
|
||||
if (hit) {
|
||||
return route.fulfill({ status: 200, contentType: hit.contentType, body: hit.body });
|
||||
}
|
||||
try {
|
||||
const resp = await route.fetch({ timeout: 15_000 });
|
||||
const body = await resp.body();
|
||||
const contentType =
|
||||
resp.headers()["content-type"] ??
|
||||
(type === "script" ? "application/javascript" : "text/css");
|
||||
if (resp.ok() && body.length) void assetCache.put(url, contentType, body);
|
||||
return route.fulfill({ status: resp.status(), contentType, body });
|
||||
} catch {
|
||||
// Fetch failed (slow/dead proxy) — let the browser attempt it itself.
|
||||
return route.continue();
|
||||
}
|
||||
}
|
||||
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
@@ -834,32 +945,58 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
// constants above.
|
||||
gotoOk = false;
|
||||
this.logger.debug(`Navigation ended: ${(navErr as Error).message?.slice(0, 80)}`);
|
||||
const connectFailed = isProxyConnectFailure(navErr);
|
||||
// A tunnel/connect failure here (not a plain slow-site timeout) means the
|
||||
// Floxy exit is dead — trip the shared gate so this + EMEX fail over to
|
||||
// DataImpulse for the cooldown window.
|
||||
if (provider === "floxy" && connectFailed) {
|
||||
this.proxyHealth.reportFloxyFailure(`pcat capture: ${classifyTransportError(navErr)}`);
|
||||
}
|
||||
// Proxy tunnel is dead (all providers). Rather than return an empty
|
||||
// capture and starve the pool, retry this same site direct.
|
||||
if (connectFailed && proxyConfig && this.captureAllowDirect && !forceDirect) {
|
||||
retryDirect = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Poll for token — short cap after healthy goto, brief grace after fail
|
||||
const pollMax = gotoOk ? CAPTURE_POLL_AFTER_OK : CAPTURE_POLL_AFTER_FAIL;
|
||||
for (let i = 0; i < pollMax; i++) {
|
||||
if (capturedToken) break;
|
||||
await new Promise((r) => setTimeout(r, CAPTURE_POLL_INTERVAL));
|
||||
if (!retryDirect) {
|
||||
// Poll for token — short cap after healthy goto, brief grace after fail
|
||||
const pollMax = gotoOk ? CAPTURE_POLL_AFTER_OK : CAPTURE_POLL_AFTER_FAIL;
|
||||
for (let i = 0; i < pollMax; i++) {
|
||||
if (capturedToken) break;
|
||||
await new Promise((r) => setTimeout(r, CAPTURE_POLL_INTERVAL));
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
if (capturedToken) {
|
||||
logCapture(true);
|
||||
// Floxy answered — clear any failover cooldown so we resume residential.
|
||||
if (provider === "floxy") this.proxyHealth.reportFloxySuccess();
|
||||
this.logger.log(
|
||||
`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}${forceDirect ? " (direct)" : ""}`,
|
||||
);
|
||||
return capturedToken;
|
||||
}
|
||||
|
||||
// Widget never fired its token call — on a sticky residential IP this is
|
||||
// the classic "partner site blocked/ratelimited this exit IP" signature.
|
||||
logCapture(false, "capture_timeout");
|
||||
this.logger.debug(`No token after ${elapsed}ms from ${siteUrl}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
if (capturedToken) {
|
||||
logCapture(true);
|
||||
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) {
|
||||
logCapture(false, classifyTransportError(err));
|
||||
this.logger.warn(`Token capture error: ${(err as Error).message}`);
|
||||
return null;
|
||||
const connectFailed = isProxyConnectFailure(err);
|
||||
if (provider === "floxy" && connectFailed) {
|
||||
this.proxyHealth.reportFloxyFailure(`pcat capture: ${classifyTransportError(err)}`);
|
||||
}
|
||||
if (connectFailed && proxyConfig && this.captureAllowDirect && !forceDirect) {
|
||||
retryDirect = true;
|
||||
} else {
|
||||
this.logger.warn(`Token capture error: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
} finally {
|
||||
if (context) {
|
||||
try {
|
||||
@@ -872,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 ───────────────────────────────────
|
||||
|
||||
@@ -11,10 +11,12 @@ import { Injectable, Logger } from "@nestjs/common";
|
||||
import { isBackfillContext } from "../../jobs/prefetch-context";
|
||||
import { PostHogService } from "../../posthog/posthog.service";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
|
||||
import {
|
||||
ProxyTelemetryService,
|
||||
classifyTransportError,
|
||||
describeProxyUrl,
|
||||
isProxyConnectFailure,
|
||||
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
|
||||
import {
|
||||
@@ -68,6 +70,7 @@ export class PartsCatalogsService {
|
||||
private redis: RedisService,
|
||||
private posthog: PostHogService,
|
||||
private proxyTelemetry: ProxyTelemetryService,
|
||||
private proxyHealth: ProxyHealthService,
|
||||
) {}
|
||||
|
||||
/** Mark parts-catalogs as actively used (5min TTL) to defer prefetch worker */
|
||||
@@ -324,6 +327,10 @@ export class PartsCatalogsService {
|
||||
if (response.ok) {
|
||||
// Token proven good on this IP — clear any accrued failure streak.
|
||||
session._slot.failCount = 0;
|
||||
// Floxy answered at transport level — clear any failover cooldown.
|
||||
if (describeProxyUrl(session.proxyUrl).provider === "floxy") {
|
||||
this.proxyHealth.reportFloxySuccess();
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
@@ -352,6 +359,11 @@ export class PartsCatalogsService {
|
||||
success: false,
|
||||
durationMs: Date.now() - attemptStart,
|
||||
});
|
||||
// Floxy exit refused/closed the CONNECT → trip the shared gate so the
|
||||
// next capture/call (and EMEX) fails over to DataImpulse.
|
||||
if (describeProxyUrl(session.proxyUrl).provider === "floxy" && isProxyConnectFailure(e)) {
|
||||
this.proxyHealth.reportFloxyFailure(`pcat call: ${classifyTransportError(e)}`);
|
||||
}
|
||||
}
|
||||
// Retry transient TRANSPORT failures only: request timeouts and undici
|
||||
// network errors ("TypeError: fetch failed" — a dropped/reset DataImpulse
|
||||
|
||||
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>
|
||||
461
apps/api/src/integrations/pl24/pl24-auth.service.spec.ts
Normal file
461
apps/api/src/integrations/pl24/pl24-auth.service.spec.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
|
||||
// Oturum modeli (1 login → PL24TOKEN çerezi → yalnız authorize ile yenileme),
|
||||
// PL24_TR_DISABLED köprüsü ve login devre kesici. Ağ yok: global fetch stub'ı;
|
||||
// private durum repo genelindeki `as unknown as` kalıbıyla okunur.
|
||||
|
||||
type RedisStub = {
|
||||
store: Map<string, unknown>;
|
||||
get: (k: string) => Promise<string | null>;
|
||||
set: (k: string, v: string, ttl?: number) => Promise<void>;
|
||||
exists: (k: string) => Promise<boolean>;
|
||||
getJson: (k: string) => Promise<unknown>;
|
||||
setJson: (k: string, v: unknown) => Promise<void>;
|
||||
del: (k: string) => Promise<void>;
|
||||
setNx: (k: string, v: string, ttl: number) => Promise<boolean>;
|
||||
incr: (k: string) => Promise<number>;
|
||||
expire: (k: string, ttl: number) => Promise<void>;
|
||||
};
|
||||
|
||||
const makeRedis = (opts: { lockTaken?: boolean } = {}): RedisStub => {
|
||||
const store = new Map<string, unknown>();
|
||||
const counters = new Map<string, number>();
|
||||
return {
|
||||
store,
|
||||
get: async (k: string) => (store.has(k) ? String(store.get(k)) : null),
|
||||
set: async (k: string, v: string) => {
|
||||
store.set(k, v);
|
||||
},
|
||||
exists: async (k: string) => store.has(k),
|
||||
getJson: async (k: string) => store.get(k) ?? null,
|
||||
setJson: async (k: string, v: unknown) => {
|
||||
store.set(k, v);
|
||||
},
|
||||
del: async (k: string) => {
|
||||
store.delete(k);
|
||||
},
|
||||
// Gerçek SET NX semantiği: var olan anahtarı ikinci kez yazmaz. Alarm
|
||||
// tekrarının bastırılması buna dayanıyor.
|
||||
setNx: async (k: string, v: string) => {
|
||||
if (opts.lockTaken && k.includes("login-lock")) return false;
|
||||
if (store.has(k)) return false;
|
||||
store.set(k, v);
|
||||
return true;
|
||||
},
|
||||
incr: async (k: string) => {
|
||||
const n = (counters.get(k) ?? 0) + 1;
|
||||
counters.set(k, n);
|
||||
return n;
|
||||
},
|
||||
expire: async () => {},
|
||||
};
|
||||
};
|
||||
|
||||
/** Bütçe servisi stub'ı: sayar ama engellemez, telemetriyi yutar. */
|
||||
const makeBudget = () => ({
|
||||
consumed: [] as string[],
|
||||
recorded: [] as unknown[],
|
||||
consume: async function (kind: string) {
|
||||
this.consumed.push(kind);
|
||||
},
|
||||
record: function (e: unknown) {
|
||||
this.recorded.push(e);
|
||||
},
|
||||
spentToday: async () => 0,
|
||||
});
|
||||
|
||||
const makeService = (cfgOverrides: Record<string, string> = {}, redis = makeRedis()) => {
|
||||
const cfg: Record<string, string> = {
|
||||
"pl24.companyCode": "tr-000000",
|
||||
"pl24.username": "admin",
|
||||
"pl24.password": "pw-tr",
|
||||
"pl24.companyCode2": "de-000000",
|
||||
"pl24.username2": "admin",
|
||||
"pl24.password2": "pw-de",
|
||||
"pl24.proxyDe": "http://u:p@127.0.0.1:9",
|
||||
...cfgOverrides,
|
||||
};
|
||||
const configService = { get: (k: string, d?: unknown) => cfg[k] ?? d } as never;
|
||||
const budget = makeBudget();
|
||||
const telegram = {
|
||||
sent: [] as string[],
|
||||
isConfigured: () => true,
|
||||
send: async function (t: string) {
|
||||
this.sent.push(t);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return {
|
||||
svc: new PL24AuthService(configService, redis as never, budget as never, telegram as never),
|
||||
redis,
|
||||
budget,
|
||||
telegram,
|
||||
};
|
||||
};
|
||||
|
||||
type Exposed = {
|
||||
effectiveAccount(account: "tr" | "de"): "tr" | "de";
|
||||
login(account: "tr" | "de"): Promise<unknown>;
|
||||
ensureSession(account: "tr" | "de"): Promise<{ sessionToken: string }>;
|
||||
sessions: Record<string, { sessionToken: string } | undefined>;
|
||||
serviceTokens: Record<"tr" | "de", Map<string, { token: string; expiresAt: number }>>;
|
||||
};
|
||||
|
||||
/** Portal login yanıtı: {loginStatus, sessionToken} + Set-Cookie PL24TOKEN. */
|
||||
const loginOk = (token = "sess-token-1") => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get: (h: string) => (h.toLowerCase() === "set-cookie" ? `PL24TOKEN=${token}; Path=/` : null),
|
||||
getSetCookie: () => [`PL24TOKEN=${token}; Path=/; Secure`],
|
||||
},
|
||||
json: async () => ({ loginStatus: "OK", sessionToken: token }),
|
||||
});
|
||||
|
||||
const jwt = (payload: Record<string, unknown>) =>
|
||||
`h.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.s`;
|
||||
|
||||
const authorizeOk = (scope = "vw_parts pl24-usage", sessionStatus = "alive") => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { get: () => null, getSetCookie: () => [] },
|
||||
json: async () => ({
|
||||
access_token: jwt({ exp: Math.floor(Date.now() / 1000) + 600, sid: "sess-token-1" }),
|
||||
expires_in: 600,
|
||||
scope,
|
||||
session_status: sessionStatus,
|
||||
}),
|
||||
});
|
||||
|
||||
const problem = (status: number, type: string) => ({
|
||||
ok: false,
|
||||
status,
|
||||
statusText: "Precondition Failed",
|
||||
headers: { get: () => null, getSetCookie: () => [] },
|
||||
json: async () => ({ type, title: type, detail: type }),
|
||||
});
|
||||
|
||||
const savedFlag = process.env.PL24_TR_DISABLED;
|
||||
|
||||
beforeEach(() => {
|
||||
// "" ≠ "true" → bayrak kapalı; delete yerine atama (biome noDelete).
|
||||
process.env.PL24_TR_DISABLED = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PL24_TR_DISABLED = savedFlag ?? "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("PL24AuthService — PL24_TR_DISABLED tr→de köprüsü", () => {
|
||||
it("bayrak kapalıyken hesaplar olduğu gibi kalır", () => {
|
||||
const { svc } = makeService();
|
||||
const p = svc as unknown as Exposed;
|
||||
expect(p.effectiveAccount("tr")).toBe("tr");
|
||||
expect(p.effectiveAccount("de")).toBe("de");
|
||||
});
|
||||
|
||||
it("bayrak açıkken tr → de'ye maplenir, de değişmez", () => {
|
||||
process.env.PL24_TR_DISABLED = "true";
|
||||
const { svc } = makeService();
|
||||
const p = svc as unknown as Exposed;
|
||||
expect(p.effectiveAccount("tr")).toBe("de");
|
||||
expect(p.effectiveAccount("de")).toBe("de");
|
||||
});
|
||||
|
||||
it("de hesabı tanımlı değilse mapleme yapılmaz (duvara yönlendirme yok)", () => {
|
||||
process.env.PL24_TR_DISABLED = "true";
|
||||
const { svc } = makeService({ "pl24.companyCode2": "" });
|
||||
expect((svc as unknown as Exposed).effectiveAccount("tr")).toBe("tr");
|
||||
});
|
||||
|
||||
it("bayrak açıkken tr login'i ağa çıkmadan reddedilir", async () => {
|
||||
process.env.PL24_TR_DISABLED = "true";
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { svc } = makeService();
|
||||
await expect((svc as unknown as Exposed).login("tr")).rejects.toThrow(/PL24_TR_DISABLED/);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("legacy clearTokens (tr) bayrak altında de servis token'larını temizler", () => {
|
||||
process.env.PL24_TR_DISABLED = "true";
|
||||
const { svc } = makeService();
|
||||
const p = svc as unknown as Exposed;
|
||||
p.serviceTokens.de.set("vw_parts", { token: "x", expiresAt: Date.now() + 60_000 });
|
||||
svc.clearTokens();
|
||||
expect(p.serviceTokens.de.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PL24AuthService — oturum modeli", () => {
|
||||
it("bir kez login eder, sonraki çağrılar aynı oturumu kullanır (yeniden login yok)", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(loginOk());
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { svc } = makeService();
|
||||
|
||||
const c1 = await svc.getSessionCookieForAccount("de");
|
||||
const c2 = await svc.getSessionCookieForAccount("de");
|
||||
|
||||
expect(c1).toBe("PL24TOKEN=sess-token-1");
|
||||
expect(c2).toBe(c1);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("eşzamanlı çağrılar tek login paylaşır (single-flight)", async () => {
|
||||
const fetchSpy = vi.fn().mockImplementation(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
return loginOk();
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { svc } = makeService();
|
||||
|
||||
await Promise.all([
|
||||
svc.getSessionCookieForAccount("de"),
|
||||
svc.getSessionCookieForAccount("de"),
|
||||
svc.getSessionCookieForAccount("de"),
|
||||
]);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("başka bir süreç oturumu tutuyorsa Redis'ten devralır, login etmez", async () => {
|
||||
const redis = makeRedis();
|
||||
redis.store.set("pl24:auth:session:de", {
|
||||
sessionToken: "shared-token",
|
||||
loginAt: Date.now(),
|
||||
lastOkAt: Date.now(),
|
||||
});
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { svc } = makeService({}, redis);
|
||||
|
||||
expect(await svc.getSessionCookieForAccount("de")).toBe("PL24TOKEN=shared-token");
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("login sadece çerez mint eder; servis token'ı authorize'dan gelir (Bearer'sız)", async () => {
|
||||
const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
|
||||
if (String(url).includes("/login")) return loginOk();
|
||||
return authorizeOk();
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { svc } = makeService();
|
||||
|
||||
await svc.authorizeServiceForAccount("vw_parts", "de");
|
||||
|
||||
const authorizeCall = fetchSpy.mock.calls.find((c) => String(c[0]).includes("/authorize"));
|
||||
expect(authorizeCall).toBeTruthy();
|
||||
const headers = (authorizeCall?.[1] as { headers: Record<string, string> }).headers;
|
||||
expect(headers.Cookie).toBe("PL24TOKEN=sess-token-1");
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
expect(headers["User-Agent"]).toMatch(/Chrome\/\d/);
|
||||
});
|
||||
|
||||
it("servis token'ı süresi dolmadan yeniden authorize edilmez", async () => {
|
||||
const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
|
||||
if (String(url).includes("/login")) return loginOk();
|
||||
return authorizeOk();
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { svc } = makeService();
|
||||
|
||||
await svc.authorizeServiceForAccount("vw_parts", "de");
|
||||
await svc.authorizeServiceForAccount("vw_parts", "de");
|
||||
|
||||
const authorizeCalls = fetchSpy.mock.calls.filter((c) => String(c[0]).includes("/authorize"));
|
||||
expect(authorizeCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("session_status=gone → oturumu düşürür, bir kez yeniden login edip devam eder", async () => {
|
||||
let authorizeCalls = 0;
|
||||
const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
|
||||
if (String(url).includes("/login")) return loginOk(`sess-${authorizeCalls}`);
|
||||
authorizeCalls += 1;
|
||||
return authorizeCalls === 1 ? authorizeOk("vw_parts", "gone") : authorizeOk();
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { svc } = makeService();
|
||||
|
||||
const token = await svc.authorizeServiceForAccount("vw_parts", "de");
|
||||
|
||||
expect(token).toBeTruthy();
|
||||
expect(authorizeCalls).toBe(2);
|
||||
expect(fetchSpy.mock.calls.filter((c) => String(c[0]).includes("/login"))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("authorize 401 → oturumu düşürür ve tek sefer yeniden dener", async () => {
|
||||
let authorizeCalls = 0;
|
||||
const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
|
||||
if (String(url).includes("/login")) return loginOk();
|
||||
authorizeCalls += 1;
|
||||
if (authorizeCalls === 1) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: { get: () => null, getSetCookie: () => [] },
|
||||
json: async () => ({}),
|
||||
};
|
||||
}
|
||||
return authorizeOk();
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { svc } = makeService();
|
||||
|
||||
await expect(svc.authorizeServiceForAccount("vw_parts", "de")).resolves.toBeTruthy();
|
||||
expect(authorizeCalls).toBe(2);
|
||||
});
|
||||
|
||||
it("oturum sınırı 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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
141
apps/api/src/integrations/pl24/pl24-budget.service.spec.ts
Normal file
141
apps/api/src/integrations/pl24/pl24-budget.service.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { backfillContext } from "../../jobs/prefetch-context";
|
||||
import { PL24BudgetExceededError, PL24BudgetService } from "./pl24-budget.service";
|
||||
|
||||
// Günlük PL24 HTTP tavanı + kullanıcı rezervi + proxy_logs telemetrisi.
|
||||
// Redis ve telemetri stub'lanır; ağ yok.
|
||||
|
||||
const makeRedis = (opts: { fail?: boolean } = {}) => {
|
||||
const counters = new Map<string, number>();
|
||||
return {
|
||||
counters,
|
||||
incr: async (k: string) => {
|
||||
if (opts.fail) throw new Error("redis down");
|
||||
const n = (counters.get(k) ?? 0) + 1;
|
||||
counters.set(k, n);
|
||||
return n;
|
||||
},
|
||||
expire: async () => {},
|
||||
get: async (k: string) => (counters.has(k) ? String(counters.get(k)) : null),
|
||||
};
|
||||
};
|
||||
|
||||
const makeTelemetry = () => {
|
||||
const events: Record<string, unknown>[] = [];
|
||||
return { events, record: (e: Record<string, unknown>) => events.push(e) };
|
||||
};
|
||||
|
||||
const make = (env: Record<string, string> = {}, redis = makeRedis()) => {
|
||||
for (const [k, v] of Object.entries(env)) vi.stubEnv(k, v);
|
||||
const telemetry = makeTelemetry();
|
||||
return { svc: new PL24BudgetService(redis as never, telemetry as never), redis, telemetry };
|
||||
};
|
||||
|
||||
const inBackfill = <T>(fn: () => Promise<T>) => backfillContext.run(true, fn);
|
||||
|
||||
describe("PL24BudgetService — günlük tavan", () => {
|
||||
it("tavan altında çağrılara izin verir", async () => {
|
||||
const { svc } = make({ PL24_HTTP_DAILY_MAX: "5" });
|
||||
for (let i = 0; i < 5; i++) await svc.consume("catalog");
|
||||
expect(await svc.spentToday()).toBe(5);
|
||||
});
|
||||
|
||||
it("tavan aşılınca kullanıcı çağrısını da reddeder", async () => {
|
||||
const { svc } = make({ PL24_HTTP_DAILY_MAX: "3" });
|
||||
for (let i = 0; i < 3; i++) await svc.consume("catalog");
|
||||
await expect(svc.consume("catalog")).rejects.toBeInstanceOf(PL24BudgetExceededError);
|
||||
});
|
||||
|
||||
it("backfill kullanıcı rezervine dokunamaz, kullanıcı trafiği devam eder", async () => {
|
||||
// tavan 10, rezerv %40 → backfill 6'da durur, kullanıcı 10'a kadar sürer
|
||||
const { svc } = make({ PL24_HTTP_DAILY_MAX: "10", PL24_HTTP_USER_RESERVE: "0.4" });
|
||||
|
||||
for (let i = 0; i < 6; i++) await inBackfill(() => svc.consume("catalog"));
|
||||
await expect(inBackfill(() => svc.consume("catalog"))).rejects.toBeInstanceOf(
|
||||
PL24BudgetExceededError,
|
||||
);
|
||||
|
||||
// Aynı gün, aynı sayaç: kullanıcı hâlâ geçebilmeli (7., 8. … 10. istek)
|
||||
await expect(svc.consume("catalog")).resolves.toBeUndefined();
|
||||
await expect(svc.consume("catalog")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("reddedilen çağrı yalnız reddedildiği yerde sayılır (ağ isteği yok)", async () => {
|
||||
const { svc, telemetry } = make({ PL24_HTTP_DAILY_MAX: "1" });
|
||||
await svc.consume("catalog");
|
||||
await expect(svc.consume("catalog")).rejects.toBeInstanceOf(PL24BudgetExceededError);
|
||||
expect(telemetry.events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("Redis düşükse PL24'ü bloklamaz (fail-open)", async () => {
|
||||
const { svc } = make({ PL24_HTTP_DAILY_MAX: "1" }, makeRedis({ fail: true }));
|
||||
await expect(svc.consume("catalog")).resolves.toBeUndefined();
|
||||
await expect(svc.consume("catalog")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PL24BudgetService — telemetri", () => {
|
||||
it("katalog çağrısını proxy_logs biçiminde kaydeder", () => {
|
||||
const { svc, telemetry } = make();
|
||||
svc.record({
|
||||
kind: "catalog",
|
||||
url: "https://www.partslink24.com/p5psa/extern/group/vin/scope?vin=X",
|
||||
proxied: true,
|
||||
account: "de",
|
||||
statusCode: 200,
|
||||
success: true,
|
||||
startedAt: Date.now() - 120,
|
||||
});
|
||||
const e = telemetry.events[0];
|
||||
expect(e.service).toBe("pl24_http");
|
||||
expect(e.provider).toBe("dataimpulse");
|
||||
expect(e.targetHost).toBe("www.partslink24.com");
|
||||
expect(e.success).toBe(true);
|
||||
expect(e.errorKind).toBeNull();
|
||||
});
|
||||
|
||||
it("login çağrısını pl24_auth olarak ayırır", () => {
|
||||
const { svc, telemetry } = make();
|
||||
svc.record({
|
||||
kind: "auth",
|
||||
url: "https://www.partslink24.com/auth/ext/api/1.1/login",
|
||||
proxied: false,
|
||||
account: "tr",
|
||||
statusCode: 400,
|
||||
success: false,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
expect(telemetry.events[0].service).toBe("pl24_auth");
|
||||
expect(telemetry.events[0].provider).toBe("none");
|
||||
});
|
||||
|
||||
it("403/429'u ban sinyali olarak işaretler", () => {
|
||||
const { svc, telemetry } = make();
|
||||
svc.record({
|
||||
kind: "catalog",
|
||||
url: "https://www.partslink24.com/x",
|
||||
proxied: true,
|
||||
account: "de",
|
||||
statusCode: 403,
|
||||
success: false,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
expect(telemetry.events[0].errorKind).toBe("banned");
|
||||
});
|
||||
|
||||
it("taşıma hatasını sınıflandırır", () => {
|
||||
const { svc, telemetry } = make();
|
||||
const err = Object.assign(new Error("fetch failed"), { name: "TimeoutError" });
|
||||
svc.record({
|
||||
kind: "catalog",
|
||||
url: "https://www.partslink24.com/x",
|
||||
proxied: true,
|
||||
account: "de",
|
||||
success: false,
|
||||
startedAt: Date.now(),
|
||||
error: err,
|
||||
});
|
||||
expect(telemetry.events[0].errorKind).toBe("timeout");
|
||||
expect(telemetry.events[0].statusCode).toBeNull();
|
||||
});
|
||||
});
|
||||
137
apps/api/src/integrations/pl24/pl24-budget.service.ts
Normal file
137
apps/api/src/integrations/pl24/pl24-budget.service.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { isBackfillContext } from "../../jobs/prefetch-context";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import {
|
||||
ProxyTelemetryService,
|
||||
classifyTransportError,
|
||||
isBanStatus,
|
||||
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||
|
||||
/**
|
||||
* PL24 HTTP budget + telemetry.
|
||||
*
|
||||
* WHY (see /home/s/ss/plv2.md §2.2, findings consumers_jobs-03/07):
|
||||
* Both PL24 account bans (tr 2026-07-24, de 2026-09-04) followed days that wrote
|
||||
* 5-7k new category rows — roughly 10k+ upstream calls/day — while real user
|
||||
* decodes never exceeded 14/day. The existing guards are all job-level
|
||||
* (jobs/min, jobs/day); nothing counted actual HTTP requests, and PL24 calls
|
||||
* were invisible in `proxy_logs` (only pcat/emex were logged), so the volume was
|
||||
* only reconstructable after the fact from DB row counts.
|
||||
*
|
||||
* This service is the one choke point every PL24 upstream call passes through:
|
||||
* - counts requests per UTC day in Redis and refuses new ones past the cap,
|
||||
* - reserves a share of that cap for real users so a runaway backfill can
|
||||
* never starve a paying customer's decode,
|
||||
* - records every attempt into `proxy_logs` (service `pl24_http`/`pl24_auth`).
|
||||
*
|
||||
* The cap is deliberately low to start (PL24_HTTP_DAILY_MAX, default 1200);
|
||||
* raise it only with telemetry in hand.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PL24BudgetService {
|
||||
private readonly logger = new Logger(PL24BudgetService.name);
|
||||
/** Log "budget exhausted" once per day per lane instead of on every call. */
|
||||
private warnedFor = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly redis: RedisService,
|
||||
private readonly telemetry: ProxyTelemetryService,
|
||||
) {}
|
||||
|
||||
private get dailyMax(): number {
|
||||
const raw = Number(process.env.PL24_HTTP_DAILY_MAX);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 1200;
|
||||
}
|
||||
|
||||
/** Fraction of the daily cap that only user-facing calls may spend. */
|
||||
private get userReserve(): number {
|
||||
const raw = Number(process.env.PL24_HTTP_USER_RESERVE);
|
||||
return Number.isFinite(raw) && raw > 0 && raw < 1 ? raw : 0.4;
|
||||
}
|
||||
|
||||
private dayKey(): string {
|
||||
return `pl24:http:${new Date().toISOString().slice(0, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count one upstream call and decide whether it may proceed.
|
||||
* Backfill stops at (1 - userReserve) of the cap; user traffic gets the rest.
|
||||
*/
|
||||
async consume(kind: "auth" | "catalog"): Promise<void> {
|
||||
const backfill = isBackfillContext();
|
||||
let spent: number;
|
||||
try {
|
||||
const key = this.dayKey();
|
||||
spent = await this.redis.incr(key);
|
||||
if (spent === 1) await this.redis.expire(key, 8 * 86_400);
|
||||
} catch {
|
||||
return; // Redis down → never block PL24 on telemetry
|
||||
}
|
||||
|
||||
const max = this.dailyMax;
|
||||
const limit = backfill ? Math.floor(max * (1 - this.userReserve)) : max;
|
||||
if (spent > limit) {
|
||||
const lane = backfill ? "backfill" : "user";
|
||||
if (!this.warnedFor.has(`${lane}:${this.dayKey()}`)) {
|
||||
this.warnedFor.add(`${lane}:${this.dayKey()}`);
|
||||
this.logger.warn(
|
||||
`PL24 daily HTTP budget exhausted for the ${lane} lane (${spent}/${limit}, cap ${max}, kind=${kind}) — refusing further calls today`,
|
||||
);
|
||||
}
|
||||
throw new PL24BudgetExceededError(lane, spent, limit);
|
||||
}
|
||||
}
|
||||
|
||||
/** Current spend (for /health and the Süper Panel). */
|
||||
async spentToday(): Promise<number> {
|
||||
try {
|
||||
return Number((await this.redis.get(this.dayKey())) ?? 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
record(opts: {
|
||||
kind: "auth" | "catalog";
|
||||
url: string;
|
||||
proxied: boolean;
|
||||
account: string;
|
||||
statusCode?: number | null;
|
||||
success: boolean;
|
||||
startedAt: number;
|
||||
error?: unknown;
|
||||
}): void {
|
||||
let targetHost: string | null = null;
|
||||
try {
|
||||
targetHost = new URL(opts.url).hostname;
|
||||
} catch {
|
||||
/* keep null */
|
||||
}
|
||||
this.telemetry.record({
|
||||
service: opts.kind === "auth" ? "pl24_auth" : "pl24_http",
|
||||
provider: opts.proxied ? "dataimpulse" : "none",
|
||||
sessionKey: opts.account,
|
||||
targetHost,
|
||||
statusCode: opts.statusCode ?? null,
|
||||
errorKind: opts.error
|
||||
? classifyTransportError(opts.error)
|
||||
: isBanStatus(opts.statusCode)
|
||||
? "banned"
|
||||
: null,
|
||||
success: opts.success,
|
||||
durationMs: Date.now() - opts.startedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when the daily PL24 HTTP budget is used up for this lane. */
|
||||
export class PL24BudgetExceededError extends Error {
|
||||
constructor(
|
||||
readonly lane: "user" | "backfill",
|
||||
readonly spent: number,
|
||||
readonly limit: number,
|
||||
) {
|
||||
super(`PL24 daily HTTP budget exhausted (${lane} lane: ${spent}/${limit})`);
|
||||
this.name = "PL24BudgetExceededError";
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,20 @@ describe("PL24FordLegacyService.parseFordGroupsFromHtml — nav-crumb junk filte
|
||||
expect(groups.some((g) => g.linkPath.includes("eu.nissan.biz"))).toBe(false);
|
||||
});
|
||||
|
||||
it("appends the Hyundai illustration description to the bare figure code", () => {
|
||||
// Real Hyundai row shape: first <td> is the figure code (in a link), the
|
||||
// second <td> is the human description — which the parser used to drop.
|
||||
const html = [
|
||||
"<table>",
|
||||
'<tr class="tc-data-row" url="image-board.action?mainGroup=EN&subGroup=20-201A&illustration=1&lang=tr">' +
|
||||
'<td class="identifier"><a href="image-board.action?subGroup=20-201A">20-201A .1</a></td>' +
|
||||
"<td>SUB ENGINE ASSY</td><td></td></tr>",
|
||||
"</table>",
|
||||
].join("");
|
||||
const groups = pg.parseFordGroupsFromHtml(html, "hyundai_parts", "");
|
||||
expect(groups[0]?.nameTr).toBe("20-201A .1 SUB ENGINE ASSY");
|
||||
});
|
||||
|
||||
it("keeps Volvo vin-group.action?...group1=... (real category) but drops the bare breadcrumb", () => {
|
||||
const html = [
|
||||
'<tr class="tc-data-row" url="vin-group.action?group1=2&lang=tr" caption="Motor"><td>Motor</td></tr>',
|
||||
|
||||
@@ -116,7 +116,7 @@ export class PL24FordLegacyService {
|
||||
}
|
||||
|
||||
// Ford / Hyundai-Kia / Nissan / Opel / Volvo devam eder...
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
await this.authService.ensureSession(account);
|
||||
|
||||
const html = await this.fetchVinGroupPage(vin, serviceName, account);
|
||||
if (!html) return null;
|
||||
@@ -124,8 +124,8 @@ export class PL24FordLegacyService {
|
||||
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
||||
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
||||
this.logger.warn(`P4 legacy: demo mode for ${serviceName}, retrying with fresh auth`);
|
||||
this.authService.clearTokensForAccount(account);
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
await this.authService.dropSessionForAccount(account, "P4 page 401");
|
||||
await this.authService.ensureSession(account);
|
||||
|
||||
const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account);
|
||||
if (!retryHtml) return null;
|
||||
@@ -1110,7 +1110,7 @@ export class PL24FordLegacyService {
|
||||
this.logger.log(`Ford: fetching vehicle list for ${serviceName}`);
|
||||
|
||||
try {
|
||||
await this.authService.authorizeService(serviceName);
|
||||
await this.authService.ensureSession("tr");
|
||||
|
||||
const config = getServiceConfig(serviceName);
|
||||
const basePath = config ? `${config.basePath}/${serviceName}` : `/ford/${serviceName}`;
|
||||
@@ -1126,8 +1126,8 @@ export class PL24FordLegacyService {
|
||||
let support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
||||
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
||||
this.logger.warn(`Ford: demo mode for ${serviceName}, retrying with fresh auth`);
|
||||
this.authService.clearTokens();
|
||||
await this.authService.authorizeService(serviceName);
|
||||
await this.authService.dropSessionForAccount("tr", "P4 page 401");
|
||||
await this.authService.ensureSession("tr");
|
||||
const retryHtml = await this.fetchP4Page(vinGroupUrl, serviceName, true);
|
||||
if (!retryHtml) return [];
|
||||
const retrySupport = this.extractScriptVariable<FordPL24Support>(retryHtml, "PL24_SUPPORT");
|
||||
@@ -1221,7 +1221,7 @@ export class PL24FordLegacyService {
|
||||
this.logger.log(`HyundaiKia: fetching vehicle list for ${serviceName}`);
|
||||
|
||||
try {
|
||||
await this.authService.authorizeService(serviceName);
|
||||
await this.authService.ensureSession("tr");
|
||||
|
||||
const config = getServiceConfig(serviceName);
|
||||
const basePath = config
|
||||
@@ -1306,7 +1306,7 @@ export class PL24FordLegacyService {
|
||||
this.logger.log(`Nissan: fetching vehicle list for ${serviceName}`);
|
||||
|
||||
try {
|
||||
await this.authService.authorizeService(serviceName);
|
||||
await this.authService.ensureSession("tr");
|
||||
|
||||
const config = getServiceConfig(serviceName);
|
||||
const basePath = config ? `${config.basePath}/${serviceName}` : `/nissan/${serviceName}`;
|
||||
@@ -1377,7 +1377,7 @@ export class PL24FordLegacyService {
|
||||
this.logger.log(`Opel: fetching vehicle list for ${serviceName}`);
|
||||
|
||||
try {
|
||||
await this.authService.authorizeService(serviceName);
|
||||
await this.authService.ensureSession("tr");
|
||||
|
||||
const config = getServiceConfig(serviceName);
|
||||
const basePath = config ? `${config.basePath}/${serviceName}` : `/opel/${serviceName}`;
|
||||
@@ -1452,7 +1452,7 @@ export class PL24FordLegacyService {
|
||||
this.logger.log(`Volvo: fetching vehicle list for ${serviceName}`);
|
||||
|
||||
try {
|
||||
await this.authService.authorizeService(serviceName);
|
||||
await this.authService.ensureSession("tr");
|
||||
|
||||
const config = getServiceConfig(serviceName);
|
||||
const basePath = config ? `${config.basePath}/${serviceName}` : `/volvo/${serviceName}`;
|
||||
@@ -1556,7 +1556,7 @@ export class PL24FordLegacyService {
|
||||
|
||||
this.logger.log(`Volvo: fetching model config for ${serviceName} mdl=${mdlId}`);
|
||||
|
||||
await this.authService.authorizeService(serviceName);
|
||||
await this.authService.ensureSession("tr");
|
||||
|
||||
try {
|
||||
const config = getServiceConfig(serviceName);
|
||||
@@ -1626,7 +1626,7 @@ export class PL24FordLegacyService {
|
||||
|
||||
this.logger.log(`Ford: fetching model config for ${serviceName} family=${familyId}`);
|
||||
|
||||
await this.authService.authorizeService(serviceName);
|
||||
await this.authService.ensureSession("tr");
|
||||
|
||||
const config = getServiceConfig(serviceName);
|
||||
const arch = config?.architecture;
|
||||
@@ -1785,7 +1785,7 @@ export class PL24FordLegacyService {
|
||||
: `Ford: fetching main groups for ${serviceName} family=${familyId} year=${modelYear} engine=${engine} gearbox=${gearbox}`,
|
||||
);
|
||||
|
||||
await this.authService.authorizeService(serviceName);
|
||||
await this.authService.ensureSession("tr");
|
||||
|
||||
try {
|
||||
const basePath = config ? `${config.basePath}/${serviceName}` : `/ford/${serviceName}`;
|
||||
@@ -1917,6 +1917,10 @@ export class PL24FordLegacyService {
|
||||
* Fiat services always use 'de'; others use round-robin via Redis.
|
||||
*/
|
||||
private async resolveAccount(userId?: string, serviceName?: string): Promise<"tr" | "de"> {
|
||||
// PL24_TR_DISABLED=true → the tr account is inactive on PL24's side; route
|
||||
// everyone to de so page fetches, proxies and cache labels all agree with the
|
||||
// auth layer's tr→de mapping (PL24AuthService.effectiveAccount).
|
||||
if (process.env.PL24_TR_DISABLED === "true") return "de";
|
||||
// Fiat always needs de account; Hyundai/Kia/Nissan parts are licensed only on de.
|
||||
if (serviceName && ["fiatp_parts", "fiatt_parts"].includes(serviceName)) {
|
||||
return "de";
|
||||
@@ -1953,7 +1957,7 @@ export class PL24FordLegacyService {
|
||||
cacheKey: string,
|
||||
account: "tr" | "de",
|
||||
): Promise<PL24DecodedVehicle | null> {
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
await this.authService.ensureSession(account);
|
||||
|
||||
const html = await this.fetchVinGroupPage(vin, serviceName, account);
|
||||
if (!html) return null;
|
||||
@@ -1961,8 +1965,8 @@ export class PL24FordLegacyService {
|
||||
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
||||
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
||||
this.logger.warn(`Fiat: demo mode for ${serviceName}, retrying with fresh auth`);
|
||||
this.authService.clearTokensForAccount(account);
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
await this.authService.dropSessionForAccount(account, "P4 demo page (session not accepted)");
|
||||
await this.authService.ensureSession(account);
|
||||
|
||||
const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account);
|
||||
if (!retryHtml) return null;
|
||||
@@ -2889,7 +2893,7 @@ export class PL24FordLegacyService {
|
||||
private async initPsaSession(
|
||||
serviceName: string,
|
||||
): Promise<{ jsessionId: string; mode: string; upds: string } | null> {
|
||||
await this.authService.authorizeService(serviceName);
|
||||
await this.authService.ensureSession("tr");
|
||||
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||
|
||||
try {
|
||||
@@ -3324,7 +3328,7 @@ export class PL24FordLegacyService {
|
||||
const isDeOnly = LEGACY_DE_SERVICES.has(serviceName);
|
||||
const account = isDeOnly ? "de" : accountParam;
|
||||
if (isDeOnly) {
|
||||
await this.authService.authorizeServiceForAccount(serviceName, "de");
|
||||
await this.authService.ensureSession("de");
|
||||
}
|
||||
// Some catalogs (Volvo vin-group.action) store hrefs relative to the catalog
|
||||
// directory (e.g. "vin-group.action?group1=…"). Prefix the service basePath
|
||||
@@ -3354,8 +3358,8 @@ export class PL24FordLegacyService {
|
||||
|
||||
if (response.status === 401) {
|
||||
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
||||
this.authService.clearTokensForAccount(account);
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
await this.authService.dropSessionForAccount(account, "P4 page 401");
|
||||
await this.authService.ensureSession(account);
|
||||
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(
|
||||
serviceName,
|
||||
account,
|
||||
@@ -3388,8 +3392,11 @@ export class PL24FordLegacyService {
|
||||
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
||||
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
||||
this.logger.warn(`Ford legacy: demo page for ${serviceName}, re-authing + retry`);
|
||||
this.authService.clearTokensForAccount(account);
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
await this.authService.dropSessionForAccount(
|
||||
account,
|
||||
"P4 demo page (session not accepted)",
|
||||
);
|
||||
await this.authService.ensureSession(account);
|
||||
return this.fetchP4Page(url, serviceName, isFullUrl, account, true);
|
||||
}
|
||||
}
|
||||
@@ -3434,8 +3441,8 @@ export class PL24FordLegacyService {
|
||||
|
||||
if (response.status === 401) {
|
||||
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
||||
this.authService.clearTokensForAccount(account);
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
await this.authService.dropSessionForAccount(account, "P4 page 401");
|
||||
await this.authService.ensureSession(account);
|
||||
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(
|
||||
serviceName,
|
||||
account,
|
||||
@@ -3648,7 +3655,10 @@ export class PL24FordLegacyService {
|
||||
segment.match(/\bjsonurl="([^"]+)"/);
|
||||
if (!urlMatch) continue;
|
||||
|
||||
const url = urlMatch[1];
|
||||
// Row attributes are HTML-escaped (`&`), 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
|
||||
if (url.includes("vehicle.action")) continue;
|
||||
// Skip header/breadcrumb navigation links that leak into the group table as
|
||||
@@ -3657,15 +3667,25 @@ export class PL24FordLegacyService {
|
||||
// categories use group.action / group-detail.action / json-(main|sub)-group.action,
|
||||
// none of which match these. Volvo's vin-group.action?...group1=... is kept.
|
||||
if (/(portal|logout)\.action/i.test(url)) continue;
|
||||
if (url.includes("vin-group.action") && !url.includes("group1=")) continue;
|
||||
// Volvo's vin-group.action rows are model pickers unless they carry
|
||||
// group1=; Hyundai/Kia use the SAME endpoint for real main groups but key
|
||||
// them with mainGroup= (BO/CH/EL/EN/MI/TR). Keep both, drop the rest.
|
||||
if (
|
||||
url.includes("vin-group.action") &&
|
||||
!url.includes("group1=") &&
|
||||
!/[?&]mainGroup=/i.test(url)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (/^https?:\/\//i.test(url) && !/\.action(\?|$)/i.test(url)) continue;
|
||||
if (seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
|
||||
const captionMatch = segment.match(/\bcaption="([^"]+)"/);
|
||||
// Try to extract any text from <td> elements, including those with nested tags
|
||||
let tdName = "";
|
||||
// Collect all <td> texts (nested tags stripped). The first non-empty one
|
||||
// is the primary name for most brands.
|
||||
const tdPattern = /<td[^>]*>([\s\S]*?)<\/td>/g;
|
||||
const tdTexts: string[] = [];
|
||||
for (const tdMatch2 of segment.matchAll(tdPattern)) {
|
||||
const text = tdMatch2[1]
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
@@ -3674,10 +3694,22 @@ export class PL24FordLegacyService {
|
||||
.replace(/\xa0/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (text.length >= 3) {
|
||||
tdName = text;
|
||||
break;
|
||||
}
|
||||
if (text.length >= 2) tdTexts.push(text);
|
||||
}
|
||||
let tdName = tdTexts.find((t) => t.length >= 3) ?? "";
|
||||
// Hyundai/Kia illustration rows put a bare figure code in the first cell
|
||||
// ("09-091 .1") and the human description in the next ("SUB ENGINE ASSY").
|
||||
// When the chosen name is code-only, append the first later cell that has
|
||||
// real words so the leaf is nameable (and canonically mappable via ENGINE).
|
||||
// Figure code like "09-091", "20-201A .1" (optional letter suffix + " .N"),
|
||||
// or a purely numeric/dotted code.
|
||||
const isCodeOnly = (s: string) => {
|
||||
const t = s.trim();
|
||||
return /^\d{1,3}-\d{1,4}[a-z]?(\s*\.\s*\d+)?$/i.test(t) || /^[\d.\s-]+$/.test(t);
|
||||
};
|
||||
if (tdName && isCodeOnly(tdName)) {
|
||||
const desc = tdTexts.find((t) => t !== tdName && /[a-zA-ZğüşıöçĞÜŞİÖÇ]{3,}/.test(t));
|
||||
if (desc) tdName = `${tdName} ${desc}`.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
// Caption may contain HTML-encoded content (e.g. <div class="restrictionBlock"...>)
|
||||
@@ -4038,6 +4070,15 @@ export class PL24FordLegacyService {
|
||||
return [];
|
||||
});
|
||||
}
|
||||
// Opel/Vauxhall and Hyundai/Kia ship their main groups as `tc-data-row`
|
||||
// table rows whose link lives in a `url=`/`jsonurl=` ATTRIBUTE, not in an
|
||||
// <a href>. parseP4NavigationCategories only reads anchors, so these brands
|
||||
// decoded with ZERO categories (prod: Opel 169/173, Hyundai 11/11, Kia 5/5)
|
||||
// even though the page carries the full list. parseFordGroupsFromHtml
|
||||
// already understands those rows — it simply was never called from decode.
|
||||
if (categories.length === 0) {
|
||||
categories = this.parseFordGroupsFromHtml(html, serviceName, "");
|
||||
}
|
||||
if (categories.length === 0) {
|
||||
categories = this.parseP4NavigationCategories(html);
|
||||
}
|
||||
|
||||
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 ====================
|
||||
|
||||
/**
|
||||
* PSA requests must leave from the same egress as their auth account: under
|
||||
* PL24_TR_DISABLED the legacy "tr" auth maps to de, whose session lives behind
|
||||
* the DE proxy — the auth service picks the dispatcher (null for real tr).
|
||||
*/
|
||||
private async psaDispatcher(): Promise<any | null> {
|
||||
return this.authService.getProxyAgent4Account("tr");
|
||||
}
|
||||
|
||||
/**
|
||||
* entry.action → startup=true (302) → 302 with mode+upds. Returns JSESSIONID/mode/upds.
|
||||
*/
|
||||
private async initPsaSession(serviceName: string): Promise<PsaSession | null> {
|
||||
await this.authService.authorizeService(serviceName);
|
||||
// P4 PSA pages authenticate off the session cookie alone — no service token.
|
||||
await this.authService.ensureSession("tr");
|
||||
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||
const dispatcher = await this.psaDispatcher();
|
||||
const withDispatcher = (o: RequestInit): RequestInit & { dispatcher?: any } =>
|
||||
dispatcher ? { ...o, dispatcher } : o;
|
||||
try {
|
||||
const entryRes = await fetch(`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
const entryRes = await fetch(
|
||||
`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`,
|
||||
withDispatcher({
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
}),
|
||||
);
|
||||
const jsessionId = entryRes.headers.get("set-cookie")?.match(/JSESSIONID=([^;]+)/)?.[1] || "";
|
||||
const loc1 = entryRes.headers.get("location");
|
||||
if (!loc1) return null;
|
||||
const hdrs2 = jsessionId
|
||||
? { ...headers, Cookie: `${headers.Cookie}; JSESSIONID=${jsessionId}` }
|
||||
: headers;
|
||||
const startupRes = await fetch(loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`, {
|
||||
method: "GET",
|
||||
headers: hdrs2,
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
const startupRes = await fetch(
|
||||
loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`,
|
||||
withDispatcher({
|
||||
method: "GET",
|
||||
headers: hdrs2,
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
}),
|
||||
);
|
||||
const loc2 = startupRes.headers.get("location") || loc1;
|
||||
const mode = loc2.match(/[?&]mode=([^&]+)/)?.[1] || "";
|
||||
// Keep upds URL-encoded exactly as returned (e.g. "2024.02.13+09%3A27%3A21+CET");
|
||||
@@ -494,12 +513,15 @@ export class PL24PsaService {
|
||||
Accept: opts.json ? "application/json,*/*" : "text/html,application/xhtml+xml,*/*;q=0.9",
|
||||
};
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
const dispatcher = await this.psaDispatcher();
|
||||
const fetchOpts: RequestInit & { dispatcher?: any } = {
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: opts.manual ? "manual" : "follow",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
};
|
||||
if (dispatcher) fetchOpts.dispatcher = dispatcher;
|
||||
const res = await fetch(url, fetchOpts);
|
||||
const location = res.headers.get("location");
|
||||
if (opts.manual && res.status >= 300 && res.status < 400) {
|
||||
return { status: res.status, text: null, location };
|
||||
@@ -543,13 +565,19 @@ export class PL24PsaService {
|
||||
Accept: "application/json,image/*,*/*;q=0.9",
|
||||
Referer: `${this.baseUrl}/psa/${serviceName}/vin-image-board.action`,
|
||||
};
|
||||
const dispatcher = await this.psaDispatcher();
|
||||
const withDispatcher = (o: RequestInit): RequestInit & { dispatcher?: any } =>
|
||||
dispatcher ? { ...o, dispatcher } : o;
|
||||
try {
|
||||
const infoRes = await fetch(`${imageUrl}&request=GetImageInfo&cv=1`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
const infoRes = await fetch(
|
||||
`${imageUrl}&request=GetImageInfo&cv=1`,
|
||||
withDispatcher({
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
}),
|
||||
);
|
||||
if (!infoRes.ok) return null;
|
||||
const info = (await infoRes.json()) as {
|
||||
imageWidth: number;
|
||||
@@ -570,12 +598,15 @@ export class PL24PsaService {
|
||||
const getImgUrl =
|
||||
`${imageUrl}&request=GetImage&format=image%2Fpng` +
|
||||
`&bbox=${encodeURIComponent(`0,0,${w},${h}`)}&width=${w}&height=${h}&scalefac=1.0&cv=1&rnd=${rnd}`;
|
||||
const imgRes = await fetch(getImgUrl, {
|
||||
method: "GET",
|
||||
headers: { ...headers, Accept: "image/png,image/*,*/*;q=0.9" },
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
const imgRes = await fetch(
|
||||
getImgUrl,
|
||||
withDispatcher({
|
||||
method: "GET",
|
||||
headers: { ...headers, Accept: "image/png,image/*,*/*;q=0.9" },
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
}),
|
||||
);
|
||||
if (!imgRes.ok) return null;
|
||||
const contentType = imgRes.headers.get("content-type") || "image/png";
|
||||
const buffer = Buffer.from(await imgRes.arrayBuffer());
|
||||
|
||||
112
apps/api/src/integrations/pl24/pl24-tree.spec.ts
Normal file
112
apps/api/src/integrations/pl24/pl24-tree.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isPl24GroupNode, isPl24LeafNode } from "./pl24-tree";
|
||||
|
||||
// Canlı P5 yanıtlarından (2026-09-16 keşfi, plv2-artefakt/) alınan gerçek
|
||||
// wid + path çiftleri. Bu dosya "0 parça" sınıfı hatanın regresyon kilidi.
|
||||
|
||||
describe("isPl24LeafNode — canlı P5 şekilleri", () => {
|
||||
it("VW (p5vwag): maingroups/subgroups grup, bom/vin yaprak", () => {
|
||||
expect(
|
||||
isPl24GroupNode({
|
||||
linkWid: "mainGroupsTable",
|
||||
linkPath: "/p5vwag/extern/groups/vin_maingroups?vin=X",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPl24GroupNode({
|
||||
linkWid: "subGroupsIllusTable",
|
||||
linkPath: "/p5vwag/extern/groups/vin_subgroups_illus?maingroup=4",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPl24LeafNode({
|
||||
linkWid: "bomlist",
|
||||
linkPath: "/p5vwag/extern/bom/vin?illustration=407-000",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("PSA (p5psa): scope + mainGroups + illusTable grup, bomDetails yaprak", () => {
|
||||
expect(
|
||||
isPl24GroupNode({
|
||||
linkWid: "scopeTable",
|
||||
linkPath: "/p5psa/extern/group/vin/scope?modelCode=1PD2",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPl24GroupNode({
|
||||
linkWid: "mainGroupTable",
|
||||
linkPath: "/p5psa/extern/group/vin/mainGroups?scope=_FCT0001",
|
||||
}),
|
||||
).toBe(true);
|
||||
// Eski `includes("group")` kuralının kaçırdığı seviye — sessiz boş panelin kaynağı.
|
||||
expect(
|
||||
isPl24GroupNode({
|
||||
linkWid: "illusTable",
|
||||
linkPath: "/p5psa/extern/group/vin/illus?mainGroup=_FCT0001_FCT0512",
|
||||
}),
|
||||
).toBe(true);
|
||||
// camelCase bomDetails — eski `includes("/bomdetails")` kaçırıyordu.
|
||||
expect(
|
||||
isPl24LeafNode({
|
||||
linkWid: "bomlist",
|
||||
linkPath: "/p5psa/extern/details/vin/bomDetails?illustration=D2F001A48A",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("Volvo (p5volvo): illustrationsTable grup, bom yaprak", () => {
|
||||
expect(
|
||||
isPl24GroupNode({
|
||||
linkWid: "illustrationsTable",
|
||||
linkPath: "/p5volvo/extern/groups/vin/illustration?group=0b00c8af80205942",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isPl24LeafNode({ linkWid: "bomlist", linkPath: "/p5volvo/extern/bom/vin?..." })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isPl24LeafNode({
|
||||
linkWid: "partinfo",
|
||||
linkPath: "/p5volvo/extern/partinfo/vin?partno=36050493",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("Subaru (p5subaru): subgroups/illustrations grup, bom/vin?figNum yaprak", () => {
|
||||
expect(
|
||||
isPl24GroupNode({
|
||||
linkWid: "illustrationsTable",
|
||||
linkPath: "/p5subaru/extern/groups/vin/illustrations?subGroup=001",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPl24LeafNode({ linkWid: "bomlist", linkPath: "/p5subaru/extern/bom/vin?figNum=01" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("wid yoksa yol kalıbına düşer (eski DB satırları)", () => {
|
||||
expect(isPl24LeafNode({ linkPath: "/p5vwag/extern/bom/vin?illustration=1" })).toBe(true);
|
||||
expect(
|
||||
isPl24LeafNode({ linkPath: "/psa/peugeot_parts/vin-image-board.action?illCode=1" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPl24GroupNode({
|
||||
linkPath: "/psa/peugeot_parts/json-vin-main-groups.action?scope=_FCT0001",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hasSubgroups=true yol tahminini ezer", () => {
|
||||
expect(isPl24LeafNode({ linkPath: "/p5x/extern/unknown", hasSubgroups: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("servicepart öğe listesi yapraktır", () => {
|
||||
expect(
|
||||
isPl24LeafNode({
|
||||
linkWid: "servicePartsItemsTable",
|
||||
linkPath: "/p5vwag/extern/servicepart/vin_items?x=1",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
65
apps/api/src/integrations/pl24/pl24-tree.ts
Normal file
65
apps/api/src/integrations/pl24/pl24-tree.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* One source of truth for "is this PL24 node a parts leaf or a group to drill?".
|
||||
*
|
||||
* WHY THIS FILE EXISTS (plv2.md, findings p5core-02 / psa-05 / consumers_jobs-01):
|
||||
* the same question was answered by four independent heuristics —
|
||||
* `categories.service` (twice), `catalog.service` and the prefetch worker — and
|
||||
* they disagreed. Two failure modes shipped repeatedly:
|
||||
*
|
||||
* 1. `linkWid.includes("group")` as the "is a group" test. Live P5 PSA's second
|
||||
* level has wid `illusTable`, Volvo's and Subaru's `illustrationsTable` —
|
||||
* none contain "group", so those nodes fell through to the parts fetcher,
|
||||
* which asked an *illustration list* for parts, got records with no partno,
|
||||
* and rendered a silent empty panel (`parts: []`, no error). This is the same
|
||||
* class of bug as the 2026-06 `isPsaParent` incident.
|
||||
* 2. Case-sensitive `includes("/bomdetails")` while p5psa and p5volvo spell the
|
||||
* endpoint `/details/vin/bomDetails` — so those leaves were queued as groups,
|
||||
* `getChildren` returned [] and their parts were never prefetched (live today
|
||||
* for Mitsubishi 99.9% / Fiat 80% / Renault 70% of bomDetails leaves).
|
||||
*
|
||||
* The reliable cross-brand marker is the response's own `link.wid`: `bomlist`
|
||||
* (and the service-parts/partinfo variants) means parts, anything else means
|
||||
* drill. Path matching stays as a fallback for stored rows without a wid.
|
||||
*/
|
||||
|
||||
/** `link.wid` values that identify a parts (BOM) node across every P5 backend. */
|
||||
const LEAF_WIDS = new Set(["bomlist", "bomoverviewlist", "servicepartsitemstable", "partinfo"]);
|
||||
|
||||
/** `link.wid` values that identify a drillable group node. */
|
||||
const GROUP_WID_PATTERN =
|
||||
/(group|scope|illus|illustration|catalog|model|vpages|msppages|chemicals|category|categories)/i;
|
||||
|
||||
/**
|
||||
* Path fragments that only ever appear on a parts endpoint. `image-board` has no
|
||||
* leading slash on purpose: the legacy PSA leaf is `vin-image-board.action`.
|
||||
*/
|
||||
const LEAF_PATH_PATTERN =
|
||||
/\/(bom|bomdetails|partinfo|vin_items|mdl_items|vin_bomdetails)\b|\/bom\/|\/details\/vin\/bomdetails|\/servicepart\/vin_items|image-board/i;
|
||||
|
||||
/** True when this node yields parts (never children). */
|
||||
export function isPl24LeafNode(opts: {
|
||||
linkPath?: string | null;
|
||||
linkWid?: string | null;
|
||||
hasSubgroups?: boolean | null;
|
||||
}): boolean {
|
||||
const wid = opts.linkWid?.toLowerCase().trim();
|
||||
if (wid) {
|
||||
if (LEAF_WIDS.has(wid)) return true;
|
||||
if (GROUP_WID_PATTERN.test(wid)) return false;
|
||||
}
|
||||
// Explicit DB hint wins over path guessing when there is no usable wid.
|
||||
if (opts.hasSubgroups === true) return false;
|
||||
const lp = opts.linkPath?.toLowerCase() ?? "";
|
||||
if (!lp) return false;
|
||||
return LEAF_PATH_PATTERN.test(lp);
|
||||
}
|
||||
|
||||
/** True when this node should be drilled for children. */
|
||||
export function isPl24GroupNode(opts: {
|
||||
linkPath?: string | null;
|
||||
linkWid?: string | null;
|
||||
hasSubgroups?: boolean | null;
|
||||
}): boolean {
|
||||
if (!opts.linkPath && !opts.linkWid) return false;
|
||||
return !isPl24LeafNode(opts);
|
||||
}
|
||||
@@ -1,13 +1,29 @@
|
||||
export const PL24_DEFAULTS = {
|
||||
AUTH_TOKEN_TTL: 3600, // 1 hour in seconds
|
||||
/** Service JWTs live 600s (measured); refresh this many ms before expiry. */
|
||||
SERVICE_TOKEN_SKEW_MS: 60_000,
|
||||
/** PL24TOKEN is a session cookie with no expiry — we keep our copy for a day. */
|
||||
SESSION_TTL_S: 86_400,
|
||||
CACHE_PREFIX: "pl24:",
|
||||
REQUEST_TIMEOUT: 30000,
|
||||
MAX_RETRIES: 3,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Real Chrome UA. The old value ("…AppleWebKit/537.36" with no Chrome/Safari
|
||||
* token) matches no real browser and is a cheap automation tell.
|
||||
*/
|
||||
export const PL24_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
|
||||
|
||||
export const PL24_ENDPOINTS = {
|
||||
// Auth
|
||||
LOGIN: "/pl24-appgtw/ext/api/1.0/login",
|
||||
// Auth — LOGIN is the portal/landing endpoint (returns {loginStatus, sessionToken}
|
||||
// + Set-Cookie PL24TOKEN). LOGIN_LEGACY is the in-SPA one our old code used; it
|
||||
// still works and returns a 600s base JWT we no longer need. PL24_LOGIN_API=legacy
|
||||
// switches back for one release.
|
||||
LOGIN: "/auth/ext/api/1.1/login",
|
||||
LOGIN_LEGACY: "/pl24-appgtw/ext/api/1.0/login",
|
||||
LOGOUT_LEGACY: "/pl24-appgtw/ext/api/1.0/logout",
|
||||
SESSION: "/auth/ext/api/1.1/session",
|
||||
AUTHORIZE: "/auth/ext/api/1.1/authorize",
|
||||
|
||||
// Catalog
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ProxyTelemetryModule } from "../proxy-telemetry/proxy-telemetry.module";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { PL24BudgetService } from "./pl24-budget.service";
|
||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||
import { PL24FordService } from "./pl24-ford.service";
|
||||
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
|
||||
@@ -11,6 +13,7 @@ import { PL24Service } from "./pl24.service";
|
||||
const PL24_PROVIDERS = [
|
||||
PL24Service,
|
||||
PL24AuthService,
|
||||
PL24BudgetService,
|
||||
PL24FordLegacyService,
|
||||
PL24PsaService,
|
||||
PL24VolvoService,
|
||||
@@ -20,6 +23,7 @@ const PL24_PROVIDERS = [
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [ProxyTelemetryModule],
|
||||
providers: PL24_PROVIDERS,
|
||||
exports: PL24_PROVIDERS,
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ const svc = new PL24Service(
|
||||
{} as never, // redis
|
||||
{} as never, // storage
|
||||
{} as never, // posthog
|
||||
{} as never, // budget
|
||||
);
|
||||
const p = svc as unknown as {
|
||||
parseVehicleResponse(
|
||||
|
||||
@@ -20,6 +20,7 @@ import { PostHogService } from "../../posthog/posthog.service";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import { StorageService } from "../../storage/storage.service";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { PL24BudgetService } from "./pl24-budget.service";
|
||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||
import { PL24FordService } from "./pl24-ford.service";
|
||||
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
|
||||
@@ -55,6 +56,58 @@ const LEGACY_ARCH_SOURCE_TAG: Record<string, string> = {
|
||||
LEGACY_HYUNDAI_KIA: "hyundai-kia",
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalise a vinfoBasic label into a lookup key.
|
||||
*
|
||||
* JS `toLowerCase()` maps Turkish "İ" to "i" + U+0307 (combining dot), so PSA
|
||||
* labels like "AKTARMA SİSTEMLERİ" / "GÖVDE TİPİ" produced keys no lookup could
|
||||
* ever match and the transmission/body fields silently stayed null. Lower-case
|
||||
* with the Turkish locale, then strip combining marks and fold "ı" → "i" so a
|
||||
* single spelling matches both "Model yılı" and "MODEL YILI".
|
||||
*/
|
||||
export function normalizeLabel(label: string): string {
|
||||
return label
|
||||
.toLocaleLowerCase("tr")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/ı/g, "i")
|
||||
.replace(/[\s/]+/g, "_")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* PSA model year: "AM 2005" → 2005. Index labels ("01 MAJÖR ENDEKS",
|
||||
* 'MAJÖR ENDEKS "0C"') are NOT years — returning one there is how a Citroën
|
||||
* ended up as a 2001 model. PSA VINs also don't encode the model year in
|
||||
* position 10, so the caller must fall back to DAM or leave it null.
|
||||
*/
|
||||
export function parsePsaModelYear(value: string | null | undefined): number | null {
|
||||
if (!value) return null;
|
||||
if (/endeks/i.test(value)) return null;
|
||||
const m = value.match(/\b(?:AM\s*)?((?:19|20)\d{2})\b/i);
|
||||
if (m) return Number(m[1]);
|
||||
const short = value.match(/^\s*AM\s*(\d{2})\s*$/i);
|
||||
if (short) {
|
||||
const n = Number(short[1]);
|
||||
return n >= 70 ? 1900 + n : 2000 + n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PSA "DAM" (e.g. "10479CJ") = days since 1976-01-01 + plant code → build date.
|
||||
* PSA model years roll in July, so a build after June belongs to the next one.
|
||||
*/
|
||||
export function damToModelYear(dam: string | null | undefined): number | null {
|
||||
if (!dam) return null;
|
||||
const m = dam.match(/^(\d{4,5})/);
|
||||
if (!m) return null;
|
||||
const date = new Date(Date.UTC(1976, 0, 1) + Number(m[1]) * 86_400_000);
|
||||
const year = date.getUTCFullYear();
|
||||
if (year < 1980 || year > 2100) return null;
|
||||
return date.getUTCMonth() >= 6 ? year + 1 : year;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PL24Service {
|
||||
private readonly logger = new Logger(PL24Service.name);
|
||||
@@ -74,6 +127,7 @@ export class PL24Service {
|
||||
private redis: RedisService,
|
||||
private storage: StorageService,
|
||||
private posthog: PostHogService,
|
||||
private readonly budget: PL24BudgetService,
|
||||
) {
|
||||
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
|
||||
this.timeout = 30000;
|
||||
@@ -944,15 +998,28 @@ export class PL24Service {
|
||||
return opts;
|
||||
};
|
||||
|
||||
const response = await fetch(url, buildOpts(headers));
|
||||
// Every PL24 upstream call is counted and logged here (the one choke point).
|
||||
const response = await this.budgetedFetch(
|
||||
url,
|
||||
buildOpts(headers),
|
||||
account,
|
||||
Boolean(dispatcher),
|
||||
);
|
||||
|
||||
if (response.status === 401) {
|
||||
this.logger.warn(`Got 401 (account=${account}), refreshing token...`);
|
||||
this.logger.warn(`Got 401 (account=${account}), refreshing service token...`);
|
||||
// A stale *service* token is the cheap explanation; the session itself is
|
||||
// only dropped if the re-authorize also fails (handled in the auth service).
|
||||
this.authService.clearTokensForAccount(account);
|
||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||
const newHeaders = await this.authService.buildAuthHeadersForAccount(account, serviceName);
|
||||
|
||||
const retry = await fetch(url, buildOpts(newHeaders));
|
||||
const retry = await this.budgetedFetch(
|
||||
url,
|
||||
buildOpts(newHeaders),
|
||||
account,
|
||||
Boolean(dispatcher),
|
||||
);
|
||||
|
||||
if (!retry.ok) {
|
||||
throw new Error(`HTTP ${retry.status}: ${retry.statusText}`);
|
||||
@@ -971,6 +1038,50 @@ export class PL24Service {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single gate for PL24 upstream traffic: daily budget first (a refusal costs
|
||||
* no request at all), then the call, then telemetry into `proxy_logs`.
|
||||
*/
|
||||
private async budgetedFetch(
|
||||
url: string,
|
||||
opts: RequestInit & { dispatcher?: any },
|
||||
account: "tr" | "de",
|
||||
proxied: boolean,
|
||||
): Promise<Response> {
|
||||
// The kill switch used to gate decodeVin only, so drills, backfill and
|
||||
// catalog browse kept hammering PL24 after the source was "killed".
|
||||
if (!(await this.posthog.isSourceLive("pl24"))) {
|
||||
throw new ServiceUnavailableException("PL24 kapali (kill-source-pl24)");
|
||||
}
|
||||
await this.budget.consume("catalog");
|
||||
const startedAt = Date.now();
|
||||
const activeAccount = this.authService.activeAccount(account);
|
||||
try {
|
||||
const response = await fetch(url, opts);
|
||||
this.budget.record({
|
||||
kind: "catalog",
|
||||
url,
|
||||
proxied,
|
||||
account: activeAccount,
|
||||
statusCode: response.status,
|
||||
success: response.ok,
|
||||
startedAt,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.budget.record({
|
||||
kind: "catalog",
|
||||
url,
|
||||
proxied,
|
||||
account: activeAccount,
|
||||
success: false,
|
||||
startedAt,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: Account routing ====================
|
||||
|
||||
/**
|
||||
@@ -1051,7 +1162,7 @@ export class PL24Service {
|
||||
const label = (v.key !== undefined ? v.key : v.description) || "";
|
||||
const value = (v.key !== undefined ? v.description : v.value) || "";
|
||||
if (!label) continue;
|
||||
const key = label.toLowerCase().replace(/[\s\/]+/g, "_");
|
||||
const key = normalizeLabel(label);
|
||||
if (!(key in vehicleData)) {
|
||||
// Normalize like prNr col3: newlines→space, unescape the literal "\-" some P5
|
||||
// backends emit (JLR "XJ 2010 \- 2019", Toyota/Suzuki dates "2023\-11\-29"/"2005\-07"),
|
||||
@@ -1098,26 +1209,40 @@ export class PL24Service {
|
||||
const engineCode = lookup("motor_kodu", "engine_code");
|
||||
// Non-VAG P5 OEMs label the engine differently and give a description (sometimes with a
|
||||
// code in parens): JLR "Motor Tipi", Toyota "ENGINE 1", MAN "Yedek motor", Suzuki "Motor No.".
|
||||
const engineLabel = lookup("motor_tipi", "engine_1", "yedek_motor", "motor_no.");
|
||||
// PSA labels the full designation "MOTOR" ("TÜRBO DİZEL DV6TED4…"), Volvo
|
||||
// "Motor" ("D4162T"), Subaru "Engine" — none of which the VAG-shaped list had.
|
||||
const engineLabel = lookup(
|
||||
"motor_tipi",
|
||||
"engine_1",
|
||||
"yedek_motor",
|
||||
"motor_no.",
|
||||
"motor",
|
||||
"engine",
|
||||
);
|
||||
|
||||
// Transmission: VAG "Şanzıman kodu"; other OEMs use their own labels — JLR "Vites Kutusu",
|
||||
// Toyota "ATM,MTM" (key "atm,mtm" — only spaces/slashes are underscored), MAN "Şanzıman",
|
||||
// Suzuki "Şanzıman numarası".
|
||||
// normalizeLabel folds ı→i and strips diacritics, so "Şanzıman kodu" and
|
||||
// "ŞANZIMAN KODU" both arrive as "sanziman_kodu". PSA uses "AKTARMA
|
||||
// SİSTEMLERİ" ("5 MEKANİK VİTES KUTUSU"), Subaru "Mission".
|
||||
const transmissionCode = lookup(
|
||||
"şanzıman_kodu",
|
||||
"sanzıman_kodu",
|
||||
"sanziman_kodu",
|
||||
"transmission_code",
|
||||
"vites_kutusu",
|
||||
"atm,mtm",
|
||||
"şanzıman",
|
||||
"şanzıman_numarası",
|
||||
"aktarma_sistemleri",
|
||||
"sanziman",
|
||||
"sanziman_numarasi",
|
||||
"mission",
|
||||
);
|
||||
|
||||
// Build body type from prNr K8* (Kaporta formları); brands without a prNr segment
|
||||
// (e.g. BMW) carry it in vinfoBasic "Karoseri" ("Limousine").
|
||||
const bodyType =
|
||||
Object.entries(prNrByCode).find(([code]) => code.startsWith("K8"))?.[1] ||
|
||||
lookup("karoseri", "body", "body_type") ||
|
||||
// PSA "GÖVDE TİPİ" ("4 KAPILI SEDAN"), Volvo "Kaporta Stili" ("Sedan").
|
||||
lookup("karoseri", "body", "body_type", "govde_tipi", "kaporta_stili") ||
|
||||
null;
|
||||
|
||||
// Engine description from prNr D3* (Motor nitelikleri)
|
||||
@@ -1140,7 +1265,7 @@ export class PL24Service {
|
||||
// the friendly name is in "Araç" / description ("L200(EUR/MMTH)") — strip the region suffix.
|
||||
const isMitsubishi = getServiceApiPath(serviceName) === "/p5mitsubishi";
|
||||
const baseModel = isMitsubishi
|
||||
? (lookup("araç") || (data.description as string) || lookup("model") || "")
|
||||
? (lookup("arac") || (data.description as string) || lookup("model") || "")
|
||||
.replace(/\s*\([^)]*\)\s*$/, "")
|
||||
.trim()
|
||||
: lookup("model_bilgisi", "model")?.trim() ||
|
||||
@@ -1163,17 +1288,20 @@ export class PL24Service {
|
||||
// production date ("05/09/2014"); p5daimler → only "Teslimat tarihi" (delivery, "04.05.2009").
|
||||
// Falling through to the VIN year-char (extractModelYear) is the last resort and is often
|
||||
// wrong for Mercedes (10th-char "1" → 2001 for a 2015 car), so read the dates first.
|
||||
// PSA writes "AM 2005" (or an index label that is NOT a year) and its VINs
|
||||
// do not encode the model year in position 10, so parse the PSA forms
|
||||
// first and fall back to the DAM build date before ever touching the VIN.
|
||||
year:
|
||||
parsePsaModelYear(lookup("model_yili", "year")) ||
|
||||
Number.parseInt(lookup("model_yili", "year") || "", 10) ||
|
||||
Number.parseInt(
|
||||
(lookup("my", "üretim_tarihi", "uretim_tarihi", "teslimat_tarihi") || "").match(
|
||||
/(19|20)\d{2}/,
|
||||
)?.[0] || "",
|
||||
(lookup("my", "uretim_tarihi", "teslimat_tarihi") || "").match(/(19|20)\d{2}/)?.[0] || "",
|
||||
10,
|
||||
) ||
|
||||
damToModelYear(lookup("dam")) ||
|
||||
extractModelYear(vin) ||
|
||||
0,
|
||||
series: lookup("seri", "satis_tipi", "sales_type"),
|
||||
series: lookup("seri", "satis_tipi", "sales_type", "turu"),
|
||||
bodyType,
|
||||
engineCode:
|
||||
engineCode ||
|
||||
@@ -1187,7 +1315,7 @@ export class PL24Service {
|
||||
colorCode:
|
||||
lookup("dis_rengi_boya_numarasi", "exterior_color___paint_code") ||
|
||||
lookup("tavan_rengi", "roof_color"),
|
||||
productionDate: lookup("üretim_tarihi", "date_of_production"),
|
||||
productionDate: lookup("uretim_tarihi", "date_of_production", "teslimat_tarihi"),
|
||||
raw: data,
|
||||
catalogInfo: {
|
||||
serviceName,
|
||||
@@ -1352,9 +1480,14 @@ export class PL24Service {
|
||||
const values = (record.values as Record<string, string>) || {};
|
||||
const link = (record.link as Record<string, unknown>) || {};
|
||||
|
||||
// PSA (p5psa) shares one `record.id` (the illusPath) across many
|
||||
// illustrations — 36 live records had only 11 distinct ids — so the unique
|
||||
// key is `values.illustration` ("D2F 0 01A 48A", spaces stripped). Without
|
||||
// this the children collapse onto each other and most get dropped.
|
||||
const code =
|
||||
values.subgroup ||
|
||||
values.illustrationNumber ||
|
||||
values.illustration?.replace(/\s+/g, "") ||
|
||||
values.id ||
|
||||
values.code ||
|
||||
String(record.id || "");
|
||||
@@ -1432,11 +1565,14 @@ export class PL24Service {
|
||||
records = responseData.parts as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
const partRecords = records.filter(
|
||||
(record) =>
|
||||
record.characteristic !== "sectionrow" &&
|
||||
(record.partno || (record.values as Record<string, unknown>)?.partno),
|
||||
);
|
||||
const partRecords = records.filter((record) => {
|
||||
if (record.characteristic === "sectionrow") return false;
|
||||
if (!(record.partno || (record.values as Record<string, unknown>)?.partno)) return false;
|
||||
// Volvo (p5volvo) prefixes each BOM with a header row that carries a
|
||||
// partno but no link and is flagged unavailable — a phantom part if kept.
|
||||
if (record.id === "null_null" || (record.unavailable === true && !record.link)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return partRecords.map((part) => {
|
||||
const values = (part.values as Record<string, string>) || {};
|
||||
@@ -1444,11 +1580,14 @@ export class PL24Service {
|
||||
const formattedPartNo = ((part.partno as string) || values.partno || "").trim();
|
||||
const cleanPartNo = formattedPartNo.replace(/\s+/g, "");
|
||||
|
||||
const qtyStr = values.qty || "";
|
||||
// qty (VAG/Subaru) · coef (PSA) · unit (Volvo) — same field, three names.
|
||||
const qtyStr = values.qty || values.coef || values.unit || "";
|
||||
const quantity = Number.parseInt(qtyStr.trim(), 10) || undefined;
|
||||
|
||||
const remark = values.remark?.trim() || undefined;
|
||||
const modelCodes = values.modelDescription?.trim() || undefined;
|
||||
// PSA/Volvo/Subaru express applicability as `restriction`
|
||||
// ("+ DIESEL TURBO DV6TED4 WITHOUT FAP"); VAG uses modelDescription.
|
||||
const modelCodes = (values.modelDescription || values.restriction)?.trim() || undefined;
|
||||
|
||||
let superseded: { oldCode: string; newCode: string } | undefined;
|
||||
const supersededByValue = (part.supersededBy as string) || values.supersededBy || "";
|
||||
|
||||
@@ -53,6 +53,58 @@ export interface PL24JWTPayload {
|
||||
alo: string;
|
||||
}
|
||||
|
||||
/** Portal login (`/auth/ext/api/1.1/login`) request + response. */
|
||||
export interface PL24LoginRequestV2 {
|
||||
account: string;
|
||||
user: string;
|
||||
password: string;
|
||||
squeezeOut: boolean;
|
||||
/** Two-factor confirmation code, when PL24 asks for one. */
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface PL24LoginResponseV2 {
|
||||
loginStatus?: "OK" | string;
|
||||
sessionToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC7807 body PL24 returns on a refused login (HTTP 400/412). `type` carries
|
||||
* the reason; the UI bundle enumerates these.
|
||||
*/
|
||||
export interface PL24LoginProblem {
|
||||
type?: string;
|
||||
title?: string;
|
||||
detail?: string;
|
||||
/** For two-fa-required: "AUTHENTICATOR" | "EMAIL". */
|
||||
method?: string;
|
||||
code?: string;
|
||||
token?: string;
|
||||
completionToken?: string;
|
||||
}
|
||||
|
||||
export const PL24_LOGIN_PROBLEM = {
|
||||
SESSION_LIMIT_EXCEEDED: "urn:login:session-limit-exceeded",
|
||||
TWO_FA_REQUIRED: "urn:login:two-factor-authentication-required",
|
||||
TWO_FA_INVALID: "urn:login:two-factor-authentication-invalid-code",
|
||||
ACCOUNT_PENDING: "urn:login:account-pending",
|
||||
PRECONDITION_FAILED: "urn:login:precondition-failed",
|
||||
ACCOUNT_NOT_ACTIVE: "urn:login:account-not-active",
|
||||
USER_NOT_ACTIVE: "urn:login:user-not-active",
|
||||
AUTHENTICATION_FAILED: "urn:login:authentication",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* One PL24 session = the PL24TOKEN cookie. It has no expiry of its own; it dies
|
||||
* when PL24 squeezes it out (another login on the same account) or is revoked,
|
||||
* which surfaces as authorize → 401 or `session_status: "gone"`.
|
||||
*/
|
||||
export interface PL24Session {
|
||||
sessionToken: string;
|
||||
loginAt: number;
|
||||
lastOkAt: number;
|
||||
}
|
||||
|
||||
export interface PL24TokenData {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
@@ -287,19 +339,61 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
|
||||
|
||||
// PSA Group (Citroën, Peugeot)
|
||||
citroen_parts: {
|
||||
basePath: "/psa",
|
||||
apiPath: "/psa",
|
||||
architecture: "LEGACY_PSA",
|
||||
basePath: "/pl24-app/citroen_parts",
|
||||
apiPath: "/p5psa",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
citroenDs_parts: {
|
||||
basePath: "/psa",
|
||||
apiPath: "/psa",
|
||||
architecture: "LEGACY_PSA",
|
||||
basePath: "/pl24-app/citroenDs_parts",
|
||||
apiPath: "/p5psa",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
peugeot_parts: {
|
||||
basePath: "/psa",
|
||||
apiPath: "/psa",
|
||||
architecture: "LEGACY_PSA",
|
||||
basePath: "/pl24-app/peugeot_parts",
|
||||
apiPath: "/p5psa",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Stellantis-era Opel/Vauxhall — the PSA-platform catalogue (Corsa F, Mokka B…).
|
||||
// GM-era W0L/W0V cars stay in the legacy opel_parts P4 catalogue below.
|
||||
psa_opel_parts: {
|
||||
basePath: "/pl24-app/psa_opel_parts",
|
||||
apiPath: "/p5psa",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
psa_vauxhall_parts: {
|
||||
basePath: "/pl24-app/psa_vauxhall_parts",
|
||||
apiPath: "/p5psa",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Subaru (own P5 backend)
|
||||
subaru_parts: {
|
||||
basePath: "/pl24-app/subaru_parts",
|
||||
apiPath: "/p5subaru",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Rest of the Fiat/Stellantis family — same /p5fiat backend as fiatp/fiatt
|
||||
abarth_parts: {
|
||||
basePath: "/pl24-app/abarth_parts",
|
||||
apiPath: "/p5fiat",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
alfa_parts: {
|
||||
basePath: "/pl24-app/alfa_parts",
|
||||
apiPath: "/p5fiat",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
jeep_parts: {
|
||||
basePath: "/pl24-app/jeep_parts",
|
||||
apiPath: "/p5fiat",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
lancia_parts: {
|
||||
basePath: "/pl24-app/lancia_parts",
|
||||
apiPath: "/p5fiat",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Ford Group
|
||||
@@ -352,14 +446,14 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
|
||||
|
||||
// Volvo/Polestar
|
||||
volvo_parts: {
|
||||
basePath: "/volvo",
|
||||
apiPath: "/volvo",
|
||||
architecture: "LEGACY_VOLVO",
|
||||
basePath: "/pl24-app/volvo_parts",
|
||||
apiPath: "/p5volvo",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
polestar_parts: {
|
||||
basePath: "/volvo",
|
||||
apiPath: "/volvo",
|
||||
architecture: "LEGACY_VOLVO",
|
||||
basePath: "/pl24-app/polestar_parts",
|
||||
apiPath: "/p5volvo",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Fiat Group (FCA) — P5 Modern catalog at /p5fiat, requires de-708171 account.
|
||||
@@ -536,8 +630,8 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
||||
ZFA: "fiatp_parts", // Fiat SpA Italy (most common)
|
||||
ZCF: "fiatp_parts", // Tofaş Turkey (Linea, Fiorino, etc.)
|
||||
ZFF: "fiatp_parts", // Abarth / Fiat Sport
|
||||
ZAR: "fiatp_parts", // Alfa Romeo
|
||||
ZLA: "fiatp_parts", // Lancia
|
||||
ZAR: "alfa_parts", // Alfa Romeo (kendi kataloğu, /p5fiat backend)
|
||||
ZLA: "lancia_parts", // Lancia (kendi kataloğu, /p5fiat backend)
|
||||
|
||||
// Fiat Commercial (fiatt_parts)
|
||||
ZFC: "fiatt_parts", // Fiat Commercial
|
||||
@@ -563,9 +657,16 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
||||
JNK: "infiniti_parts", // Infiniti (Japan/Korea)
|
||||
|
||||
// Opel / Vauxhall
|
||||
// Subaru (canlı WMI decode: JF1 → subaru_parts, error:false)
|
||||
JF1: "subaru_parts",
|
||||
JF2: "subaru_parts",
|
||||
// Jeep (Stellantis; /p5fiat backend)
|
||||
"1C4": "jeep_parts",
|
||||
"1J4": "jeep_parts",
|
||||
|
||||
W0L: "opel_parts", // Opel AG (Germany)
|
||||
W0V: "opel_parts", // Opel (newer Stellantis-era WMI)
|
||||
VXK: "opel_parts", // PSA/Stellantis-platform Opel (Corsa F, Mokka B — France/Spain plants)
|
||||
VXK: "psa_opel_parts", // PSA-platform Opel (Corsa F, Mokka B) → Stellantis kataloğu /p5psa
|
||||
|
||||
// Citroën (PSA)
|
||||
VF7: "citroen_parts", // Citroën SA (France)
|
||||
@@ -794,6 +895,13 @@ export const SERVICE_TO_BRAND: Record<string, string> = {
|
||||
vauxhall_parts: "Opel",
|
||||
// Volvo/Polestar
|
||||
volvo_parts: "Volvo",
|
||||
subaru_parts: "Subaru",
|
||||
psa_opel_parts: "Opel",
|
||||
psa_vauxhall_parts: "Opel",
|
||||
abarth_parts: "Abarth",
|
||||
alfa_parts: "Alfa Romeo",
|
||||
jeep_parts: "Jeep",
|
||||
lancia_parts: "Lancia",
|
||||
polestar_parts: "Polestar",
|
||||
// Fiat Group
|
||||
fiatp_parts: "Fiat",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ConfigService } from "@nestjs/config";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ProxyHealthService } from "./proxy-health.service";
|
||||
import { isProxyConnectFailure } from "./proxy-telemetry.service";
|
||||
|
||||
function makeConfig(overrides: Record<string, unknown> = {}): ConfigService {
|
||||
return {
|
||||
get: vi.fn((key: string, def?: unknown) =>
|
||||
Object.hasOwn(overrides, key) ? overrides[key] : def,
|
||||
),
|
||||
} as unknown as ConfigService;
|
||||
}
|
||||
|
||||
describe("ProxyHealthService — Floxy failover gate", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts healthy (Floxy not down)", () => {
|
||||
const h = new ProxyHealthService(makeConfig());
|
||||
expect(h.isFloxyDown()).toBe(false);
|
||||
});
|
||||
|
||||
it("trips for the cooldown window then self-heals when it lapses", () => {
|
||||
const h = new ProxyHealthService(makeConfig({ FLOXY_FAILOVER_COOLDOWN_MS: 180_000 }));
|
||||
h.reportFloxyFailure("test");
|
||||
expect(h.isFloxyDown()).toBe(true);
|
||||
expect(h.cooldownRemainingSec()).toBe(180);
|
||||
|
||||
vi.advanceTimersByTime(179_000);
|
||||
expect(h.isFloxyDown()).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(2_000); // past the 180s window
|
||||
expect(h.isFloxyDown()).toBe(false);
|
||||
});
|
||||
|
||||
it("a Floxy success clears the cooldown immediately", () => {
|
||||
const h = new ProxyHealthService(makeConfig({ FLOXY_FAILOVER_COOLDOWN_MS: 180_000 }));
|
||||
h.reportFloxyFailure();
|
||||
expect(h.isFloxyDown()).toBe(true);
|
||||
h.reportFloxySuccess();
|
||||
expect(h.isFloxyDown()).toBe(false);
|
||||
});
|
||||
|
||||
it("re-reporting a failure extends the window", () => {
|
||||
const h = new ProxyHealthService(makeConfig({ FLOXY_FAILOVER_COOLDOWN_MS: 100_000 }));
|
||||
h.reportFloxyFailure();
|
||||
vi.advanceTimersByTime(90_000);
|
||||
h.reportFloxyFailure(); // extends from now
|
||||
vi.advanceTimersByTime(20_000); // 110s since first failure, 20s since second
|
||||
expect(h.isFloxyDown()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isProxyConnectFailure", () => {
|
||||
it("matches proxy/tunnel connection failures (the Floxy-down signal)", () => {
|
||||
for (const msg of [
|
||||
"page.goto: net::ERR_TUNNEL_CONNECTION_FAILED at https://emexdwc.ae/",
|
||||
"net::ERR_PROXY_CONNECTION_FAILED",
|
||||
"TypeError: fetch failed",
|
||||
"connect ECONNREFUSED 1.2.3.4:12321",
|
||||
"Client network socket disconnected (ECONNRESET)",
|
||||
]) {
|
||||
expect(isProxyConnectFailure(new Error(msg)), msg).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT match a plain slow-site navigation timeout", () => {
|
||||
const e = new Error("Timeout 30000ms exceeded.");
|
||||
e.name = "TimeoutError";
|
||||
expect(isProxyConnectFailure(e)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
/**
|
||||
* Shared Floxy-health gate. Floxy is the residential primary for EMEX + pcat
|
||||
* egress (HTTP fetch, Playwright capture, browser scrape). When Floxy goes dark
|
||||
* — balance exhausted (proxy returns 402) or the entry node refuses CONNECT
|
||||
* (`ERR_TUNNEL_CONNECTION_FAILED` / `fetch failed`) — every consumer otherwise
|
||||
* keeps hammering the dead proxy and the decode success rate collapses (this
|
||||
* happened 2026-06-11/13: ~17k tunnel failures/24h, decode 78% → 45%).
|
||||
*
|
||||
* This singleton is the one place that answers "should we route through Floxy
|
||||
* right now?". Any consumer that sees a Floxy transport failure trips the gate;
|
||||
* for the cooldown window all consumers fail over to the DataImpulse datacenter
|
||||
* pool instead. The gate self-heals: once the cooldown lapses, the next request
|
||||
* probes Floxy again and `reportFloxySuccess()` clears it (or re-trips if still
|
||||
* down). A live Floxy answer at any time clears the cooldown immediately.
|
||||
*
|
||||
* In-memory only and deliberately dependency-light (no DB, no Redis): a single
|
||||
* api/worker process is the egress unit, and a stale cooldown is self-correcting
|
||||
* within one window. Cooldown is tunable via FLOXY_FAILOVER_COOLDOWN_MS.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProxyHealthService {
|
||||
private readonly logger = new Logger(ProxyHealthService.name);
|
||||
private readonly cooldownMs: number;
|
||||
private floxyDownUntil = 0;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
const raw = Number(this.config.get("FLOXY_FAILOVER_COOLDOWN_MS", 180_000));
|
||||
this.cooldownMs = Number.isFinite(raw) && raw >= 0 ? raw : 180_000;
|
||||
}
|
||||
|
||||
/** True while Floxy is in cooldown — consumers should route via DataImpulse. */
|
||||
isFloxyDown(): boolean {
|
||||
return Date.now() < this.floxyDownUntil;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floxy transport failure observed — open (or extend) the cooldown so every
|
||||
* consumer fails over to DataImpulse. Logged once per outage, not per failure,
|
||||
* so a 6k-failure storm doesn't flood the logs.
|
||||
*/
|
||||
reportFloxyFailure(reason?: string): void {
|
||||
const wasDown = this.isFloxyDown();
|
||||
this.floxyDownUntil = Date.now() + this.cooldownMs;
|
||||
if (!wasDown) {
|
||||
this.logger.warn(
|
||||
`Floxy proxy unhealthy${reason ? ` (${reason})` : ""} — failing over to DataImpulse for ${Math.round(
|
||||
this.cooldownMs / 1000,
|
||||
)}s`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A Floxy request just succeeded — Floxy is healthy again, clear the gate. */
|
||||
reportFloxySuccess(): void {
|
||||
if (this.isFloxyDown()) {
|
||||
this.logger.log("Floxy proxy recovered — resuming Floxy-primary routing");
|
||||
}
|
||||
this.floxyDownUntil = 0;
|
||||
}
|
||||
|
||||
/** Seconds remaining in the current cooldown (0 when healthy). */
|
||||
cooldownRemainingSec(): number {
|
||||
return Math.max(0, Math.ceil((this.floxyDownUntil - Date.now()) / 1000));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ProxyHealthService } from "./proxy-health.service";
|
||||
import { ProxyTelemetryService } from "./proxy-telemetry.service";
|
||||
|
||||
// Imported by PartsCatalogsModule and EmexModule; Nest module caching makes the
|
||||
// service a singleton (one shared flush buffer) across both.
|
||||
// services singletons (one shared flush buffer / one shared Floxy-health gate)
|
||||
// across both.
|
||||
@Module({
|
||||
providers: [ProxyTelemetryService],
|
||||
exports: [ProxyTelemetryService],
|
||||
providers: [ProxyTelemetryService, ProxyHealthService],
|
||||
exports: [ProxyTelemetryService, ProxyHealthService],
|
||||
})
|
||||
export class ProxyTelemetryModule {}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { proxyLogs } from "../../database/schema/core";
|
||||
|
||||
/**
|
||||
* Proxy telemetry — fire-and-forget logging of every proxied upstream attempt
|
||||
* (pcat call/capture/validate, emex http) into `proxy_logs`, so the Süper Panel
|
||||
* (pcat call/capture/validate, emex http, pl24 catalog/auth) into `proxy_logs`, so the Süper Panel
|
||||
* can grade proxy providers per service and track banned exit IPs / sessions.
|
||||
*
|
||||
* Design constraints:
|
||||
@@ -13,7 +13,13 @@ import { proxyLogs } from "../../database/schema/core";
|
||||
* - Bounded memory: the buffer is capped; when full, oldest rows are dropped.
|
||||
*/
|
||||
|
||||
export type ProxyServiceLeg = "pcat_call" | "pcat_capture" | "pcat_validate" | "emex_http";
|
||||
export type ProxyServiceLeg =
|
||||
| "pcat_call"
|
||||
| "pcat_capture"
|
||||
| "pcat_validate"
|
||||
| "emex_http"
|
||||
| "pl24_http"
|
||||
| "pl24_auth";
|
||||
export type ProxyProviderName = "floxy" | "dataimpulse" | "none";
|
||||
|
||||
export interface ProxyLogEvent {
|
||||
@@ -52,6 +58,25 @@ export function classifyTransportError(err: unknown): string {
|
||||
return "transport";
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an error means we could not even reach the upstream THROUGH the
|
||||
* proxy — the proxy entry node refused/closed/reset the CONNECT, or undici/
|
||||
* Chromium reported a tunnel/proxy connection failure ("fetch failed",
|
||||
* ERR_TUNNEL_CONNECTION_FAILED, ERR_PROXY_CONNECTION_FAILED, ECONNREFUSED…).
|
||||
* This is the "the proxy itself is dead" signal that should trip Floxy failover.
|
||||
*
|
||||
* Deliberately EXCLUDES a plain navigation/request TimeoutError with no proxy
|
||||
* marker: a slow-but-alive residential exit shouldn't trip failover — only a
|
||||
* genuine connection failure should.
|
||||
*/
|
||||
export function isProxyConnectFailure(err: unknown): boolean {
|
||||
const e = err as Error & { cause?: unknown };
|
||||
const text = `${e?.name ?? ""} ${e?.message ?? ""} ${String(e?.cause ?? "")}`;
|
||||
return /ERR_TUNNEL_CONNECTION_FAILED|ERR_PROXY_CONNECTION_FAILED|ERR_SOCKS_CONNECTION_FAILED|NS_ERROR_PROXY|ECONNREFUSED|ECONNRESET|UND_ERR_SOCKET|UND_ERR_CONNECT_TIMEOUT|socket hang up|other side closed|tunnel|proxy connection|fetch failed/i.test(
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive provider + session key from a proxy URL the way the integrations
|
||||
* build them: Floxy passwords may embed `_session-<id>_lifetime-<n>` (sticky);
|
||||
@@ -79,10 +104,7 @@ export function describeProxyUrl(proxyUrl: string | null | undefined): {
|
||||
* probe through the same proxy. Only meaningful for sticky sessions — on a
|
||||
* rotating leg the probe and the real request exit from different IPs.
|
||||
*/
|
||||
export async function resolveExitIp(
|
||||
proxyUrl: string,
|
||||
timeoutMs = 5_000,
|
||||
): Promise<string | null> {
|
||||
export async function resolveExitIp(proxyUrl: string, timeoutMs = 5_000): Promise<string | null> {
|
||||
try {
|
||||
const { ProxyAgent } = await import("undici");
|
||||
const res = await fetch("https://api.ipify.org?format=text", {
|
||||
@@ -138,7 +160,9 @@ export class ProxyTelemetryService implements OnModuleDestroy {
|
||||
);
|
||||
} catch (err) {
|
||||
// Telemetry must never matter more than the product. Drop the batch.
|
||||
this.logger.debug(`proxy_logs flush failed (${batch.length} rows): ${(err as Error).message}`);
|
||||
this.logger.debug(
|
||||
`proxy_logs flush failed (${batch.length} rows): ${(err as Error).message}`,
|
||||
);
|
||||
} finally {
|
||||
this.flushing = false;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
2427
apps/api/src/integrations/vinpin/vinpin-driver.service.ts
Normal file
2427
apps/api/src/integrations/vinpin/vinpin-driver.service.ts
Normal file
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
|
||||
});
|
||||
});
|
||||
618
apps/api/src/integrations/vinpin/vinpin.constants.ts
Normal file
618
apps/api/src/integrations/vinpin/vinpin.constants.ts
Normal file
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* Vinpin ePER driver constants.
|
||||
*
|
||||
* The screen coordinates below are calibrated for a 1600x900 viewport against
|
||||
* the Vinpin ePER (Fiat) web catalog rendered inside its Blast/Horizon canvas.
|
||||
* The UI is a pixel canvas with no stable DOM selectors, so navigation is
|
||||
* coordinate-driven.
|
||||
*
|
||||
* These were VERIFIED LIVE against a real Vinpin trial seat (2026-06-26) with a
|
||||
* vision loop (screenshot + OCR), not guessed — every transition is OCR-checked
|
||||
* at runtime and retried, so minor drift self-heals. Re-verify the Fiat-tile
|
||||
* coordinate if the brand-grid layout ever changes.
|
||||
*/
|
||||
|
||||
import { getBrandFromWmi } from "@sase/shared";
|
||||
|
||||
/** Viewport the coordinates are calibrated for. */
|
||||
export const VINPIN_VIEWPORT = { width: 1600, height: 900 } as const;
|
||||
|
||||
/** Coordinate map (x, y) for the ePER canvas. Verified live @ 1600x900. */
|
||||
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). */
|
||||
fiatBrand: { x: 230, y: 104 },
|
||||
/** "OK" button of the spurious Russian-language dialog shown on ePER open. */
|
||||
langDialogDismiss: { x: 746, y: 454 },
|
||||
/** "Spare Parts" catalog tile on the ePER portal home ("Доступные каталоги").
|
||||
* Opens the Spare Parts Catalogue that carries the VIN identification panel.
|
||||
* (The seat may open straight onto the catalogue or onto this portal — the
|
||||
* driver detects which and clicks here only when stuck on the portal.) */
|
||||
sparePartsTile: { x: 214, y: 330 },
|
||||
/** Right-panel "Идентификация автомобиля → VIN" input field. */
|
||||
vinField: { x: 1200, y: 189 },
|
||||
/** Decode arrow (→) next to the VIN field. */
|
||||
vinSubmitArrow: { x: 1283, y: 189 },
|
||||
/** "OK" button of the "vehicles not found" alert (genuine not-found result). */
|
||||
notFoundOk: { x: 816, y: 448 },
|
||||
|
||||
// ─── Renault flow (Rpartstore primary + Dialogys fallback) ───
|
||||
// Coordinates below are 1600x900 STARTING POINTS from the live benchmark on the
|
||||
// permanent seat (trvinpin41080). They are OCR-verified+retried at runtime like
|
||||
// the Fiat flow, so minor drift self-heals — but re-verify if layout changes.
|
||||
// TUNE.
|
||||
/** Window-close (X) button of an open catalog window. ⚠️ DO NOT USE on the
|
||||
* HTML-Access desktop: this coord actually hits the LANGUAGE SELECTOR (it opens a
|
||||
* dropdown that makes the brand grid unrecognizable and wedges the ensureBrandGrid
|
||||
* thrash). It is NO LONGER clicked by ANY path — the browser-tab ✕ below is the
|
||||
* only close action. Kept documented so the bad coordinate isn't re-introduced. */
|
||||
windowClose: { x: 1298, y: 14 },
|
||||
/** Browser-tab ✕ of an open catalog app — the PRIMARY window-close action. The
|
||||
* tab sits just under the window title bar, e.g. "Renault Rpartstore ✕".
|
||||
* Validated live: clicking (93,45) closed the catalog and returned to the clean
|
||||
* brand grid (the old {135,45} missed the ✕). TUNE. */
|
||||
catalogTabClose: { x: 93, y: 45 },
|
||||
/** "Çıkış yap" logout button (bottom-right of the RDS/Horizon desktop). Ends the
|
||||
* remote session so the NEXT warm-up starts from a fresh login/grid instead of
|
||||
* resuming into the last-open dirty catalog desktop. Used by the clean-teardown
|
||||
* routine. ⚠️ With a blocking modal up (e.g. the Rpartstore launch-error dialog)
|
||||
* this becomes a channel DISCONNECT rather than a clean RDS log-off — so the
|
||||
* teardown dismisses any modal BEFORE clicking it. TUNE. */
|
||||
logoutButton: { x: 1543, y: 877 },
|
||||
/** "Close" button of the Horizon "You have been disconnected" dialog. If "Çıkış
|
||||
* yap" turned into a channel disconnect (dirty RDS resume), clicking this lands
|
||||
* back on the HTML-Access launcher ("No Running Items") from which the VINPIN app
|
||||
* is relaunched for a fresh VinPower grid. Verified @ 1600x900. TUNE. */
|
||||
disconnectedClose: { x: 953, y: 505 },
|
||||
/** Renault brand tile on the VinPower grid (opens the Rpartstore/Dialogys
|
||||
* submenu). mousedown/up like the Fiat tile. TUNE. */
|
||||
renaultBrand: { x: 450, y: 707 },
|
||||
/** Rpartstore entry in the Renault submenu (PRIMARY catalog). TUNE. */
|
||||
renaultRpartstore: { x: 584, y: 779 },
|
||||
/** Dialogys entry in the Renault submenu (FALLBACK catalog). TUNE. */
|
||||
renaultDialogys: { x: 584, y: 733 },
|
||||
/** Both Renault catalogs pop a Russian-language dialog on open → its OK. TUNE. */
|
||||
renaultLangOk: { x: 746, y: 454 },
|
||||
/** Rpartstore "Şasi no ile arama" VIN field. Submit with Enter (NOT the yellow
|
||||
* button — it moves). TUNE. */
|
||||
rpartstoreVinField: { x: 735, y: 194 },
|
||||
/** Rpartstore "RPartStore" home/logo (top-left) — resets to the search home
|
||||
* between VINs (more reliable than close+reopen). TUNE. */
|
||||
rpartstoreHome: { x: 110, y: 92 },
|
||||
/** Rpartstore error-card close (✕) — dismisses the "Şasi seçilmedi" /
|
||||
* "bulunamadı" overlay that otherwise covers the VIN field. TUNE. */
|
||||
rpartstoreErrorClose: { x: 928, y: 167 },
|
||||
/** Rpartstore yellow search button (fallback to Enter submit). TUNE. */
|
||||
rpartstoreSearchBtn: { x: 975, y: 195 },
|
||||
/** "OK" button of the Rpartstore hard launch-error modal ("Ошибка запуска
|
||||
* каталога" / title "Renault Rpartstore") that fires when Rpartstore is DOWN
|
||||
* server-side. Verified live @ 1600x900 (viewport frame) — dismisses the modal
|
||||
* so the driver can fall straight back to Dialogys. TUNE. */
|
||||
rpartstoreLaunchErrorOk: { x: 868, y: 530 },
|
||||
/** Dialogys VIN field. TUNE. */
|
||||
dialogysVinField: { x: 457, y: 258 },
|
||||
/** Dialogys ПОИСК (search) button. TUNE. */
|
||||
dialogysSearch: { x: 590, y: 382 },
|
||||
/** Dialogys Home button (reset step 1). TUNE. */
|
||||
dialogysHome: { x: 78, y: 102 },
|
||||
/** Dialogys ИЗМЕНИТЬ А/М — change-vehicle (reset step 2). TUNE. */
|
||||
dialogysChangeVehicle: { x: 1217, y: 160 },
|
||||
} as const;
|
||||
|
||||
/** Screenshot clip (px) of the Renault Rpartstore/Dialogys vehicle-page HEADER
|
||||
* region, fed to OCR. Rpartstore renders "RENAULT <model> (<code>)" /
|
||||
* "DACIA <model>" + "Şasi: <VIN>" in 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;
|
||||
|
||||
/** Screenshot clip (px) of the decode-result modal body, fed to OCR. Captures
|
||||
* the model line ("6J - TIPO - EGEA (2015-2021)"), SINCOM, trim, prod-date and
|
||||
* the echoed VIN. Verified live. Widened to ~900px for the warm-session daemon
|
||||
* (some decode modals render wider on the permanent seat). */
|
||||
export const VINPIN_MODAL_REGION = { x: 250, y: 120, width: 900, height: 500 } as const;
|
||||
|
||||
/** Screenshot clip (px) of the CENTERED Rpartstore hard launch-error modal
|
||||
* ("Renault Rpartstore" / "Ошибка запуска каталога", OK ~868,530), fed to OCR
|
||||
* UPSCALED (ocrRegion's default 3×). ⚠️ ROOT-CAUSE NOTE (verified live 2026-07-15
|
||||
* on seat trvinpin47828): the modal is a small (~220×140px) centered Cyrillic
|
||||
* dialog. OCR'ing the FULL 1600×900 frame at 1× (what the old load-poll did)
|
||||
* CANNOT read it — tesseract returns the surrounding brand-grid tiles and drops
|
||||
* the tiny modal text, so `rpartstoreLaunchError` never matched → the DOWN
|
||||
* Rpartstore was misclassified as a "born-stuck spinner" and 3 reopens (~54s) were
|
||||
* burned before Dialogys. Cropping to THIS region + 3× upscale makes the string
|
||||
* legible ("Owwu6ka 3anycka KaTanora"), so the launch error is detected on the
|
||||
* FIRST open and the flow bails straight to Dialogys. Sized generously around the
|
||||
* screen-centered dialog to tolerate drift while excluding the right sidebar. */
|
||||
export const VINPIN_RPARTSTORE_ERROR_REGION = {
|
||||
x: 540,
|
||||
y: 370,
|
||||
width: 560,
|
||||
height: 230,
|
||||
} as const;
|
||||
|
||||
/** OCR keyword sets for state detection (case-insensitive). */
|
||||
export const VINPIN_OCR = {
|
||||
/** 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. */
|
||||
eperOpen: /Dealer|Pycc|Русск|Spare|Поиск|Запчаст|EPER/i,
|
||||
/** We're on the Spare Parts CATALOGUE (left model tree visible) — the page
|
||||
* that carries the right-panel VIN identification field. Discriminated from
|
||||
* the portal home by the model-tree tokens (which OCR cleanly in Latin),
|
||||
* since the portal shows only "Accessories / Service Packages / REMAN". */
|
||||
catalogueReady:
|
||||
/Spare Parts Catalogue|GRANDE PANDA|124 SPIDER|TOPOLINO|TIPO|EGEA|DOBLO|ULYSSE|SEDICI/i,
|
||||
/** The decode modal actually carries a result (a model / prod-date / MVS). */
|
||||
modalHit:
|
||||
/MVS|найден|Prod\.?\s*date|TIPO|EGEA|DOBLO|PALIO|PANDA|PUNTO|LINEA|DUCATO|QUBO|FIORINO|ULYSSE/i,
|
||||
/** Genuine "no vehicle found" outcome (the catalog has no record). Used against
|
||||
* the cropped decode-modal region in runVinFlow, where the loose `Catalogue`
|
||||
* token is a useful settle signal. */
|
||||
notFound: /не\s*найден|not\s*found|Catalogue/i,
|
||||
/** STRICT genuine not-found — requires an actual "не найден"/"not found" string,
|
||||
* WITHOUT the loose `Catalogue` token. Use this against the FULL frame (where
|
||||
* the "Spare Parts Catalogue" header is always present, so `notFound` would
|
||||
* false-match), e.g. the warm-Fiat genuine-miss-vs-garble decision — so an
|
||||
* on-panel transient garble falls through to the cold retry instead of null. */
|
||||
notFoundStrict: /не\s*найден|not\s*found/i,
|
||||
|
||||
// ─── Renault flow OCR sets ───
|
||||
/** A catalog app window (Rpartstore / Dialogys / Fiat ePER) is open OVER the
|
||||
* brand grid — its chrome must be closed to fall back to the grid. Covers the
|
||||
* Rpartstore/Dialogys titles + the ePER "Spare Parts" / "Son araca geri dön"
|
||||
* navigation that only appear inside an open catalog. */
|
||||
catalogWindowOpen:
|
||||
/RPartStore|Rpartstore|Dialogys|Son\s*araca|Spare\s*Parts|Yedek\s*par|ePER|Üniversal\s*ürün/i,
|
||||
/** Renault submenu is open (Rpartstore + Dialogys entries visible). */
|
||||
renaultSubmenu: /Rpartstore|Rpart|Dialogys|Dialog/i,
|
||||
/** Rpartstore app/window is open (home OR a vehicle page) — its chrome tokens
|
||||
* ("RPartStore", "Güncel araçlar", "Ne arıyorsunuz", "Grup siparişi",
|
||||
* "Şasi") appear only inside Rpartstore. */
|
||||
rpartstoreOpen: /RPartStore|Rpartstore|Güncel\s*ara|Grup\s*sipariş|arıyor|Şasi|Sasi/i,
|
||||
/** Rpartstore home/search page (the VIN search field is up). */
|
||||
rpartstoreReady: /Güncel\s*ara|arıyor|Grup\s*sipariş|Şasi\s*no|Sasi\s*no|Ara\b/i,
|
||||
/** Rpartstore search-home actually RENDERED its landing markers ("Şasi no ile
|
||||
* arama" / "Ne arıyorsunuz" / "Güncel araçlar" / "Grup siparişi"). This is the
|
||||
* spinner-guard signal: it distinguishes a LOADED home from the "born-stuck"
|
||||
* infinite spinner, which shows only the window CHROME ("RPartStore" title —
|
||||
* matched by rpartstoreOpen) with no search-home content. Kept strictly to
|
||||
* content-only tokens so a spinning-but-titled window never false-passes. */
|
||||
rpartstoreLoaded:
|
||||
/Şasi\s*no\s*ile|Sasi\s*no\s*ile|Ne\s*arıyor|Ne\s*ariyor|Güncel\s*ara(ç|c)|Guncel\s*ara(ç|c)|Grup\s*sipariş|arıyorsunuz/i,
|
||||
/** Rpartstore HARD launch-error modal — the Russian "Ошибка запуска каталога"
|
||||
* (Catalog launch error) dialog that fires within ~8-10s of opening when
|
||||
* Rpartstore is DOWN server-side (entitlement/backend failure). Reopening never
|
||||
* helps, so detecting this must SHORT-CIRCUIT straight to Dialogys instead of
|
||||
* burning the reopen budget. ⚠️ This must be read from an UPSCALED crop of
|
||||
* `VINPIN_RPARTSTORE_ERROR_REGION` — on a full 1×frame the modal is illegible
|
||||
* (see that region's note). The eng-OCR of the Cyrillic "Ошибка запуска
|
||||
* каталога" transliterates to "Owwu6ka 3anycka KaTanora" / "OwwbKa 3anycka
|
||||
* KaTanora" (captured verbatim live 2026-07-15). The `3anycka`+`KaTanora` tokens
|
||||
* are the stable anchors; the "Ошибка" head transliterates variably
|
||||
* (Owwu6ka/OwwbKa/Owwnbka) so all seen forms are listed. Its title alone
|
||||
* ("Renault Rpartstore") false-matches rpartstoreOpen — this content string is
|
||||
* what disambiguates the error. */
|
||||
rpartstoreLaunchError: /запуска\s*катал|Ошибка\s*запуск|3anycka|KaTanora|Owwu6ka|OwwbKa|Owwnbka/i,
|
||||
/** Rpartstore decoded a vehicle — header shows RENAULT/DACIA <model> + Şasi. */
|
||||
rpartstoreHit: /RENAULT|DACIA|Şasi\s*:|Sasi\s*:/i,
|
||||
/** Rpartstore genuine not-found — the error card ("İlişikli araç bulunamadı" /
|
||||
* "Şasi ... bulunamadı" / "Şasi seçilmedi"). */
|
||||
rpartstoreNotFound: /bulunamad|İlişikli|Ilisikli|araç\s*bulun|seçilmedi|secilmedi/i,
|
||||
/** Dialogys form/vehicle page reached (Cyrillic UI, ПОИСК / ИЗМЕНИТЬ). */
|
||||
dialogysReady: /ПОИСК|ПОИC|Dialogys|VIN|ИЗМЕНИТЬ/i,
|
||||
|
||||
// ─── Warm-session daemon OCR sets ───
|
||||
/** The RDS/Horizon session is still ALIVE — the desktop status panel shows the
|
||||
* host/seat identifiers ("HORIZON-RDST01"/"HORIZON-RDST02" / "trvinpin41080").
|
||||
* The seat now LOAD-BALANCES across RDST01 and RDST02, so both host tags count.
|
||||
* Their ABSENCE combined with a `sessionDropped` marker means the session dropped. */
|
||||
sessionAlive: /HORIZON[- ]?RDST0[12]|RDST0[12]|trvinpin\d*/i,
|
||||
/** The RDS/Horizon session DROPPED — a "Disconnected"/reconnect dialog or the
|
||||
* "no free sessions" gate. Triggers a teardown + re-warm + single retry. */
|
||||
sessionDropped:
|
||||
/Disconnected|nofreesession|no\s*free\s*session|reconnect|Session\s*(has\s*)?(been\s*)?(disconnected|ended|expired|timed\s*out)|HTML\s*Access/i,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Which taskbar button belongs to which catalog window. The warm daemon OCRs the
|
||||
* bottom taskbar and matches these labels to bind brand→coordinate at runtime
|
||||
* (the button ORDER depends on the open sequence, so we never hardcode the
|
||||
* mapping — see `orderTaskbarWindows`). `grid` matches the VinPower brand-grid
|
||||
* button used to open the next catalog without closing the current one.
|
||||
*/
|
||||
export const VINPIN_WINDOW_LABELS = {
|
||||
fiat: /ePER|Dealer|Fiat/i,
|
||||
rpartstore: /RPartStore|Rpart/i,
|
||||
dialogys: /Dialogys|Dialog/i,
|
||||
grid: /VinPower|VINPIN|Marka|Brand/i,
|
||||
} as const;
|
||||
|
||||
export type VinpinWindowKey = keyof typeof VINPIN_WINDOW_LABELS;
|
||||
|
||||
/**
|
||||
* OCR keyword that confirms a given catalog window is now in the FOREGROUND after
|
||||
* a taskbar raise (reuses the existing state sets). Used to verify a raise landed
|
||||
* on the right window before running a decode on it.
|
||||
*/
|
||||
export const VINPIN_WINDOW_FOREGROUND: Record<"fiat" | "rpartstore" | "dialogys", RegExp> = {
|
||||
// ⚠️ Fiat-window-ONLY tokens. The old `Поиск` token was SHARED with the Dialogys
|
||||
// "ПОИСК" search button (`/Поиск/i.test("ПОИСК") === true`), so in a partial-warm
|
||||
// session where Dialogys ends up foreground the Fiat raise falsely reported success
|
||||
// and the VIN was typed into Dialogys → silent garbage. `Dealer`/`ePER` are Fiat
|
||||
// ePER chrome (never on Dialogys/Rpartstore); the rest are VIN-panel model tokens.
|
||||
fiat: /Spare\s*Parts\s*Catalogue|GRANDE\s*PANDA|TIPO|EGEA|DOBLO|Dealer|ePER/i,
|
||||
rpartstore: /RPartStore|Rpartstore|Güncel\s*ara|Grup\s*sipariş|Şasi|Sasi/i,
|
||||
dialogys: /ПОИСК|ПОИC|Dialogys|ИЗМЕНИТЬ/i,
|
||||
};
|
||||
|
||||
/**
|
||||
* Horizon HTML-Access client-chrome DOM selectors (REAL DOM, outside the Blast
|
||||
* canvas — hidden until the sidebar is opened). Used by the state-agnostic
|
||||
* stray-app recovery (`closeStrayRunningApps`): the sidebar "Running" panel lists
|
||||
* each RDS app with a per-app close ✕ that TERMINATES that specific app window
|
||||
* regardless of its internal modal/spinner/loading state. Proven live to cleanly
|
||||
* close both a Fiat "Fiat Dealer" window and a Renault Rpartstore launch-error
|
||||
* resume, each returning to a pristine brand grid — the root fix for the
|
||||
* dirty-resume failure the canvas tab-✕ (only hits a TABBED window's ✕) could not
|
||||
* recover. Treated as GIVEN from the live probe; every use is guarded (try/catch +
|
||||
* presence check) so a wrong/absent selector degrades to the canvas/OCR fallback
|
||||
* rather than throwing. ⚠️ A Connection-Server logout+relogin is NOT a recovery
|
||||
* here: the seat publishes apps-only (no desktop → no Start-menu Log Off), so the
|
||||
* RDS session ends ONLY on server-side idle timeout — logout+relogin provably
|
||||
* RESUMES the same dirty window. This DOM path is the recovery instead. */
|
||||
export const VINPIN_DOM = {
|
||||
/** Left-edge grip (~10,450) that reveals/collapses the Horizon sidebar. */
|
||||
sidebarToggler: "#sidebar-toggler",
|
||||
/** One <ul> per running RDS app in the sidebar "Running" panel. */
|
||||
runningApp: "ul.running-app",
|
||||
/** The app's display name inside a running-app row ("VinPower", "Fiat Dealer",
|
||||
* "Renault Rpartstore", "Dialogys", "Loading application..."). */
|
||||
runningAppName: "li.running-app-name, li.focused-app-name",
|
||||
/** Per-app close ✕ inside a running-app row — terminates THAT app window. */
|
||||
appCloseImage: "li.icon-close-app-image",
|
||||
/** "Available → VINPIN" launch tile (relaunch VinPower if it was closed). */
|
||||
availableVinpin: "#available-VINPIN",
|
||||
/** App name to KEEP — every OTHER running app is a stray to terminate. */
|
||||
vinPowerAppName: /VinPower/i,
|
||||
/** Bounded passes over the Running panel (rows shift as apps close). */
|
||||
maxClosePasses: 6,
|
||||
} as const;
|
||||
|
||||
/** Bottom Horizon/RDS taskbar clip (px), fed to OCR to read the open windows'
|
||||
* button labels left→right. TUNE against the permanent seat @ 1600x900. */
|
||||
export const VINPIN_TASKBAR_REGION = { x: 0, y: 866, width: 1600, height: 34 } as const;
|
||||
|
||||
/** Candidate taskbar button CENTERS along the bottom bar, left→right. The daemon
|
||||
* binds each open window to a slot by the OCR'd label order. Extra slots give
|
||||
* headroom for drift; a raise is OCR-verified and re-tried on the next slot.
|
||||
* TUNE against the permanent seat @ 1600x900. */
|
||||
export const VINPIN_TASKBAR_SLOTS = [
|
||||
{ x: 220, y: 884 },
|
||||
{ x: 360, y: 884 },
|
||||
{ x: 500, y: 884 },
|
||||
{ x: 640, y: 884 },
|
||||
{ x: 780, y: 884 },
|
||||
] as const;
|
||||
|
||||
/** Global "VIN veya katalog ara" router box on the VinPower grid (~1455,96). Only
|
||||
* used to OPEN a brand's window the first time (it routes, it does NOT decode and
|
||||
* the VIN does not carry into the catalog). TUNE. */
|
||||
export const VINPIN_ROUTER_BOX = { x: 1455, y: 96 } as const;
|
||||
|
||||
/** Warm-session daemon tunables. */
|
||||
export const VINPIN_WARM = {
|
||||
/** Business-hours window (Europe/Istanbul) the seat is held warm. */
|
||||
businessStartHour: 8,
|
||||
businessEndHour: 21,
|
||||
/** Idle keepalive cadence — a `mouse.move` every ~75s held the RDS session
|
||||
* 12.6 min with zero disconnect in the live proof. */
|
||||
keepaliveIntervalMs: 75_000,
|
||||
/** Harmless in-window point the keepalive nudges the cursor to. TUNE. */
|
||||
keepalivePoint: { x: 800, y: 500 },
|
||||
/** How often the scheduler reconciles warm-vs-hours. */
|
||||
schedulerIntervalMs: 60_000,
|
||||
/** Retries when verifying/re-trying a taskbar raise across slots. */
|
||||
raiseVerifyRetries: 3,
|
||||
/** After a taskbar-raise click, wait for the window to come forward. */
|
||||
afterRaiseMs: 1_200,
|
||||
/** Backoff after a FAILED warmUp: reconcile() and decode()'s warm-on-demand both
|
||||
* skip re-warming until the cooldown elapses, so a failing seat is not hammered
|
||||
* every ~60s (which leaves a fresh dirty window each attempt). Starts at the base
|
||||
* and doubles per consecutive failure up to the max; a successful warm resets it. */
|
||||
warmBackoffBaseMs: 60_000,
|
||||
warmBackoffMaxMs: 300_000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Given the OCR'd taskbar text and the window labels, return the windows in the
|
||||
* left→right order they appear in the bar. Pure + injectable so the brand→slot
|
||||
* binding is unit-testable without a live seat. Windows whose label isn't found
|
||||
* are omitted (the daemon then falls back to open-order for the missing ones).
|
||||
*/
|
||||
export function orderTaskbarWindows(
|
||||
taskbarText: string,
|
||||
keys: readonly ("fiat" | "rpartstore" | "dialogys")[] = ["fiat", "rpartstore", "dialogys"],
|
||||
): ("fiat" | "rpartstore" | "dialogys")[] {
|
||||
const found: { key: "fiat" | "rpartstore" | "dialogys"; idx: number }[] = [];
|
||||
for (const key of keys) {
|
||||
const label = VINPIN_WINDOW_LABELS[key];
|
||||
const m = label.exec(taskbarText);
|
||||
if (m) found.push({ key, idx: m.index });
|
||||
}
|
||||
return found.sort((a, b) => a.idx - b.idx).map((f) => f.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick which warm catalog WINDOW a VIN should be decoded on. Mirrors
|
||||
* `selectVinpinBrandFlow`: Renault/Dacia → the Rpartstore window (Dialogys is the
|
||||
* in-flow fallback), everything else (incl. Fiat) → the ePER window. Pure/testable.
|
||||
*/
|
||||
export function selectVinpinWarmWindow(vin: string): "fiat" | "rpartstore" {
|
||||
return selectVinpinBrandFlow(vin) === "renault" ? "rpartstore" : "fiat";
|
||||
}
|
||||
|
||||
/** Current hour (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. */
|
||||
export const VINPIN_WAITS = {
|
||||
afterGoto: 7_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,
|
||||
afterLangDismiss: 2_500,
|
||||
afterSearchOpen: 5_000,
|
||||
afterVinSubmit: 7_000,
|
||||
afterAlertDismiss: 250,
|
||||
/** After toggling the Horizon sidebar open/closed (real DOM chrome reveal). */
|
||||
afterSidebarToggle: 1_000,
|
||||
/** Between per-app closes in the Horizon "Running" panel (window teardown settles). */
|
||||
afterRunningAppClose: 2_000,
|
||||
/** OCR poll cadence when waiting for a detectable end-state. Each poll is
|
||||
* CAPPED at the corresponding fixed-wait budget above (kept as a fallback), so
|
||||
* if OCR never catches the transition the total wait == the old fixed sleep and
|
||||
* worst-case behaviour is unchanged. */
|
||||
pollIntervalMs: 700,
|
||||
// ─── Renault flow waits ───
|
||||
/** After clicking the window-close (X) of an open catalog window. */
|
||||
afterWindowClose: 3_500,
|
||||
/** After clicking the Renault brand tile (submenu animates in). */
|
||||
afterRenaultTile: 2_500,
|
||||
/** After picking Rpartstore/Dialogys from the submenu (catalog window opens). */
|
||||
afterRenaultCatalogOpen: 12_000,
|
||||
/** After a Rpartstore (re)open — poll for the search-home landing markers to
|
||||
* confirm it actually LOADED (vs the "born-stuck" infinite spinner). Short so
|
||||
* a stuck instance is detected fast and reopened fresh within the budget. */
|
||||
afterRpartstoreLoad: 12_000,
|
||||
/** After submitting the Rpartstore VIN (Enter) — wait for the vehicle page. */
|
||||
afterRpartstoreSubmit: 8_000,
|
||||
/** After submitting the Dialogys VIN (ПОИСК) — wait for the vehicle page. */
|
||||
afterDialogysSubmit: 8_000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Hard wall-clock budget (ms) for a SINGLE decode(vin) call, spanning all of its
|
||||
* internal attempts. A single Vinpin seat is shared by every decode, so one VIN
|
||||
* that gets stuck in a re-establish/relaunch livelock must never grind for
|
||||
* minutes and starve real decodes. When the budget is exceeded the driver aborts,
|
||||
* tears the browser down, poisons the seat (forcing the next decode to cold
|
||||
* re-establish) and returns null. Kept well above the healthy p90 (a warm decode
|
||||
* is seconds; a full cold re-establish is ~60-80s) so it only ever fires on a
|
||||
* genuine stall. Bumped 150s→180s to leave a cheap safety margin for a slow cold
|
||||
* establish stacking on the Rpartstore launch-error probe + the Dialogys fallback
|
||||
* decode (~25s) — the Rpartstore launch-error short-circuit means we no longer
|
||||
* waste ~54s of reopens, but the extra headroom covers a cold-establish edge.
|
||||
* Override with VINPIN_DECODE_BUDGET_MS. */
|
||||
export const VINPIN_DECODE_BUDGET_MS = 180_000;
|
||||
|
||||
/**
|
||||
* Rpartstore acquire-with-spinner-guard tunables. A freshly-opened Rpartstore
|
||||
* instance sometimes gets "born stuck" on an infinite spinner (survives
|
||||
* raise/maximize); the only reliable fix is to CLOSE the stuck instance and
|
||||
* reopen a FRESH one. `maxOpens` bounds the initial-open-plus-reopen attempts;
|
||||
* `acquireBudgetMs` sub-caps the whole acquire loop so a hopeless Rpartstore
|
||||
* still leaves wall-clock headroom (inside VINPIN_DECODE_BUDGET_MS) to fall back
|
||||
* to Dialogys rather than burning the entire decode budget on reopens.
|
||||
*/
|
||||
export const VINPIN_RPARTSTORE = {
|
||||
// Rpartstore has two failure modes: a genuine born-stuck spinner (reopen can
|
||||
// recover it) and a hard server-side launch error "Ошибка запуска каталога"
|
||||
// (reopen NEVER helps — Rpartstore is DOWN). Detecting the tiny centered error
|
||||
// modal by OCR proved unreliable across renderings, and burning reopens on a
|
||||
// DOWN Rpartstore (a) wastes ~54s and (b) dirties the RDS desktop so the
|
||||
// Dialogys fallback then can't finish inside the budget.
|
||||
// `maxOpens` still bounds reopen attempts (a real transient spinner can recover
|
||||
// on a reopen), but `acquireBudgetMs` is the hard lever: one open+load-poll is
|
||||
// ~22s, so a 14s wall-clock sub-cap makes the loop bail after the FIRST open if
|
||||
// it hasn't loaded — straight to Dialogys on a still-clean seat (~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
|
||||
* text. The modal interleaves Cyrillic boilerplate, a platform code, the model
|
||||
* name, a SINCOM code and a trim string; matching against a known-token list is
|
||||
* far more robust than positional parsing. Extend as new models appear.
|
||||
*/
|
||||
export const FIAT_MODEL_TOKENS = [
|
||||
"TIPO",
|
||||
"EGEA",
|
||||
"LINEA",
|
||||
"DOBLO",
|
||||
"FIORINO",
|
||||
"PUNTO",
|
||||
"PANDA",
|
||||
"BRAVO",
|
||||
"STILO",
|
||||
"ALBEA",
|
||||
"PALIO",
|
||||
"SIENA",
|
||||
"MAREA",
|
||||
"500",
|
||||
"500L",
|
||||
"500X",
|
||||
"QUBO",
|
||||
"DUCATO",
|
||||
"SCUDO",
|
||||
"TALENTO",
|
||||
"FREEMONT",
|
||||
"CROMA",
|
||||
"IDEA",
|
||||
"SEDICI",
|
||||
"MULTIPLA",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Known Renault/Dacia model tokens used to extract the model name from the
|
||||
* Rpartstore/Dialogys vehicle-page header. Same token-matching strategy as the
|
||||
* Fiat parser (far more robust than positional parsing against OCR noise).
|
||||
* Generation numbers / roman numerals in the header are ignored — PL24 catalog
|
||||
* rows carry their own generation suffixes and matching is best-effort.
|
||||
*/
|
||||
export const RENAULT_MODEL_TOKENS = [
|
||||
"CLIO",
|
||||
"LUTECIA", // Clio's JP/TR export name — appears in PL24 rows ("CLIO 4 / LUTECIA 4")
|
||||
"MEGANE",
|
||||
"SCENIC",
|
||||
"KANGOO",
|
||||
"LAGUNA",
|
||||
"LATITUDE",
|
||||
"SAFRANE",
|
||||
"FLUENCE",
|
||||
"SYMBOL",
|
||||
"THALIA",
|
||||
"KADJAR",
|
||||
"CAPTUR",
|
||||
"DUSTER",
|
||||
"SANDERO",
|
||||
"LOGAN",
|
||||
"TALISMAN",
|
||||
"ESPACE",
|
||||
"TWINGO",
|
||||
"MODUS",
|
||||
"KOLEOS",
|
||||
"TRAFIC",
|
||||
"MASTER",
|
||||
"TALIANT",
|
||||
"ARKANA",
|
||||
"AUSTRAL",
|
||||
"VELSATIS",
|
||||
"TWIZY",
|
||||
"ZOE",
|
||||
"EXPRESS",
|
||||
// Dacia
|
||||
"DOKKER",
|
||||
"LODGY",
|
||||
"SPRING",
|
||||
// Old R-number platforms + TR-market badges (pre-2000). These genuinely need
|
||||
// Rpartstore to VIN-resolve, so this mostly future-proofs the header parse for
|
||||
// an old Renault (e.g. VF1553… R19). The parser tokenises on non-alphanumerics,
|
||||
// so a BARE "19" would collide with year/engine digits — the R-prefixed form
|
||||
// ("R19") is required so it only matches the actual model badge, never a number.
|
||||
"R5",
|
||||
"R9",
|
||||
"R11",
|
||||
"R12",
|
||||
"R19",
|
||||
"R21",
|
||||
"R25",
|
||||
"EUROPA", // R19 Europa (TR)
|
||||
"BROADWAY", // R9 Broadway (TR)
|
||||
"TOROS", // R12 Toros (TR)
|
||||
"FLASH", // R11 Flash (TR)
|
||||
] as const;
|
||||
|
||||
/** WMIs that route to the Renault (Rpartstore/Dialogys) flow. VF1/VF2 are the
|
||||
* common Renault WMIs; VF6/VF7 are included per the benchmark (some Renault/
|
||||
* Dacia LCVs and older platforms). Dacia-badged cars often carry a Renault WMI
|
||||
* (e.g. VF1 Duster) — the RENAULT-vs-DACIA distinction is read from the decode
|
||||
* header, not the WMI. UU1 is the dedicated Dacia WMI. */
|
||||
const RENAULT_FLOW_WMIS = new Set<string>(["VF1", "VF2", "VF6", "VF7", "UU1"]);
|
||||
|
||||
/**
|
||||
* Pick the Vinpin catalog flow for a VIN by brand. Renault/Dacia → the new
|
||||
* Rpartstore/Dialogys flow; everything else (including Fiat) → the existing ePER
|
||||
* flow, so Fiat behaviour is byte-identical. Derived from the WMI: trusts
|
||||
* `getBrandFromWmi` first (Renault/Dacia), then a Renault-WMI allowlist.
|
||||
*/
|
||||
export function selectVinpinBrandFlow(vin: string): "renault" | "fiat" {
|
||||
const wmi = (vin || "").toUpperCase().slice(0, 3);
|
||||
const brand = getBrandFromWmi(wmi);
|
||||
if (brand === "Renault" || brand === "Dacia") return "renault";
|
||||
if (RENAULT_FLOW_WMIS.has(wmi)) return "renault";
|
||||
return "fiat";
|
||||
}
|
||||
|
||||
/**
|
||||
* Brands for which the Vinpin fallback is enabled. Fiat (Egea/NM4356 no-catalog
|
||||
* cluster) + Renault/Dacia (Rpartstore/Dialogys flow). A Set so it's trivially
|
||||
* extensible. Compared case-insensitively against the canonical browse brand.
|
||||
*/
|
||||
export const VINPIN_BRAND_ALLOWLIST = new Set<string>(["fiat", "renault", "dacia"]);
|
||||
|
||||
export function isVinpinBrandAllowed(brand: string | null | undefined): boolean {
|
||||
if (!brand) return false;
|
||||
return VINPIN_BRAND_ALLOWLIST.has(brand.trim().toLowerCase());
|
||||
}
|
||||
187
apps/api/src/integrations/vinpin/vinpin.matcher.spec.ts
Normal file
187
apps/api/src/integrations/vinpin/vinpin.matcher.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type CatalogCandidate,
|
||||
modelTokens,
|
||||
parseYearRange,
|
||||
pickBestCatalogMatch,
|
||||
} from "./vinpin.matcher";
|
||||
|
||||
describe("modelTokens", () => {
|
||||
it("drops parenthetical year ranges and bare years, keeps model tokens", () => {
|
||||
expect(modelTokens("TIPO - EGEA (2015-2021)")).toEqual(["TIPO", "EGEA"]);
|
||||
expect(modelTokens("Tipo-Egea MCA (2020-....)")).toEqual(["TIPO", "EGEA", "MCA"]);
|
||||
expect(modelTokens("DOBLO REST. 2005 (2005-2016)")).toEqual(["DOBLO", "REST"]);
|
||||
});
|
||||
|
||||
it("keeps engine displacements as tokens (only 1950-2039 count as years)", () => {
|
||||
// The 4-digit engine sizes must survive — dropping them made the ancient
|
||||
// "TIPO 1100-1370-1580" collapse to just ["TIPO"] and win the tiebreak.
|
||||
expect(modelTokens("TIPO 1100-1370-1580 (1987-1993)")).toEqual([
|
||||
"TIPO",
|
||||
"1100",
|
||||
"1370",
|
||||
"1580",
|
||||
]);
|
||||
expect(modelTokens("TIPO 1750-2000 (1990-1993)")).toEqual(["TIPO", "1750"]); // 2000 = year-range
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseYearRange", () => {
|
||||
it("parses closed and open-ended ranges", () => {
|
||||
expect(parseYearRange("(2015-2021)")).toEqual({ start: 2015, end: 2021 });
|
||||
expect(parseYearRange("Tipo-Egea MCA (2020-....)")).toEqual({ start: 2020, end: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickBestCatalogMatch", () => {
|
||||
const candidates: CatalogCandidate[] = [
|
||||
{ id: "egea-mca", model: "Tipo-Egea MCA (2020-....)", year: "2020-" },
|
||||
{ id: "egea", model: "TIPO - EGEA (2015-2021)", year: "2015-2021" },
|
||||
{ id: "linea", model: "LINEA (2007-2017)", year: "2007-2017" },
|
||||
{ id: "doblo", model: "DOBLO REST. 2005 (2005-2016)", year: "2005-2016" },
|
||||
];
|
||||
|
||||
it("matches TIPO-EGEA 2017 to the year-overlapping Egea catalog (not the 2020 MCA)", () => {
|
||||
const id = pickBestCatalogMatch({ model: "TIPO-EGEA", modelYear: "2017" }, candidates);
|
||||
expect(id).toBe("egea");
|
||||
});
|
||||
|
||||
it("prefers the MCA Egea for a 2021+ car", () => {
|
||||
const id = pickBestCatalogMatch({ model: "TIPO-EGEA", modelYear: "2022" }, candidates);
|
||||
expect(id).toBe("egea-mca");
|
||||
});
|
||||
|
||||
it("matches a single-token model (LINEA)", () => {
|
||||
const id = pickBestCatalogMatch({ model: "LINEA", modelYear: "2010" }, candidates);
|
||||
expect(id).toBe("linea");
|
||||
});
|
||||
|
||||
it("returns null when no candidate contains all decoded model tokens", () => {
|
||||
const id = pickBestCatalogMatch({ model: "PANDA", modelYear: "2018" }, candidates);
|
||||
expect(id).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an empty model", () => {
|
||||
expect(pickBestCatalogMatch({ model: null, modelYear: "2018" }, candidates)).toBeNull();
|
||||
});
|
||||
|
||||
it("never falls back onto an ancient catalog for a bare, year-less model", () => {
|
||||
const withAncient: CatalogCandidate[] = [
|
||||
...candidates,
|
||||
{ id: "tipo-1987", model: "TIPO 1100-1370-1580 (1987-1993)", year: "1987-1993" },
|
||||
];
|
||||
// Bare "TIPO", no year (the exact shape of the NM4131 false positive that
|
||||
// used to land on the 1987 Tipo). Must resolve to a modern Egea instead.
|
||||
const id = pickBestCatalogMatch({ model: "TIPO", modelYear: null }, withAncient);
|
||||
expect(id).not.toBe("tipo-1987");
|
||||
expect(["egea", "egea-mca"]).toContain(id);
|
||||
});
|
||||
|
||||
it("breaks a score tie toward the newer catalog", () => {
|
||||
const puntos: CatalogCandidate[] = [
|
||||
{ id: "punto-old", model: "PUNTO (1993-1999)", year: "1993-1999" },
|
||||
{ id: "punto-new", model: "PUNTO (2012-2018)", year: "2012-2018" },
|
||||
];
|
||||
// Identical tokens + no year → equal score → recency decides.
|
||||
expect(pickBestCatalogMatch({ model: "PUNTO", modelYear: null }, puntos)).toBe("punto-new");
|
||||
});
|
||||
|
||||
it("breaks a score tie toward the richer catalog (more categories)", () => {
|
||||
const koleos: CatalogCandidate[] = [
|
||||
{ id: "koleos-thin", model: "KOLEOS", year: null, categoryCount: 12 },
|
||||
{ id: "koleos-full", model: "KOLEOS", year: null, categoryCount: 44 },
|
||||
];
|
||||
// Identical tokens + no year → equal score → category count decides.
|
||||
expect(pickBestCatalogMatch({ model: "KOLEOS", modelYear: null }, koleos)).toBe("koleos-full");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickBestCatalogMatch — market-qualifier precision (Renault/Dacia)", () => {
|
||||
it("prefers the EXACT model over a wrong-market superset (KADJAR beats KADJAR ÇİN)", () => {
|
||||
// The confirmed prod bug: a European VF1 Renault decoded as "KADJAR" landed
|
||||
// on the China-market "KADJAR ÇİN" catalog (fewer categories, wrong parts).
|
||||
const candidates: CatalogCandidate[] = [
|
||||
{ id: "kadjar-cn", model: "KADJAR ÇİN", year: null, categoryCount: 22 },
|
||||
{ id: "kadjar-eu", model: "KADJAR", year: null, categoryCount: 44 },
|
||||
];
|
||||
expect(pickBestCatalogMatch({ model: "KADJAR", modelYear: "2018" }, candidates)).toBe(
|
||||
"kadjar-eu",
|
||||
);
|
||||
});
|
||||
|
||||
it("still picks the exact model even when the market candidate is richer", () => {
|
||||
// Market penalty must dominate — an exact match wins regardless of category
|
||||
// count or ordering.
|
||||
const candidates: CatalogCandidate[] = [
|
||||
{ id: "kadjar-cn", model: "KADJAR ÇİN", year: null, categoryCount: 999 },
|
||||
{ id: "kadjar-eu", model: "KADJAR", year: null, categoryCount: 1 },
|
||||
];
|
||||
expect(pickBestCatalogMatch({ model: "KADJAR", modelYear: null }, candidates)).toBe(
|
||||
"kadjar-eu",
|
||||
);
|
||||
});
|
||||
|
||||
it("penalizes a market qualifier but NOT a generation token (CLIO)", () => {
|
||||
const candidates: CatalogCandidate[] = [
|
||||
{ id: "clio-cn", model: "CLIO ÇİN", year: null },
|
||||
{ id: "clio", model: "CLIO", year: null },
|
||||
];
|
||||
// Exact "CLIO" beats the China-market superset.
|
||||
expect(pickBestCatalogMatch({ model: "CLIO", modelYear: null }, candidates)).toBe("clio");
|
||||
});
|
||||
|
||||
it("keeps generations matchable — a generation extra token is not a market penalty", () => {
|
||||
// Decoded "CLIO" with only generation-qualified catalogs (no exact bare CLIO)
|
||||
// and one China catalog. The generation candidate must win over the market one,
|
||||
// proving the market penalty doesn't collateral-damage generations.
|
||||
const candidates: CatalogCandidate[] = [
|
||||
{ id: "clio-cn", model: "CLIO ÇİN", year: null },
|
||||
{
|
||||
id: "clio-4",
|
||||
model: "CLIO 4 / LUTECIA 4 (2012-2019)",
|
||||
year: "2012-2019",
|
||||
categoryCount: 40,
|
||||
},
|
||||
{
|
||||
id: "clio-3",
|
||||
model: "CLIO 3 / LUTECIA 3 (2005-2014)",
|
||||
year: "2005-2014",
|
||||
categoryCount: 30,
|
||||
},
|
||||
];
|
||||
const id = pickBestCatalogMatch({ model: "CLIO", modelYear: "2015" }, candidates);
|
||||
// Year-overlap picks the 2012-2019 gen-4; the China catalog is penalized out.
|
||||
expect(id).toBe("clio-4");
|
||||
});
|
||||
|
||||
it("matches an exact generation model and penalizes only the market superset (DUSTER II)", () => {
|
||||
// Roman-numeral generations survive tokenization (single digits are dropped by
|
||||
// the length>=2 filter, an existing behavior). Decoded "DUSTER II" is exact on
|
||||
// duster-2; "DUSTER II ÇİN" carries an extra market token and is penalized out.
|
||||
const candidates: CatalogCandidate[] = [
|
||||
{ id: "duster-2", model: "DUSTER II", year: null, categoryCount: 30 },
|
||||
{ id: "duster-cn", model: "DUSTER II ÇİN", year: null, categoryCount: 30 },
|
||||
];
|
||||
expect(pickBestCatalogMatch({ model: "DUSTER II", modelYear: null }, candidates)).toBe(
|
||||
"duster-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to a market catalog only when it is the sole candidate", () => {
|
||||
// If the ONLY catalog for a decoded model is a regional one, still match it —
|
||||
// the penalty lowers the score but does not disqualify.
|
||||
const candidates: CatalogCandidate[] = [{ id: "x62-cn", model: "X62 CHINE", year: null }];
|
||||
expect(pickBestCatalogMatch({ model: "X62", modelYear: null }, candidates)).toBe("x62-cn");
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelTokens — diacritic folding", () => {
|
||||
it("folds Turkish diacritics so market qualifiers survive as ASCII tokens", () => {
|
||||
// Without folding, ÇİN loses Ç/İ to the ASCII strip and collapses to a
|
||||
// dropped 1-char "N", making KADJAR ÇİN tokenize identically to KADJAR.
|
||||
expect(modelTokens("KADJAR ÇİN")).toEqual(["KADJAR", "CIN"]);
|
||||
expect(modelTokens("ARKANA RUSYA")).toEqual(["ARKANA", "RUSYA"]);
|
||||
// The single-digit "2" is dropped by the length>=2 filter (existing behavior).
|
||||
expect(modelTokens("KOLEOS 2 - ÇİN")).toEqual(["KOLEOS", "CIN"]);
|
||||
});
|
||||
});
|
||||
265
apps/api/src/integrations/vinpin/vinpin.matcher.ts
Normal file
265
apps/api/src/integrations/vinpin/vinpin.matcher.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Vinpin catalog matcher — maps a Vinpin-decoded Fiat model (e.g. "TIPO-EGEA")
|
||||
* + model year onto an EXISTING PL24 catalog_vehicles row, so the parts can be
|
||||
* served from PL24's catalog. Vinpin only identifies the car; PL24 already has
|
||||
* the Egea/Linea/Doblo/etc. catalogs.
|
||||
*
|
||||
* The pure picker (`pickBestCatalogMatch`) is unit-tested directly; the
|
||||
* @Injectable wrapper runs the DB query (brand='Fiat', source='pl24') and hands
|
||||
* the rows to the picker.
|
||||
*/
|
||||
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../../database/database.provider";
|
||||
import { catalogVehicles } from "../../database/schema/core";
|
||||
|
||||
export interface CatalogCandidate {
|
||||
id: string;
|
||||
model: string | null;
|
||||
year: string | null;
|
||||
/** How many catalog categories this vehicle has fetched. Used only as a final
|
||||
* tiebreak among otherwise-equal candidates — the fuller catalog is the
|
||||
* mainstream one. Optional so the pure picker can be unit-tested without it. */
|
||||
categoryCount?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Market/region qualifier tokens (diacritic-folded, upper-case). A catalog model
|
||||
* that carries one of these tokens BEYOND the decoded model targets a specific
|
||||
* regional market — e.g. "KADJAR ÇİN" is the China-market Kadjar catalog (source
|
||||
* XZH, 22 categories) vs the mainstream European "KADJAR" (XFE, 44 categories).
|
||||
* For a European (VF1…) VIN the regional catalog serves the wrong parts, so a
|
||||
* candidate whose EXTRA tokens are region qualifiers is heavily penalized — but
|
||||
* only when the token is extra (the decode itself doesn't carry a market today).
|
||||
*
|
||||
* Generation tokens (roman numerals, digits, platform codes like "II"/"3"/"XM3")
|
||||
* are deliberately NOT here, so multi-generation models stay fully matchable;
|
||||
* only MARKET qualifiers get the penalty.
|
||||
*
|
||||
* Grounded in prod data — the real Renault/Dacia catalog models that carry a
|
||||
* region qualifier are: KADJAR ÇİN, CAPTUR II ÇİN, KOLEOS 2 - ÇİN, ARKANA RUSYA
|
||||
* and X62 CHINE. ("EUROPE" appears too — ARKANA EUROPE, CAPTUR II EUROPE — but
|
||||
* that is the mainstream market and is never penalized.)
|
||||
*/
|
||||
export const MARKET_QUALIFIERS = new Set<string>([
|
||||
// China — "ÇİN" folds to CIN via diacritic normalization; CHINE is the French
|
||||
// spelling seen in "X62 CHINE".
|
||||
"CIN",
|
||||
"CHINA",
|
||||
"CHINE",
|
||||
"CN",
|
||||
// Russia
|
||||
"RUSYA",
|
||||
"RUSSIA",
|
||||
"RU",
|
||||
// Brazil
|
||||
"BREZILYA",
|
||||
"BRAZIL",
|
||||
"BR",
|
||||
// India
|
||||
"HINDISTAN",
|
||||
"INDIA",
|
||||
"IN",
|
||||
// Korea
|
||||
"KORE",
|
||||
"KOREA",
|
||||
// Mexico / Mercosur
|
||||
"MEKSIKA",
|
||||
"MEXICO",
|
||||
"MERCOSUR",
|
||||
// Other regional
|
||||
"GCC",
|
||||
"USA",
|
||||
"AMERIKA",
|
||||
]);
|
||||
|
||||
export interface DecodedForMatch {
|
||||
model: string | null;
|
||||
modelYear: string | null;
|
||||
/** Brand to match against (catalog_vehicles.brand_name). Defaults to Fiat when
|
||||
* omitted, preserving the original Fiat-only behaviour. */
|
||||
brand?: string | null;
|
||||
}
|
||||
|
||||
/** Tokenize a model string into upper-case model tokens, dropping parenthetical
|
||||
* groups (year ranges) and bare 4-digit *years*. "TIPO - EGEA (2015-2021)" →
|
||||
* ["TIPO","EGEA"]. Keeps "500"/"500X" AND engine displacements like 1100 / 1370
|
||||
* / 1580 / 2400 — only 1950-2039 (plausible car years) are treated as years.
|
||||
* Dropping displacements made ancient catalogs ("TIPO 1100-1370-1580") collapse
|
||||
* to just ["TIPO"], looking falsely specific and winning the fewest-extra-tokens
|
||||
* tiebreak over the modern Egea. */
|
||||
export function modelTokens(s: string | null | undefined): string[] {
|
||||
if (!s) return [];
|
||||
return (
|
||||
s
|
||||
.toUpperCase()
|
||||
// Fold diacritics to ASCII so market qualifiers survive tokenization. Without
|
||||
// this the Turkish "ÇİN" (China) loses its Ç/İ to the [^A-Z0-9] strip below
|
||||
// and collapses to a dropped 1-char "N" — making "KADJAR ÇİN" tokenize
|
||||
// IDENTICALLY to "KADJAR" and defeating both the specificity tiebreak and the
|
||||
// market penalty. NFD + combining-mark removal maps ÇİN→CIN, Ş→S, Ğ→G, Ö→O …
|
||||
.normalize("NFD")
|
||||
.replace(/\p{M}/gu, "") // strip the combining marks NFD split off
|
||||
.replace(/\([^)]*\)/g, " ") // drop parenthetical year ranges
|
||||
.replace(/\b(19[5-9]\d|20[0-3]\d)\b/g, " ") // drop bare YEARS 1950-2039; keep displacements
|
||||
.replace(/[^A-Z0-9]+/g, " ")
|
||||
.split(" ")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length >= 2)
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse a year range from free text. Returns {start, end} where end===null
|
||||
* means open-ended ("(2020-....)"). */
|
||||
export function parseYearRange(
|
||||
s: string | null | undefined,
|
||||
): { start: number; end: number | null } | null {
|
||||
if (!s) return null;
|
||||
const m = s.match(/(\d{4})\s*[-–—]\s*(\d{4}|\.+|present|now)?/i);
|
||||
if (!m) {
|
||||
const single = s.match(/\b(\d{4})\b/);
|
||||
if (single) {
|
||||
const y = Number(single[1]);
|
||||
return { start: y, end: y };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const start = Number(m[1]);
|
||||
const endRaw = m[2];
|
||||
const end = endRaw && /^\d{4}$/.test(endRaw) ? Number(endRaw) : null;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function yearInRange(year: number, range: { start: number; end: number | null }): boolean {
|
||||
if (year < range.start) return false;
|
||||
if (range.end !== null && year > range.end) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best catalog_vehicles row for a decoded model + year. A candidate
|
||||
* qualifies only when EVERY decoded model token appears in the candidate's
|
||||
* model tokens (subset match). Scoring, strongest signal first:
|
||||
* 1. Year-range overlap — the strongest signal; routes multi-generation models
|
||||
* (a 2022 "TIPO-EGEA" → the 2020+ MCA catalog, not the 2015-2021 one).
|
||||
* 2. EXACT normalized model match (candidate tokens ≡ decoded tokens) — a
|
||||
* smaller bonus that breaks the tie in favour of a plain "KADJAR" over the
|
||||
* superset "KADJAR ÇİN" when no year distinguishes them.
|
||||
* 3. Market-qualifier penalty — extra tokens that are region qualifiers (ÇİN,
|
||||
* RUSYA, …) make this a WRONG-market catalog; penalized hard so it only ever
|
||||
* wins as the sole candidate. Generation / other extra tokens keep only the
|
||||
* mild "fewer extras = more specific" penalty, so generations stay matchable.
|
||||
* 4. Tiebreaks on equal score: richer catalog (more categories), then newer.
|
||||
* Returns the id or null.
|
||||
*/
|
||||
export function pickBestCatalogMatch(
|
||||
decoded: DecodedForMatch,
|
||||
candidates: CatalogCandidate[],
|
||||
): string | null {
|
||||
const decTokens = modelTokens(decoded.model);
|
||||
if (decTokens.length === 0) return null;
|
||||
const decYear = decoded.modelYear ? Number.parseInt(decoded.modelYear, 10) : null;
|
||||
|
||||
let best: {
|
||||
id: string;
|
||||
score: number;
|
||||
categoryCount: number;
|
||||
startYear: number;
|
||||
} | null = null;
|
||||
|
||||
for (const cand of candidates) {
|
||||
const candText = `${cand.model ?? ""} ${cand.year ?? ""}`;
|
||||
const candTokens = modelTokens(candText);
|
||||
const candSet = new Set(candTokens);
|
||||
|
||||
// Subset requirement — all decoded model tokens must be present.
|
||||
if (!decTokens.every((t) => candSet.has(t))) continue;
|
||||
|
||||
const extraTokens = candTokens.filter((t) => !decTokens.includes(t));
|
||||
|
||||
let score = 1000; // base for any qualifying candidate
|
||||
|
||||
// (1) EXACT normalized model-name match: candidate carries NO tokens beyond
|
||||
// the decoded model. This breaks the tie in favour of the plain model over a
|
||||
// superset (e.g. "KADJAR" over "KADJAR ÇİN" when neither has a distinguishing
|
||||
// year). Kept SMALLER than the year-range swing (±700) on purpose, so a
|
||||
// decisive model-year still routes generations correctly — a 2022 "TIPO-EGEA"
|
||||
// must still land on the 2020+ MCA catalog, not the exact-named 2015-2021 one.
|
||||
if (extraTokens.length === 0) score += 300;
|
||||
|
||||
// (2) Year-range overlap.
|
||||
const range = parseYearRange(candText);
|
||||
if (decYear && Number.isFinite(decYear) && range) {
|
||||
score += yearInRange(decYear, range) ? 500 : -200;
|
||||
}
|
||||
|
||||
// (3) Extra-token penalties. A region-qualifier extra token means the
|
||||
// candidate is a wrong-market catalog for a decode that carries no market —
|
||||
// penalize hard so it can only win when it is the ONLY candidate. Everything
|
||||
// else (generation numerals, platform codes) keeps the mild specificity
|
||||
// penalty, leaving multi-generation models fully matchable.
|
||||
const marketExtras = extraTokens.filter((t) => MARKET_QUALIFIERS.has(t)).length;
|
||||
const otherExtras = extraTokens.length - marketExtras;
|
||||
score -= otherExtras;
|
||||
score -= marketExtras * 5000;
|
||||
|
||||
// (4) Tiebreaks on equal score: prefer the richer catalog (more categories —
|
||||
// the fuller catalog is the mainstream one), then the newer generation. A
|
||||
// bare, year-less model (e.g. "TIPO") must never fall back onto an ancient
|
||||
// generation — modern is the overwhelmingly likelier intent.
|
||||
const categoryCount = cand.categoryCount ?? 0;
|
||||
const startYear = range?.start ?? 0;
|
||||
const better =
|
||||
!best ||
|
||||
score > best.score ||
|
||||
(score === best.score && categoryCount > best.categoryCount) ||
|
||||
(score === best.score && categoryCount === best.categoryCount && startYear > best.startYear);
|
||||
if (better) {
|
||||
best = { id: cand.id, score, categoryCount, startYear };
|
||||
}
|
||||
}
|
||||
|
||||
return best?.id ?? null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VinpinCatalogMatcher {
|
||||
private readonly logger = new Logger(VinpinCatalogMatcher.name);
|
||||
|
||||
constructor(@Inject(DATABASE) private readonly db: Database) {}
|
||||
|
||||
/**
|
||||
* Find the best PL24 catalog_vehicle (of the decoded brand — Fiat by default,
|
||||
* or Renault/Dacia) for a decoded model + year. Returns the id, or null.
|
||||
*/
|
||||
async match(decoded: DecodedForMatch): Promise<string | null> {
|
||||
if (!decoded.model) return null;
|
||||
const brand = (decoded.brand ?? "Fiat").toLowerCase();
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: catalogVehicles.id,
|
||||
model: catalogVehicles.model,
|
||||
year: catalogVehicles.year,
|
||||
// Category count feeds the richer-catalog tiebreak (more categories = the
|
||||
// mainstream catalog). Correlated subquery keeps the row shape flat.
|
||||
categoryCount: sql<number>`(
|
||||
SELECT count(*)::int FROM categories
|
||||
WHERE categories.catalog_vehicle_id = ${catalogVehicles.id}
|
||||
)`,
|
||||
})
|
||||
.from(catalogVehicles)
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${catalogVehicles.brandName}) = ${brand}`,
|
||||
eq(catalogVehicles.source, "pl24"),
|
||||
),
|
||||
);
|
||||
|
||||
const id = pickBestCatalogMatch(decoded, rows as CatalogCandidate[]);
|
||||
this.logger.log(
|
||||
`match brand=${brand} model="${decoded.model}" year=${decoded.modelYear ?? "?"} → ${id ?? "null"} (over ${rows.length} ${brand} catalog vehicles)`,
|
||||
);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
16
apps/api/src/integrations/vinpin/vinpin.module.ts
Normal file
16
apps/api/src/integrations/vinpin/vinpin.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { VinpinDriverService } from "./vinpin-driver.service";
|
||||
import { VinpinCatalogMatcher } from "./vinpin.matcher";
|
||||
|
||||
/**
|
||||
* Vinpin ePER decode-oracle module. Feature-flagged (VINPIN_ENABLED) — every
|
||||
* component is a strict no-op when the flag is off. The actual browser driving
|
||||
* happens in the standalone worker (vinpin-decode processor) via the module-level
|
||||
* singleton; this NestModule wires the services into the API graph for DI
|
||||
* completeness and future direct use (e.g. a health endpoint or admin trigger).
|
||||
*/
|
||||
@Module({
|
||||
providers: [VinpinDriverService, VinpinCatalogMatcher],
|
||||
exports: [VinpinDriverService, VinpinCatalogMatcher],
|
||||
})
|
||||
export class VinpinModule {}
|
||||
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
|
||||
});
|
||||
});
|
||||
183
apps/api/src/integrations/vinpin/vinpin.ocr.ts
Normal file
183
apps/api/src/integrations/vinpin/vinpin.ocr.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Vinpin OCR — the reliable read channel for the Blast/Horizon pixel canvas.
|
||||
*
|
||||
* The ePER catalog renders into a canvas (no DOM text, clipboard is intermittent),
|
||||
* so the ONLY dependable way to read the decode modal is a screenshot + OCR.
|
||||
*
|
||||
* Pipeline: `page.screenshot({ clip })` (native crop) → optional ffmpeg 3× upscale
|
||||
* (greatly improves small-text accuracy; degrades gracefully to raw pixels when
|
||||
* ffmpeg is absent) → tesseract.js (pure-JS, no system binary). The tesseract
|
||||
* worker is a lazily-created process-wide singleton (eng traineddata cached on
|
||||
* first use), reused across every decode in the warm session.
|
||||
*
|
||||
* Everything here is failure-tolerant: any error → "" so the driver treats it as
|
||||
* "no decode" and never throws to its caller.
|
||||
*
|
||||
* ⚠️ RUNTIME NOTE: `ffmpeg` is a SOFT dependency (upscale only). tesseract.js
|
||||
* downloads `eng.traineddata.gz` once (network) and caches it under
|
||||
* VINPIN_OCR_CACHE (default os.tmpdir()/vinpin-tess). Bundle the traineddata in
|
||||
* the image if the worker has no egress.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import type { Page } from "playwright";
|
||||
|
||||
const logger = new Logger("VinpinOcr");
|
||||
|
||||
export interface OcrClip {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Minimal structural type for the tesseract.js worker we use (avoids a hard type
|
||||
// dep / `any` while keeping it decoupled from the lib's exported types).
|
||||
interface TessWorker {
|
||||
recognize(image: Buffer): Promise<{ data: { text: string } }>;
|
||||
terminate(): Promise<unknown>;
|
||||
}
|
||||
|
||||
let workerPromise: Promise<TessWorker> | null = null;
|
||||
|
||||
async function getWorker(): Promise<TessWorker> {
|
||||
if (!workerPromise) {
|
||||
workerPromise = (async () => {
|
||||
// Dynamic import so a missing/broken OCR dep can never crash module load.
|
||||
const tesseract = (await import("tesseract.js")) as unknown as {
|
||||
createWorker: (
|
||||
lang: string,
|
||||
oem: number,
|
||||
opts: { cachePath: string; gzip: boolean },
|
||||
) => Promise<TessWorker>;
|
||||
};
|
||||
const cachePath = process.env.VINPIN_OCR_CACHE || join(tmpdir(), "vinpin-tess");
|
||||
return tesseract.createWorker("eng", 1, { cachePath, gzip: true });
|
||||
})().catch((err) => {
|
||||
workerPromise = null; // allow a later retry
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return workerPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop a `region` out of a full-page PNG and upscale `scale`× via ffmpeg.
|
||||
*
|
||||
* IMPORTANT: we crop from a FULL screenshot with ffmpeg rather than using
|
||||
* Playwright's `screenshot({ clip })`. Clip-capture of the Blast compositor
|
||||
* canvas intermittently returns a BLANK image under headful/xvfb even when the
|
||||
* full screenshot is fine (verified failure mode) — full-frame capture is
|
||||
* reliable. Falls back to the untouched PNG when ffmpeg is missing/errors.
|
||||
*/
|
||||
function cropScale(png: Buffer, region: OcrClip | undefined, scale: number): Promise<Buffer> {
|
||||
if (!region && scale <= 1) return Promise.resolve(png);
|
||||
const filters: string[] = [];
|
||||
if (region) filters.push(`crop=${region.width}:${region.height}:${region.x}:${region.y}`);
|
||||
if (scale > 1) filters.push(`scale=iw*${scale}:ih*${scale}:flags=lanczos`);
|
||||
filters.push("format=gray");
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const ff = spawn("ffmpeg", [
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-vf",
|
||||
filters.join(","),
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-vcodec",
|
||||
"png",
|
||||
"pipe:1",
|
||||
]);
|
||||
const chunks: Buffer[] = [];
|
||||
ff.stdout.on("data", (c: Buffer) => chunks.push(c));
|
||||
ff.on("error", () => resolve(png)); // ffmpeg not installed
|
||||
ff.on("close", (code) => resolve(code === 0 && chunks.length ? Buffer.concat(chunks) : png));
|
||||
ff.stdin.on("error", () => {});
|
||||
ff.stdin.end(png);
|
||||
} catch {
|
||||
resolve(png);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Full-page screenshot → (optional ffmpeg crop) + upscale → OCR. Returns
|
||||
* whitespace-collapsed text, or "" on any failure. `clip` undefined = OCR the
|
||||
* whole frame at 1× (state detection). */
|
||||
export async function ocrRegion(page: Page, clip?: OcrClip, scale = 3): Promise<string> {
|
||||
try {
|
||||
const full = await page.screenshot({ fullPage: false });
|
||||
const img = await cropScale(full, clip, clip ? scale : 1);
|
||||
// Opt-in diagnostics: dump the OCR'd image (great for tuning coords in prod).
|
||||
const dbg = process.env.VINPIN_DEBUG_DIR;
|
||||
if (dbg) {
|
||||
try {
|
||||
const { writeFileSync } = await import("node:fs");
|
||||
const tag = clip ? `clip-${clip.x}x${clip.y}` : "full";
|
||||
writeFileSync(join(dbg, `vinpin-ocr-${tag}-${Date.now()}.png`), img);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const worker = await getWorker();
|
||||
const { data } = await worker.recognize(img);
|
||||
return (data.text || "").replace(/\s+/g, " ").trim();
|
||||
} catch (err) {
|
||||
logger.warn(`ocrRegion failed: ${(err as Error).message}`);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a text `read` until `predicate(text)` holds, or the `capMs` budget is
|
||||
* exhausted. Returns as soon as the predicate matches (the common, fast case);
|
||||
* otherwise the total elapsed sleep equals `capMs` — the SAME fallback wait the
|
||||
* old fixed `waitForTimeout(capMs)` used, so worst-case behaviour is unchanged.
|
||||
*
|
||||
* Pure/injectable (no Playwright/OCR deps) so it can be unit-tested directly:
|
||||
* pass a fake `read`, `sleep` and `now`. The driver wraps it with an OCR read.
|
||||
*/
|
||||
export interface PollForTextOptions {
|
||||
read: () => Promise<string>;
|
||||
predicate: (text: string) => boolean;
|
||||
/** Fallback cap — total sleep never exceeds this (== the old fixed wait). */
|
||||
capMs: number;
|
||||
/** Cadence between reads. */
|
||||
intervalMs: number;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
/** Injectable clock (defaults to Date.now) — for deterministic tests. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export async function pollForText(
|
||||
opts: PollForTextOptions,
|
||||
): Promise<{ matched: boolean; text: string }> {
|
||||
const now = opts.now ?? (() => Date.now());
|
||||
const interval = Math.max(1, opts.intervalMs);
|
||||
const deadline = now() + opts.capMs;
|
||||
let text = "";
|
||||
while (now() < deadline) {
|
||||
const remaining = deadline - now();
|
||||
await opts.sleep(Math.min(interval, remaining));
|
||||
text = await opts.read();
|
||||
if (opts.predicate(text)) return { matched: true, text };
|
||||
}
|
||||
return { matched: false, text };
|
||||
}
|
||||
|
||||
/** Tear down the shared tesseract worker (best-effort). */
|
||||
export async function terminateOcr(): Promise<void> {
|
||||
if (!workerPromise) return;
|
||||
try {
|
||||
const w = await workerPromise;
|
||||
await w.terminate();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
workerPromise = null;
|
||||
}
|
||||
110
apps/api/src/integrations/vinpin/vinpin.parser.spec.ts
Normal file
110
apps/api/src/integrations/vinpin/vinpin.parser.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isUsableParse,
|
||||
isUsableRenaultParse,
|
||||
parseRenaultHeader,
|
||||
parseVinpinModal,
|
||||
} from "./vinpin.parser";
|
||||
|
||||
const SAMPLE =
|
||||
"MVS, найденные по vin NM435600006H43436 6J - TIPO - EGEA (2015-2021) 356G37001162 - 3V HIGH PLUS 1.6 120CV D5 CM E6 GS TR Двигатель: 8165300 Автомобиль: 279476 Prod. date: 2/8/2017 Vin: NM435600006H43436";
|
||||
|
||||
describe("parseVinpinModal", () => {
|
||||
it("parses the real ePER modal sample (TIPO-EGEA / 356G37001162 / 2017)", () => {
|
||||
const p = parseVinpinModal(SAMPLE);
|
||||
expect(p.model).toBe("TIPO-EGEA");
|
||||
expect(p.sincom).toBe("356G37001162");
|
||||
expect(p.modelYear).toBe("2017"); // from Prod. date, not the (2015-2021) range
|
||||
expect(p.engine).toBe("8165300");
|
||||
expect(p.trim).toContain("HIGH PLUS");
|
||||
expect(isUsableParse(p)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mistake the VIN or short engine number for a SINCOM code", () => {
|
||||
const p = parseVinpinModal(SAMPLE);
|
||||
expect(p.sincom).not.toContain("NM4356");
|
||||
expect(p.sincom).not.toBe("8165300");
|
||||
});
|
||||
|
||||
it("falls back to the year-range start when no production date is present", () => {
|
||||
const p = parseVinpinModal("9F - LINEA (2007-2017) 323A11005000 - 1.3 MJET");
|
||||
expect(p.model).toBe("LINEA");
|
||||
expect(p.sincom).toBe("323A11005000");
|
||||
expect(p.modelYear).toBe("2007");
|
||||
});
|
||||
|
||||
it("returns all-null for empty/garbage input and reports unusable", () => {
|
||||
const p = parseVinpinModal("");
|
||||
expect(p.model).toBeNull();
|
||||
expect(p.sincom).toBeNull();
|
||||
expect(isUsableParse(p)).toBe(false);
|
||||
expect(isUsableParse(parseVinpinModal(null))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a stale-breadcrumb model with no SINCOM (vehicle-not-found false positive)", () => {
|
||||
// Real garbled OCR of a failed NM4131 decode: the "не найден" alert is
|
||||
// mangled ("He HaaeHs") so it slips the not-found check, and the screen
|
||||
// still shows the operator's last catalog ("FIAT » TIPO - EGEA"). A model
|
||||
// token is present but there is NO SINCOM → must be treated as unusable so
|
||||
// it is not false-mapped onto the ancient "TIPO 1987-1993" catalog.
|
||||
const p = parseVinpinModal(
|
||||
"Fiat Dealer Spare Parts FIAT TIPO - EGBA 500 HYBRID TIPO-EGEA MCA (2020) DOBLO FREEMONT LINEA IDEA To yKasaHHeIM N3paMETpaM 3BTOMOBHMN He HaaeHs",
|
||||
);
|
||||
expect(p.model).not.toBeNull(); // breadcrumb model still extracted…
|
||||
expect(p.sincom).toBeNull(); // …but no SINCOM
|
||||
expect(isUsableParse(p)).toBe(false); // → not a real decode
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRenaultHeader", () => {
|
||||
it("parses an Rpartstore RENAULT header (model + brand)", () => {
|
||||
const p = parseRenaultHeader("RENAULT KADJAR (RFE) Şasi: VF1RFE00653633190");
|
||||
expect(p.model).toBe("KADJAR");
|
||||
expect(p.brand).toBe("RENAULT");
|
||||
expect(isUsableRenaultParse(p)).toBe(true);
|
||||
});
|
||||
|
||||
it("reads DACIA as the brand when the header carries it", () => {
|
||||
const p = parseRenaultHeader("DACIA DUSTER (HSA) Şasi: VF1HJD40865644941");
|
||||
expect(p.model).toBe("DUSTER");
|
||||
expect(p.brand).toBe("DACIA");
|
||||
});
|
||||
|
||||
it("extracts the base model and ignores the generation numeral (CLIO IV)", () => {
|
||||
const p = parseRenaultHeader("RENAULT CLIO IV (R0G) Şasi: VF15R0G0H49335605");
|
||||
expect(p.model).toBe("CLIO");
|
||||
expect(p.brand).toBe("RENAULT");
|
||||
});
|
||||
|
||||
it("handles a Dialogys header with no explicit brand (defaults RENAULT)", () => {
|
||||
const p = parseRenaultHeader("Clio II (X65), 1.5 dCi");
|
||||
expect(p.model).toBe("CLIO");
|
||||
expect(p.brand).toBe("RENAULT");
|
||||
});
|
||||
|
||||
it("captures a model year when the header carries one", () => {
|
||||
const p = parseRenaultHeader("RENAULT LATITUDE (2011-2015)");
|
||||
expect(p.model).toBe("LATITUDE");
|
||||
expect(p.modelYear).toBe("2011");
|
||||
});
|
||||
|
||||
it("does NOT mis-grab the status-bar license-expiry date as the model year", () => {
|
||||
// The full-frame OCR fallback sweeps in the Rpartstore/Dialogys status bar,
|
||||
// whose license EXPIRY date ("…14.07.2026") used to be read as the model year.
|
||||
const p = parseRenaultHeader("RENAULT KADJAR (RFE) Şasi: VF1RFE00653633190 Lisans: 14.07.2026");
|
||||
expect(p.model).toBe("KADJAR");
|
||||
expect(p.modelYear).toBeNull(); // NOT "2026" from the expiry date
|
||||
});
|
||||
|
||||
it("still prefers a genuine generation range even when an expiry date is present", () => {
|
||||
const p = parseRenaultHeader("RENAULT LATITUDE (2011-2015) Lisans: 14.07.2026");
|
||||
expect(p.modelYear).toBe("2011"); // the (2011-2015) range wins over the date
|
||||
});
|
||||
|
||||
it("returns no model / unusable for headers without a known token", () => {
|
||||
const p = parseRenaultHeader("İlişikli araç bulunamadı");
|
||||
expect(p.model).toBeNull();
|
||||
expect(isUsableRenaultParse(p)).toBe(false);
|
||||
expect(isUsableRenaultParse(parseRenaultHeader(""))).toBe(false);
|
||||
});
|
||||
});
|
||||
173
apps/api/src/integrations/vinpin/vinpin.parser.ts
Normal file
173
apps/api/src/integrations/vinpin/vinpin.parser.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Pure parser for the Vinpin ePER decode-result modal text.
|
||||
*
|
||||
* Sample modal text (clipboard dump of the result panel):
|
||||
* "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"
|
||||
*
|
||||
* → { model: "TIPO-EGEA", sincom: "356G37001162", trim: "3V HIGH PLUS 1.6 …",
|
||||
* modelYear: "2017", engine: "8165300" }
|
||||
*
|
||||
* Kept dependency-free and side-effect-free so it can be unit-tested directly.
|
||||
*/
|
||||
|
||||
import { FIAT_MODEL_TOKENS, RENAULT_MODEL_TOKENS } from "./vinpin.constants";
|
||||
|
||||
export interface VinpinParsed {
|
||||
model: string | null;
|
||||
sincom: string | null;
|
||||
trim: string | null;
|
||||
modelYear: string | null;
|
||||
engine: string | null;
|
||||
}
|
||||
|
||||
// SINCOM: 3 digits then 8-10 alphanumerics (e.g. 356G37001162). The leading
|
||||
// \b avoids matching inside the VIN (which begins with letters → no boundary
|
||||
// before a digit run) and rejects the short engine/vehicle numbers.
|
||||
const SINCOM_RE = /\b\d{3}[A-Z0-9]{8,10}\b/;
|
||||
// Engine number after the Cyrillic "Двигатель:" (or an "Engine:" fallback).
|
||||
const ENGINE_RE = /(?:Двигатель|Engine)\s*:\s*(\d+)/i;
|
||||
// Production date → take the year. e.g. "Prod. date: 2/8/2017".
|
||||
const PROD_DATE_YEAR_RE = /Prod\.?\s*date\s*:\s*\d{1,2}\/\d{1,2}\/(\d{4})/i;
|
||||
// Model-year range fallback, e.g. "(2015-2021)".
|
||||
const YEAR_RANGE_RE = /\((\d{4})\s*-\s*\d{4}\)/;
|
||||
|
||||
/**
|
||||
* Extract the model name as the run of consecutive known Fiat model tokens
|
||||
* (joined by "-"). "6J - TIPO - EGEA" → "TIPO-EGEA"; "LINEA" → "LINEA".
|
||||
*/
|
||||
function extractModel(text: string): string | null {
|
||||
const upper = text.toUpperCase();
|
||||
// Tokenize on whitespace and dashes; keep alphanumeric tokens.
|
||||
const tokens = upper.split(/[^A-Z0-9]+/).filter(Boolean);
|
||||
const known = new Set<string>(FIAT_MODEL_TOKENS as readonly string[]);
|
||||
const collected: string[] = [];
|
||||
for (const tok of tokens) {
|
||||
if (known.has(tok)) {
|
||||
collected.push(tok);
|
||||
} else if (collected.length > 0) {
|
||||
// Stop at the first non-model token AFTER we've started collecting, so we
|
||||
// capture the contiguous model phrase and not a later stray token.
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (collected.length === 0) return null;
|
||||
// De-dup consecutive repeats while preserving order.
|
||||
const deduped = collected.filter((t, i) => i === 0 || t !== collected[i - 1]);
|
||||
return deduped.join("-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the trim/description: the text between the SINCOM code and the
|
||||
* "Двигатель"/"Engine" marker, with a leading "- " stripped.
|
||||
*/
|
||||
function extractTrim(text: string, sincom: string | null): string | null {
|
||||
if (!sincom) return null;
|
||||
const sincomIdx = text.indexOf(sincom);
|
||||
if (sincomIdx === -1) return null;
|
||||
let tail = text.slice(sincomIdx + sincom.length);
|
||||
const stopMatch = tail.match(/Двигатель|Engine|Автомобиль|Prod\.?\s*date|Vin\s*:/i);
|
||||
if (stopMatch && stopMatch.index !== undefined) {
|
||||
tail = tail.slice(0, stopMatch.index);
|
||||
}
|
||||
const trim = tail.replace(/^[\s\-–—]+/, "").trim();
|
||||
return trim.length > 0 ? trim : null;
|
||||
}
|
||||
|
||||
export function parseVinpinModal(rawText: string | null | undefined): VinpinParsed {
|
||||
const text = (rawText ?? "").replace(/\s+/g, " ").trim();
|
||||
if (!text) {
|
||||
return { model: null, sincom: null, trim: null, modelYear: null, engine: null };
|
||||
}
|
||||
|
||||
const sincom = text.match(SINCOM_RE)?.[0] ?? null;
|
||||
const model = extractModel(text);
|
||||
const engine = text.match(ENGINE_RE)?.[1] ?? null;
|
||||
const modelYear = text.match(PROD_DATE_YEAR_RE)?.[1] ?? text.match(YEAR_RANGE_RE)?.[1] ?? null;
|
||||
const trim = extractTrim(text, sincom);
|
||||
|
||||
return { model, sincom, trim, modelYear, engine };
|
||||
}
|
||||
|
||||
/**
|
||||
* A parse is usable only when we got a SINCOM code — the trim identifier that a
|
||||
* genuine ePER decode always emits ("356G37001162"). A model *alone* is a stale
|
||||
* breadcrumb: when the VIN search returns "vehicle not found" (a TR NM4 VIN ePER
|
||||
* can't resolve), the screen still shows whatever catalog the operator was last
|
||||
* browsing (e.g. "FIAT » TIPO - EGEA"), and the OCR of the Cyrillic "не найден"
|
||||
* alert garbles enough to slip past the not-found check. Grabbing that
|
||||
* breadcrumb model ("TIPO"/"PANDA") and matching it to a catalog is a false
|
||||
* positive that lands users on the wrong (often ancient) catalog. Requiring the
|
||||
* SINCOM cleanly separates the two: every genuine decode carries one; the
|
||||
* breadcrumb false positives never do.
|
||||
*/
|
||||
export function isUsableParse(p: VinpinParsed): boolean {
|
||||
return Boolean(p.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")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async cancel(
|
||||
@Param("id") id: string,
|
||||
@Body() body: { reason?: string; founderId?: string },
|
||||
) {
|
||||
async cancel(@Param("id") id: string, @Body() body: { reason?: string; founderId?: string }) {
|
||||
this.requireFounder(body);
|
||||
this.requireReason(body, "cancel");
|
||||
return this.billing.cancel({
|
||||
@@ -86,10 +83,7 @@ export class BillingController {
|
||||
|
||||
@Post(":id/resume")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async resume(
|
||||
@Param("id") id: string,
|
||||
@Body() body: { reason?: string; founderId?: string },
|
||||
) {
|
||||
async resume(@Param("id") id: string, @Body() body: { reason?: string; founderId?: string }) {
|
||||
this.requireFounder(body);
|
||||
this.requireReason(body, "resume");
|
||||
return this.billing.resume({
|
||||
@@ -128,3 +122,53 @@ export class BillingController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User-scoped billing admin: start a fresh subscription for a user whose
|
||||
* previous one is expired/cancelled (the panel's "Yeni abonelik başlat").
|
||||
*/
|
||||
@Controller("internal/admin/users")
|
||||
@Public()
|
||||
@UseGuards(InternalTokenGuard)
|
||||
export class UserBillingController {
|
||||
constructor(private billing: BillingService) {}
|
||||
|
||||
@Post(":id/subscriptions")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async startSubscription(
|
||||
@Param("id") id: string,
|
||||
@Body()
|
||||
body: {
|
||||
planId?: string;
|
||||
billingPeriod?: string;
|
||||
days?: number;
|
||||
brandIds?: string[];
|
||||
reason?: string;
|
||||
founderId?: string;
|
||||
},
|
||||
) {
|
||||
if (!body.founderId) throw new BadRequestException("founderId required");
|
||||
if (!body.reason || body.reason.trim().length < 5) {
|
||||
throw new BadRequestException("start-subscription requires reason (min 5 chars)");
|
||||
}
|
||||
if (!body.planId) throw new BadRequestException("planId required");
|
||||
if (body.billingPeriod !== "monthly" && body.billingPeriod !== "yearly") {
|
||||
throw new BadRequestException("billingPeriod must be monthly|yearly");
|
||||
}
|
||||
if (body.days !== undefined && (typeof body.days !== "number" || body.days <= 0)) {
|
||||
throw new BadRequestException("days must be a positive number");
|
||||
}
|
||||
if (body.brandIds !== undefined && !Array.isArray(body.brandIds)) {
|
||||
throw new BadRequestException("brandIds must be an array");
|
||||
}
|
||||
return this.billing.startSubscription({
|
||||
userId: id,
|
||||
planId: body.planId,
|
||||
billingPeriod: body.billingPeriod,
|
||||
days: body.days,
|
||||
brandIds: body.brandIds,
|
||||
reason: body.reason,
|
||||
founderId: body.founderId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { brands, plans, userBrands, userSubscriptions } from "../database/schema/core";
|
||||
import { brands, plans, userBrands, userSubscriptions, users } from "../database/schema/core";
|
||||
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
||||
|
||||
@Injectable()
|
||||
@@ -31,8 +31,8 @@ export class BillingService {
|
||||
reason: string;
|
||||
founderId: string;
|
||||
}) {
|
||||
if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 90) {
|
||||
throw new BadRequestException("days must be 1..90");
|
||||
if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 3650) {
|
||||
throw new BadRequestException("days must be 1..3650");
|
||||
}
|
||||
const [sub] = await this.db
|
||||
.select()
|
||||
@@ -74,6 +74,133 @@ export class BillingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a brand-new ACTIVE subscription for a user whose previous one is
|
||||
* expired/cancelled (or who has none). Mirrors what activateSubscription does
|
||||
* for a paid checkout — status/dates/plan-based brand assignment — but does
|
||||
* NOT emit revenue events (PostHog subscription_activated / Meta Purchase) or
|
||||
* consume referral credit: this is a founder grant, no money moved here.
|
||||
* `days` overrides the default period (monthly → +1 ay, yearly → +1 yıl).
|
||||
*/
|
||||
async startSubscription(input: {
|
||||
userId: string;
|
||||
planId: string;
|
||||
billingPeriod: "monthly" | "yearly";
|
||||
days?: number;
|
||||
brandIds?: string[];
|
||||
reason: string;
|
||||
founderId: string;
|
||||
}) {
|
||||
if (input.billingPeriod !== "monthly" && input.billingPeriod !== "yearly") {
|
||||
throw new BadRequestException("billingPeriod must be monthly|yearly");
|
||||
}
|
||||
if (input.days !== undefined) {
|
||||
if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 3650) {
|
||||
throw new BadRequestException("days must be 1..3650");
|
||||
}
|
||||
}
|
||||
|
||||
const [user] = await this.db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.id, input.userId))
|
||||
.limit(1);
|
||||
if (!user) throw new NotFoundException("Kullanıcı bulunamadı");
|
||||
|
||||
const [live] = await this.db
|
||||
.select({ id: userSubscriptions.id, status: userSubscriptions.status })
|
||||
.from(userSubscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(userSubscriptions.userId, input.userId),
|
||||
inArray(userSubscriptions.status, ["active", "trial"]),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (live) {
|
||||
throw new ConflictException(
|
||||
`Kullanıcının zaten '${live.status}' bir subscription'ı var (${live.id}) — activate / plan değiştir / uzat kullanın`,
|
||||
);
|
||||
}
|
||||
|
||||
const [plan] = await this.db.select().from(plans).where(eq(plans.id, input.planId)).limit(1);
|
||||
if (!plan) throw new NotFoundException("Plan bulunamadı");
|
||||
if (!plan.isActive) throw new ConflictException("Plan aktif değil");
|
||||
|
||||
const activeBrands = await this.db.select().from(brands).where(eq(brands.isActive, true));
|
||||
let brandIds: string[];
|
||||
if (plan.brandCount === 0) {
|
||||
// Full plan → every active brand, whatever the caller sent.
|
||||
brandIds = activeBrands.map((b) => b.id);
|
||||
} else {
|
||||
const requested = input.brandIds ?? [];
|
||||
if (requested.length !== plan.brandCount) {
|
||||
throw new BadRequestException(
|
||||
`Bu plan tam olarak ${plan.brandCount} marka gerektirir, ${requested.length} seçildi`,
|
||||
);
|
||||
}
|
||||
if (new Set(requested).size !== requested.length) {
|
||||
throw new BadRequestException("Marka listesi tekrar içeriyor");
|
||||
}
|
||||
const valid = new Set(activeBrands.map((b) => b.id));
|
||||
for (const id of requested) {
|
||||
if (!valid.has(id)) throw new BadRequestException(`Geçersiz veya pasif marka: ${id}`);
|
||||
}
|
||||
brandIds = requested;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const endDate = new Date(now);
|
||||
if (input.days !== undefined) {
|
||||
endDate.setDate(endDate.getDate() + input.days);
|
||||
} else if (input.billingPeriod === "yearly") {
|
||||
endDate.setFullYear(endDate.getFullYear() + 1);
|
||||
} else {
|
||||
endDate.setMonth(endDate.getMonth() + 1);
|
||||
}
|
||||
|
||||
const [created] = await this.db
|
||||
.insert(userSubscriptions)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
planId: input.planId,
|
||||
status: "active",
|
||||
billingPeriod: input.billingPeriod,
|
||||
startDate: now,
|
||||
endDate,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (brandIds.length > 0) {
|
||||
await this.db.insert(userBrands).values(
|
||||
brandIds.map((brandId) => ({
|
||||
userId: input.userId,
|
||||
subscriptionId: created.id,
|
||||
brandId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`start: user=${input.userId} subscription=${created.id} plan=${plan.name} ` +
|
||||
`${input.billingPeriod}${input.days !== undefined ? ` ${input.days}d` : ""} → ${endDate.toISOString()} ` +
|
||||
`brands=${brandIds.length} founder=${input.founderId} reason="${input.reason.slice(0, 80)}"`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
subscriptionId: created.id,
|
||||
userId: input.userId,
|
||||
planId: plan.id,
|
||||
planName: plan.name,
|
||||
billingPeriod: input.billingPeriod,
|
||||
status: "active",
|
||||
startDate: created.startDate?.toISOString() ?? null,
|
||||
endDate: created.endDate?.toISOString() ?? null,
|
||||
brandCount: brandIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
async manuallyActivate(input: {
|
||||
subscriptionId: string;
|
||||
reason: string;
|
||||
@@ -126,9 +253,7 @@ export class BillingService {
|
||||
.limit(1);
|
||||
if (!sub) throw new NotFoundException("Subscription bulunamadı");
|
||||
if (sub.status !== "active" && sub.status !== "trial") {
|
||||
throw new ConflictException(
|
||||
`'${sub.status}' subscription için plan değişimi yapılamaz`,
|
||||
);
|
||||
throw new ConflictException(`'${sub.status}' subscription için plan değişimi yapılamaz`);
|
||||
}
|
||||
if (sub.planId === input.newPlanId) {
|
||||
throw new ConflictException("Subscription zaten bu planda");
|
||||
@@ -234,16 +359,10 @@ export class BillingService {
|
||||
.limit(1);
|
||||
if (!sub) throw new NotFoundException("Subscription bulunamadı");
|
||||
if (sub.status !== "active" && sub.status !== "trial") {
|
||||
throw new ConflictException(
|
||||
`'${sub.status}' subscription için brand atama yapılamaz`,
|
||||
);
|
||||
throw new ConflictException(`'${sub.status}' subscription için brand atama yapılamaz`);
|
||||
}
|
||||
|
||||
const [plan] = await this.db
|
||||
.select()
|
||||
.from(plans)
|
||||
.where(eq(plans.id, sub.planId))
|
||||
.limit(1);
|
||||
const [plan] = await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1);
|
||||
if (!plan) throw new NotFoundException("Plan bulunamadı");
|
||||
|
||||
// brandCount 0 = "all brands" (Full plan): don't accept a custom list;
|
||||
@@ -282,9 +401,7 @@ export class BillingService {
|
||||
.from(userBrands)
|
||||
.where(eq(userBrands.subscriptionId, input.subscriptionId));
|
||||
|
||||
await this.db
|
||||
.delete(userBrands)
|
||||
.where(eq(userBrands.subscriptionId, input.subscriptionId));
|
||||
await this.db.delete(userBrands).where(eq(userBrands.subscriptionId, input.subscriptionId));
|
||||
|
||||
if (input.brandIds.length > 0) {
|
||||
await this.db.insert(userBrands).values(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import type { Request, Response } from "express";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
import { InternalTokenGuard } from "../common/guards/internal-token.guard";
|
||||
import { ImpersonationService } from "./impersonation.service";
|
||||
@@ -66,9 +66,8 @@ export class ImpersonationController {
|
||||
ttlMinutes: body.ttlMinutes ?? 15,
|
||||
reason,
|
||||
ipAddress: this.getIp(req),
|
||||
userAgent: typeof req.headers["user-agent"] === "string"
|
||||
? req.headers["user-agent"]
|
||||
: undefined,
|
||||
userAgent:
|
||||
typeof req.headers["user-agent"] === "string" ? req.headers["user-agent"] : undefined,
|
||||
});
|
||||
|
||||
const baseUrl = this.configService.get<string>("auth.url") ?? "";
|
||||
@@ -149,8 +148,7 @@ export class ImpersonationController {
|
||||
|
||||
private consumeSecret(): string {
|
||||
const secret =
|
||||
process.env.INTERNAL_IMPERSONATION_SECRET ??
|
||||
this.configService.get<string>("auth.secret");
|
||||
process.env.INTERNAL_IMPERSONATION_SECRET ?? this.configService.get<string>("auth.secret");
|
||||
if (!secret) throw new Error("INTERNAL_IMPERSONATION_SECRET or auth.secret required");
|
||||
return secret;
|
||||
}
|
||||
|
||||
@@ -32,10 +32,7 @@ export class ImpersonationService {
|
||||
expiresAt: Date;
|
||||
sessionId: string;
|
||||
}> {
|
||||
const ttl = Math.min(
|
||||
Math.max(params.ttlMinutes || DEFAULT_TTL_MINUTES, 1),
|
||||
MAX_TTL_MINUTES,
|
||||
);
|
||||
const ttl = Math.min(Math.max(params.ttlMinutes || DEFAULT_TTL_MINUTES, 1), MAX_TTL_MINUTES);
|
||||
|
||||
// Verify target exists (and isn't an admin — defense in depth)
|
||||
const [target] = await this.db
|
||||
@@ -78,9 +75,7 @@ export class ImpersonationService {
|
||||
const cookieValue = encodeURIComponent(signedValue);
|
||||
|
||||
const isHttps = (this.configService.get<string>("auth.url") ?? "").startsWith("https://");
|
||||
const cookieName = isHttps
|
||||
? SESSION_COOKIE_NAME_SECURE
|
||||
: SESSION_COOKIE_NAME_INSECURE;
|
||||
const cookieName = isHttps ? SESSION_COOKIE_NAME_SECURE : SESSION_COOKIE_NAME_INSECURE;
|
||||
|
||||
this.logger.log(
|
||||
`Impersonation session created: founder=${params.founderId} target=${params.targetUserId} ttl=${ttl}m reason="${params.reason.slice(0, 80)}" sessionId=${sessionId.slice(0, 8)}…`,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { StripeModule } from "../payments/stripe/stripe.module";
|
||||
import { PublicApiQuotaService } from "../public-api/public-api-quota.service";
|
||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||
import { BillingController } from "./billing.controller";
|
||||
import { ApiKeysAdminController } from "./api-keys.controller";
|
||||
import { BillingController, UserBillingController } from "./billing.controller";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { ImpersonationController } from "./impersonation.controller";
|
||||
import { ImpersonationService } from "./impersonation.service";
|
||||
@@ -14,9 +16,11 @@ import { InternalVehiclesService } from "./vehicles.service";
|
||||
@Module({
|
||||
imports: [SubscriptionsModule, StripeModule],
|
||||
controllers: [
|
||||
ApiKeysAdminController,
|
||||
ImpersonationController,
|
||||
LifecycleController,
|
||||
BillingController,
|
||||
UserBillingController,
|
||||
PaymentsAdminController,
|
||||
InternalVehiclesController,
|
||||
],
|
||||
@@ -25,6 +29,9 @@ import { InternalVehiclesService } from "./vehicles.service";
|
||||
LifecycleService,
|
||||
BillingService,
|
||||
InternalVehiclesService,
|
||||
// Redis kota sayaçlarını /internal/admin/api-keys/usage için okur
|
||||
// (RedisModule @Global olduğundan bağımlılığı hazır).
|
||||
PublicApiQuotaService,
|
||||
],
|
||||
})
|
||||
export class InternalAdminModule {}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user