Compare commits
197 Commits
fix/backfi
...
feat/fiat-
| Author | SHA1 | Date | |
|---|---|---|---|
| de960a0dd4 | |||
| cee11b5769 | |||
| 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 |
@@ -30,6 +30,8 @@ ARG VITE_POSTHOG_KEY=
|
|||||||
ARG VITE_META_PIXEL_ID=
|
ARG VITE_META_PIXEL_ID=
|
||||||
ARG VITE_CHATWOOT_BASE_URL=
|
ARG VITE_CHATWOOT_BASE_URL=
|
||||||
ARG VITE_CHATWOOT_WEBSITE_TOKEN=
|
ARG VITE_CHATWOOT_WEBSITE_TOKEN=
|
||||||
|
ARG VITE_GOOGLE_ADS_ID=
|
||||||
|
ARG VITE_GOOGLE_ADS_SIGNUP_LABEL=
|
||||||
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
|
|||||||
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");
|
||||||
@@ -127,6 +127,104 @@
|
|||||||
"when": 1781395200000,
|
"when": 1781395200000,
|
||||||
"tag": "0017_oem_expert_rewards",
|
"tag": "0017_oem_expert_rewards",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -65,6 +65,7 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"stripe": "^22.1.1",
|
"stripe": "^22.1.1",
|
||||||
|
"tesseract.js": "^5.1.1",
|
||||||
"undici": "^7.22.0",
|
"undici": "^7.22.0",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,20 +26,24 @@ import { validate } from "./config/env.validation";
|
|||||||
import { ContactModule } from "./contact/contact.module";
|
import { ContactModule } from "./contact/contact.module";
|
||||||
import { DatabaseModule } from "./database/database.module";
|
import { DatabaseModule } from "./database/database.module";
|
||||||
import { DemoModule } from "./demo/demo.module";
|
import { DemoModule } from "./demo/demo.module";
|
||||||
|
import { DiscountCodesModule } from "./discount-codes/discount-codes.module";
|
||||||
import { EmailModule } from "./email/email.module";
|
import { EmailModule } from "./email/email.module";
|
||||||
import { HealthController } from "./health.controller";
|
import { HealthController } from "./health.controller";
|
||||||
import { EmexModule } from "./integrations/emex/emex.module";
|
import { EmexModule } from "./integrations/emex/emex.module";
|
||||||
import { PModule } from "./integrations/p/p.module";
|
import { PModule } from "./integrations/p/p.module";
|
||||||
|
import { VinpinModule } from "./integrations/vinpin/vinpin.module";
|
||||||
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
|
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
|
||||||
import { JobsModule } from "./jobs/jobs.module";
|
import { JobsModule } from "./jobs/jobs.module";
|
||||||
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
|
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
|
||||||
import { NotificationsModule } from "./notifications/notifications.module";
|
import { NotificationsModule } from "./notifications/notifications.module";
|
||||||
import { OemSuggestionsModule } from "./oem-suggestions/oem-suggestions.module";
|
import { OemSuggestionsModule } from "./oem-suggestions/oem-suggestions.module";
|
||||||
import { OemVotesModule } from "./oem-votes/oem-votes.module";
|
import { OemVotesModule } from "./oem-votes/oem-votes.module";
|
||||||
|
import { PartPricesModule } from "./part-prices/part-prices.module";
|
||||||
import { PartsModule } from "./parts/parts.module";
|
import { PartsModule } from "./parts/parts.module";
|
||||||
import { PaymentsModule } from "./payments/payments.module";
|
import { PaymentsModule } from "./payments/payments.module";
|
||||||
import { PlansModule } from "./plans/plans.module";
|
import { PlansModule } from "./plans/plans.module";
|
||||||
import { PostHogModule } from "./posthog/posthog.module";
|
import { PostHogModule } from "./posthog/posthog.module";
|
||||||
|
import { PublicApiModule } from "./public-api/public-api.module";
|
||||||
import { RedisModule } from "./redis/redis.module";
|
import { RedisModule } from "./redis/redis.module";
|
||||||
import { ReferralsModule } from "./referrals/referrals.module";
|
import { ReferralsModule } from "./referrals/referrals.module";
|
||||||
import { StorageModule } from "./storage/storage.module";
|
import { StorageModule } from "./storage/storage.module";
|
||||||
@@ -84,14 +88,17 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
|||||||
PlansModule,
|
PlansModule,
|
||||||
SubscriptionsModule,
|
SubscriptionsModule,
|
||||||
PaymentsModule,
|
PaymentsModule,
|
||||||
|
DiscountCodesModule,
|
||||||
ReferralsModule,
|
ReferralsModule,
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
CategoriesModule,
|
CategoriesModule,
|
||||||
DemoModule,
|
DemoModule,
|
||||||
PartsModule,
|
PartsModule,
|
||||||
PModule,
|
PModule,
|
||||||
|
PartPricesModule,
|
||||||
JobsModule,
|
JobsModule,
|
||||||
EmexModule,
|
EmexModule,
|
||||||
|
VinpinModule,
|
||||||
TranslationsModule,
|
TranslationsModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
AnalyticsModule,
|
AnalyticsModule,
|
||||||
@@ -106,6 +113,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
|||||||
PostHogModule,
|
PostHogModule,
|
||||||
TelemetryModule,
|
TelemetryModule,
|
||||||
InternalAdminModule,
|
InternalAdminModule,
|
||||||
|
PublicApiModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { generateReferralCode, normalizeName } from "@sase/shared";
|
import { generateReferralCode, normalizeName } from "@sase/shared";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { captcha } from "better-auth/plugins";
|
import { apiKey, captcha } from "better-auth/plugins";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
@@ -224,6 +224,21 @@ export function createAuth(
|
|||||||
"http://localhost:4000",
|
"http://localhost:4000",
|
||||||
],
|
],
|
||||||
plugins: [
|
plugins: [
|
||||||
|
// Public API anahtarları (api.sase.tr / /api/v1). Doğrulama ApiKeyGuard
|
||||||
|
// üzerinden auth.api.verifyApiKey ile yapılır; enableSessionForAPIKeys
|
||||||
|
// bilinçli olarak KAPALI — API anahtarı dashboard oturumu yerine geçmez,
|
||||||
|
// cookie tabanlı AuthGuard'ın davranışı değişmez.
|
||||||
|
apiKey({
|
||||||
|
defaultPrefix: "sase_",
|
||||||
|
enableMetadata: true,
|
||||||
|
// Anahtar başına istek tavanı (verify başına sayaç). Günlük decode
|
||||||
|
// kotasından ayrıdır — o, PublicApiQuotaService'te Redis ile tutulur.
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
timeWindow: 60_000, // 1 dakika
|
||||||
|
maxRequests: 120,
|
||||||
|
},
|
||||||
|
}),
|
||||||
// Cloudflare Turnstile: yalnızca secret tanımlıysa aktif. sign-in/sign-up
|
// Cloudflare Turnstile: yalnızca secret tanımlıysa aktif. sign-in/sign-up
|
||||||
// uçları "x-captcha-response" header'ındaki token ile doğrulanır.
|
// uçları "x-captcha-response" header'ındaki token ile doğrulanır.
|
||||||
...(process.env.TURNSTILE_SECRET_KEY
|
...(process.env.TURNSTILE_SECRET_KEY
|
||||||
@@ -247,3 +262,42 @@ export function getAuth() {
|
|||||||
}
|
}
|
||||||
return authInstance;
|
return authInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** apiKey plugin'inin döndürdüğü anahtar kaydı (verify/create cevapları). */
|
||||||
|
export interface ApiKeyRecord {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string | null;
|
||||||
|
start: string | null;
|
||||||
|
prefix: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* apiKey plugin uçları. `authInstance` geniş `ReturnType<typeof betterAuth>`
|
||||||
|
* ile tiplendiği için plugin uçları tipte görünmüyor (runtime'da mevcutlar);
|
||||||
|
* bu erişimci onları dar ve doğru imzalarla expose eder.
|
||||||
|
*/
|
||||||
|
export function getApiKeyApi() {
|
||||||
|
return getAuth().api as unknown as {
|
||||||
|
verifyApiKey: (input: { body: { key: string } }) => Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
error: { message?: string; code: string } | null;
|
||||||
|
key: ApiKeyRecord | null;
|
||||||
|
}>;
|
||||||
|
createApiKey: (input: {
|
||||||
|
body: {
|
||||||
|
userId: string;
|
||||||
|
name?: string;
|
||||||
|
prefix?: string;
|
||||||
|
expiresIn?: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
rateLimitEnabled?: boolean;
|
||||||
|
rateLimitTimeWindow?: number;
|
||||||
|
rateLimitMax?: number;
|
||||||
|
};
|
||||||
|
}) => Promise<ApiKeyRecord & { key: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,10 +52,7 @@ export class BlogController {
|
|||||||
// Automation endpoint — published by the Süper Panel content pipeline via n8n.
|
// Automation endpoint — published by the Süper Panel content pipeline via n8n.
|
||||||
@Post("posts/internal")
|
@Post("posts/internal")
|
||||||
@Public()
|
@Public()
|
||||||
async createInternal(
|
async createInternal(@Headers("authorization") authHeader: string, @Body() body: CreateBlogPost) {
|
||||||
@Headers("authorization") authHeader: string,
|
|
||||||
@Body() body: CreateBlogPost,
|
|
||||||
) {
|
|
||||||
assertAutomationToken(authHeader);
|
assertAutomationToken(authHeader);
|
||||||
if (!body?.title || !body?.body_markdown) {
|
if (!body?.title || !body?.body_markdown) {
|
||||||
throw new UnauthorizedException("title and body_markdown are required");
|
throw new UnauthorizedException("title and body_markdown are required");
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { Injectable, Logger, NotFoundException, ServiceUnavailableException } from "@nestjs/common";
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { RedisService } from "../redis/redis.service";
|
import { RedisService } from "../redis/redis.service";
|
||||||
import type { BlogPost, BlogPostListItem, CreateBlogPost } from "./blog.dto";
|
import type { BlogPost, BlogPostListItem, CreateBlogPost } from "./blog.dto";
|
||||||
|
|
||||||
@@ -56,10 +51,21 @@ function resolveCover(row: Pick<DirectusPost, "cover_image" | "cover_file">): st
|
|||||||
// slug, but we slugify defensively (and to derive one if it's missing).
|
// slug, but we slugify defensively (and to derive one if it's missing).
|
||||||
function slugify(input: string): string {
|
function slugify(input: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
ç: "c", ğ: "g", ı: "i", İ: "i", ö: "o", ş: "s", ü: "u",
|
ç: "c",
|
||||||
Ç: "c", Ğ: "g", Ö: "o", Ş: "s", Ü: "u",
|
ğ: "g",
|
||||||
|
ı: "i",
|
||||||
|
İ: "i",
|
||||||
|
ö: "o",
|
||||||
|
ş: "s",
|
||||||
|
ü: "u",
|
||||||
|
Ç: "c",
|
||||||
|
Ğ: "g",
|
||||||
|
Ö: "o",
|
||||||
|
Ş: "s",
|
||||||
|
Ü: "u",
|
||||||
};
|
};
|
||||||
return input
|
return (
|
||||||
|
input
|
||||||
.split("")
|
.split("")
|
||||||
.map((c) => map[c] ?? c)
|
.map((c) => map[c] ?? c)
|
||||||
.join("")
|
.join("")
|
||||||
@@ -68,7 +74,8 @@ function slugify(input: string): string {
|
|||||||
.replace(/[̀-ͯ]/g, "")
|
.replace(/[̀-ͯ]/g, "")
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
.replace(/^-+|-+$/g, "")
|
.replace(/^-+|-+$/g, "")
|
||||||
.slice(0, 160) || "yazi";
|
.slice(0, 160) || "yazi"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -95,7 +102,9 @@ export class BlogService {
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await res.text().catch(() => "");
|
const detail = await res.text().catch(() => "");
|
||||||
throw new Error(`Directus ${init.method ?? "GET"} ${path} -> ${res.status}: ${detail.slice(0, 300)}`);
|
throw new Error(
|
||||||
|
`Directus ${init.method ?? "GET"} ${path} -> ${res.status}: ${detail.slice(0, 300)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const json = (await res.json()) as { data: T };
|
const json = (await res.json()) as { data: T };
|
||||||
return json.data;
|
return json.data;
|
||||||
@@ -136,7 +145,8 @@ export class BlogService {
|
|||||||
"filter[project][_eq]": DIRECTUS_PROJECT,
|
"filter[project][_eq]": DIRECTUS_PROJECT,
|
||||||
"filter[status][_eq]": "published",
|
"filter[status][_eq]": "published",
|
||||||
sort: "-published_at",
|
sort: "-published_at",
|
||||||
fields: "slug,title,meta_description,tags,cover_image,cover_file.filename_disk,published_at",
|
fields:
|
||||||
|
"slug,title,meta_description,tags,cover_image,cover_file.filename_disk,published_at",
|
||||||
limit: "-1",
|
limit: "-1",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -81,6 +81,19 @@ export class CatalogController {
|
|||||||
return this.catalogService.getCategoryTree(id, user.id, body, engine, gearbox, mainGroupsPath);
|
return this.catalogService.getCategoryTree(id, user.id, body, engine, gearbox, mainGroupsPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unified canonical browse for a catalog vehicle (pl24 flow).
|
||||||
|
@Get("vehicles/:id/canonical")
|
||||||
|
getCanonicalTree(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Query("body") body: string | undefined,
|
||||||
|
@Query("engine") engine: string | undefined,
|
||||||
|
@Query("gearbox") gearbox: string | undefined,
|
||||||
|
@Query("mgp") mainGroupsPath: string | undefined,
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
) {
|
||||||
|
return this.catalogService.getCanonicalTree(id, user.id, body, engine, gearbox, mainGroupsPath);
|
||||||
|
}
|
||||||
|
|
||||||
@Get("vehicles/:id/categories/:categoryId")
|
@Get("vehicles/:id/categories/:categoryId")
|
||||||
getCategoryWithParts(
|
getCategoryWithParts(
|
||||||
@Param("id") id: string,
|
@Param("id") id: string,
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { and, eq, inArray, or, sql } from "drizzle-orm";
|
import { and, eq, gte, inArray, isNotNull, isNull, or, sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
buildCanonicalInput,
|
||||||
|
groupCategoriesByCanonical,
|
||||||
|
overlayTemplate,
|
||||||
|
} from "../categories/canonical-grouping";
|
||||||
import { DATABASE, type Database } from "../database/database.provider";
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
import {
|
import {
|
||||||
brands,
|
brands,
|
||||||
|
canonicalCategories,
|
||||||
|
canonicalStructureTemplate,
|
||||||
|
canonicalTemplateEntries,
|
||||||
catalogVehicles,
|
catalogVehicles,
|
||||||
categories,
|
categories,
|
||||||
parts,
|
parts,
|
||||||
@@ -349,6 +357,130 @@ export class CatalogService {
|
|||||||
/**
|
/**
|
||||||
* Get category tree for a catalog vehicle (on-demand fetch from PL24).
|
* Get category tree for a catalog vehicle (on-demand fetch from PL24).
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Unified canonical browse for a catalog vehicle (pl24 flow) — the
|
||||||
|
* catalog_vehicle_id counterpart of CategoriesService.getCanonicalTree.
|
||||||
|
* Folds the vehicle's part-holding leaf categories into the 20 canonical
|
||||||
|
* buckets so pl24 brands (VW, Opel, Renault…) navigate through the same
|
||||||
|
* headings as the emex/pcat vehicle_id flow. Part counts are joined by
|
||||||
|
* category_id (pl24 parts often carry vehicle_id, not catalog_vehicle_id).
|
||||||
|
*/
|
||||||
|
async getCanonicalTree(
|
||||||
|
catalogVehicleId: string,
|
||||||
|
userId: string,
|
||||||
|
body = "_all_",
|
||||||
|
engine = "_all_",
|
||||||
|
gearbox = "_all_",
|
||||||
|
mainGroupsPath?: string,
|
||||||
|
) {
|
||||||
|
// Populate the raw source tree (lazy fetch + cache) and enforce brand access.
|
||||||
|
await this.getCategoryTree(catalogVehicleId, userId, body, engine, gearbox, mainGroupsPath);
|
||||||
|
|
||||||
|
const buckets = await this.db
|
||||||
|
.select({
|
||||||
|
id: canonicalCategories.id,
|
||||||
|
slug: canonicalCategories.slug,
|
||||||
|
name: canonicalCategories.name,
|
||||||
|
})
|
||||||
|
.from(canonicalCategories)
|
||||||
|
.where(isNull(canonicalCategories.parentId))
|
||||||
|
.orderBy(canonicalCategories.sortOrder);
|
||||||
|
const subLeaves = await this.db
|
||||||
|
.select({
|
||||||
|
id: canonicalCategories.id,
|
||||||
|
parentId: canonicalCategories.parentId,
|
||||||
|
slug: canonicalCategories.slug,
|
||||||
|
name: canonicalCategories.name,
|
||||||
|
})
|
||||||
|
.from(canonicalCategories)
|
||||||
|
.where(isNotNull(canonicalCategories.parentId))
|
||||||
|
.orderBy(canonicalCategories.sortOrder);
|
||||||
|
|
||||||
|
const cats = await this.db
|
||||||
|
.select({
|
||||||
|
id: categories.id,
|
||||||
|
name: categories.name,
|
||||||
|
source: categories.source,
|
||||||
|
canonicalId: categories.canonicalCategoryId,
|
||||||
|
subId: categories.canonicalSubcategoryId,
|
||||||
|
parentId: categories.parentId,
|
||||||
|
})
|
||||||
|
.from(categories)
|
||||||
|
.where(eq(categories.catalogVehicleId, catalogVehicleId));
|
||||||
|
|
||||||
|
const partCounts = new Map<string, number>();
|
||||||
|
const catIds = cats.map((c) => c.id);
|
||||||
|
if (catIds.length > 0) {
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ categoryId: parts.categoryId, count: sql<number>`count(*)::int` })
|
||||||
|
.from(parts)
|
||||||
|
.where(inArray(parts.categoryId, catIds))
|
||||||
|
.groupBy(parts.categoryId);
|
||||||
|
for (const r of rows) partCounts.set(r.categoryId, r.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show each bucket's natural sub-category level (see buildCanonicalInput).
|
||||||
|
const {
|
||||||
|
input: shown,
|
||||||
|
counts: shownCounts,
|
||||||
|
hasChildren,
|
||||||
|
} = buildCanonicalInput(cats, partCounts, buckets);
|
||||||
|
const groups = groupCategoriesByCanonical(shown, shownCounts, buckets, {
|
||||||
|
requireParts: false,
|
||||||
|
subLeaves,
|
||||||
|
hasChildren,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Overlay the per-brand structure template (see CategoriesService.getCanonicalTree).
|
||||||
|
const cv = await this.db
|
||||||
|
.select({ brand: catalogVehicles.brandName, source: catalogVehicles.source })
|
||||||
|
.from(catalogVehicles)
|
||||||
|
.where(eq(catalogVehicles.id, catalogVehicleId))
|
||||||
|
.limit(1);
|
||||||
|
const brand = cv[0]?.brand;
|
||||||
|
const source = cats[0]?.source ?? cv[0]?.source;
|
||||||
|
if (!brand || !source) return groups;
|
||||||
|
const template = await this.db
|
||||||
|
.select({
|
||||||
|
bucketId: canonicalStructureTemplate.canonicalCategoryId,
|
||||||
|
subId: canonicalStructureTemplate.canonicalSubcategoryId,
|
||||||
|
})
|
||||||
|
.from(canonicalStructureTemplate)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(canonicalStructureTemplate.source, source),
|
||||||
|
eq(canonicalStructureTemplate.brand, brand),
|
||||||
|
gte(canonicalStructureTemplate.vehicleCount, 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const templateEntriesRaw = await this.db
|
||||||
|
.select({
|
||||||
|
bucketId: canonicalTemplateEntries.canonicalCategoryId,
|
||||||
|
subId: canonicalTemplateEntries.canonicalSubcategoryId,
|
||||||
|
name: canonicalTemplateEntries.name,
|
||||||
|
vehicleCount: canonicalTemplateEntries.vehicleCount,
|
||||||
|
})
|
||||||
|
.from(canonicalTemplateEntries)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(canonicalTemplateEntries.source, source),
|
||||||
|
eq(canonicalTemplateEntries.brand, brand),
|
||||||
|
gte(canonicalTemplateEntries.vehicleCount, 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Cap placeholders at the MOST-SHARED 40 per bucket — some brands carry
|
||||||
|
// hundreds of model-variant names (pl24 BMW elektrik: 500+) that would
|
||||||
|
// drown the view; the widely-shared ones are the real "brand structure".
|
||||||
|
templateEntriesRaw.sort((a, z) => z.vehicleCount - a.vehicleCount);
|
||||||
|
const perBucket = new Map<string, number>();
|
||||||
|
const templateEntries = templateEntriesRaw.filter((e) => {
|
||||||
|
const n = (perBucket.get(e.bucketId) ?? 0) + 1;
|
||||||
|
perBucket.set(e.bucketId, n);
|
||||||
|
return n <= 40;
|
||||||
|
});
|
||||||
|
return overlayTemplate(groups, template, buckets, subLeaves, templateEntries);
|
||||||
|
}
|
||||||
|
|
||||||
async getCategoryTree(
|
async getCategoryTree(
|
||||||
catalogVehicleId: string,
|
catalogVehicleId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
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,19 @@ import { CategoriesService } from "./categories.service";
|
|||||||
export class CategoriesController {
|
export class CategoriesController {
|
||||||
constructor(private categoriesService: CategoriesService) {}
|
constructor(private categoriesService: CategoriesService) {}
|
||||||
|
|
||||||
|
// Sources this vehicle has (default/eper first) — drives the source switcher.
|
||||||
|
// Declared before "tree/:vehicleId" so the literal segment wins the match.
|
||||||
|
@Get("tree/:vehicleId/sources")
|
||||||
|
async getCatalogSources(@Param("vehicleId") vehicleId: string) {
|
||||||
|
return this.categoriesService.getCatalogSources(vehicleId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get("tree/:vehicleId")
|
@Get("tree/:vehicleId")
|
||||||
async getCategoryTree(@Param("vehicleId") vehicleId: string) {
|
async getCategoryTree(
|
||||||
return this.categoriesService.getCategoryTree(vehicleId);
|
@Param("vehicleId") vehicleId: string,
|
||||||
|
@Query("source") source?: string,
|
||||||
|
) {
|
||||||
|
return this.categoriesService.getCategoryTree(vehicleId, source);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("search/:vehicleId")
|
@Get("search/:vehicleId")
|
||||||
@@ -15,6 +25,27 @@ export class CategoriesController {
|
|||||||
return this.categoriesService.searchCatalog(vehicleId, q ?? "");
|
return this.categoriesService.searchCatalog(vehicleId, q ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The 20-bucket unified taxonomy (browse landing / filter chips). Declared
|
||||||
|
// before ":id" so the literal path isn't captured by the id param route.
|
||||||
|
@Get("canonical")
|
||||||
|
async getCanonicalCategories() {
|
||||||
|
return this.categoriesService.getCanonicalCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full 2-level canonical taxonomy (buckets + sub-leaves + population counts) —
|
||||||
|
// the source-independent structure exposed to B2C API consumers. Declared
|
||||||
|
// before "canonical/:vehicleId" so the literal isn't captured as a vehicleId.
|
||||||
|
@Get("canonical/taxonomy")
|
||||||
|
async getCanonicalTaxonomy() {
|
||||||
|
return this.categoriesService.getCanonicalTaxonomy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A vehicle's catalog folded into the canonical buckets (unified browse).
|
||||||
|
@Get("canonical/:vehicleId")
|
||||||
|
async getCanonicalTree(@Param("vehicleId") vehicleId: string) {
|
||||||
|
return this.categoriesService.getCanonicalTree(vehicleId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(":id/children")
|
@Get(":id/children")
|
||||||
async getChildren(@Param("id") id: string) {
|
async getChildren(@Param("id") id: string) {
|
||||||
return this.categoriesService.getChildren(id);
|
return this.categoriesService.getChildren(id);
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { and, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
import { and, eq, gte, ilike, inArray, isNotNull, isNull, or, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
type CatalogDegradationKind,
|
type CatalogDegradationKind,
|
||||||
reportCatalogDegradation,
|
reportCatalogDegradation,
|
||||||
} from "../common/catalog-degradation";
|
} from "../common/catalog-degradation";
|
||||||
import { DATABASE, type Database } from "../database/database.provider";
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
|
import {
|
||||||
|
canonicalCategories,
|
||||||
|
canonicalStructureTemplate,
|
||||||
|
canonicalTemplateEntries,
|
||||||
|
categories,
|
||||||
|
parts,
|
||||||
|
schemaPics,
|
||||||
|
vehicles,
|
||||||
|
} from "../database/schema/core";
|
||||||
import { EmexSourceDbService } from "../integrations/catalog-source-db/emex-source-db.service";
|
import { EmexSourceDbService } from "../integrations/catalog-source-db/emex-source-db.service";
|
||||||
import { PcatSourceDbService } from "../integrations/catalog-source-db/pcat-source-db.service";
|
import { PcatSourceDbService } from "../integrations/catalog-source-db/pcat-source-db.service";
|
||||||
import { EmexService } from "../integrations/emex/emex.service";
|
import { EmexService } from "../integrations/emex/emex.service";
|
||||||
@@ -14,9 +22,15 @@ import { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
|||||||
import { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
|
import { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
|
||||||
import { PL24PsaService } from "../integrations/pl24/pl24-psa.service";
|
import { PL24PsaService } from "../integrations/pl24/pl24-psa.service";
|
||||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||||
|
import { classifyNode, foldName, mapToCanonical } from "../jobs/canonical-lexicon";
|
||||||
import { RedisService } from "../redis/redis.service";
|
import { RedisService } from "../redis/redis.service";
|
||||||
import { StorageService } from "../storage/storage.service";
|
import { StorageService } from "../storage/storage.service";
|
||||||
import { TranslationsService } from "../translations/translations.service";
|
import { TranslationsService } from "../translations/translations.service";
|
||||||
|
import {
|
||||||
|
buildCanonicalInput,
|
||||||
|
groupCategoriesByCanonical,
|
||||||
|
overlayTemplate,
|
||||||
|
} from "./canonical-grouping";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CategoriesService {
|
export class CategoriesService {
|
||||||
@@ -36,8 +50,8 @@ export class CategoriesService {
|
|||||||
private emexSourceDb: EmexSourceDbService,
|
private emexSourceDb: EmexSourceDbService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getCategoryTree(vehicleId: string) {
|
async getCategoryTree(vehicleId: string, sourceFilter?: string) {
|
||||||
const cacheKey = `cat:tree:${vehicleId}`;
|
const cacheKey = `cat:tree:${vehicleId}${sourceFilter ? `:${sourceFilter}` : ""}`;
|
||||||
const cached = await this.redis.getJson<any[]>(cacheKey);
|
const cached = await this.redis.getJson<any[]>(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
@@ -443,8 +457,23 @@ export class CategoriesService {
|
|||||||
partCountRows.map((r) => [r.categoryId, r.count]),
|
partCountRows.map((r) => [r.categoryId, r.count]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Default catalog source for a multi-source vehicle: prefer eper-pekidi — the
|
||||||
|
// native OEM ePER tree Fiat dealers already know, fully populated with parts +
|
||||||
|
// diagrams — over parts-catalogs' broader-but-lazy (drill-on-demand) folders,
|
||||||
|
// which is where dealers hit gaps (a motor OEM missing under "Motor"). pcat is
|
||||||
|
// never dropped: the ?source override + /sources endpoint keep it one tap away.
|
||||||
|
const presentSources = [...new Set(dbCategories.map((c) => c.source))];
|
||||||
|
const activeSource =
|
||||||
|
sourceFilter && presentSources.includes(sourceFilter)
|
||||||
|
? sourceFilter
|
||||||
|
: presentSources.includes("eper-pekidi")
|
||||||
|
? "eper-pekidi"
|
||||||
|
: null;
|
||||||
|
const forTree = activeSource
|
||||||
|
? dbCategories.filter((c) => c.source === activeSource)
|
||||||
|
: dbCategories;
|
||||||
// Build tree
|
// Build tree
|
||||||
const tree = this.buildTree(dbCategories, directPartCounts);
|
const tree = this.buildTree(forTree, directPartCounts);
|
||||||
// Cache a populated tree for an hour; an EMPTY tree (transient decode/proxy
|
// Cache a populated tree for an hour; an EMPTY tree (transient decode/proxy
|
||||||
// failure) only for 60s so a blip doesn't poison the catalog for an hour —
|
// failure) only for 60s so a blip doesn't poison the catalog for an hour —
|
||||||
// it self-heals on the next request after the source recovers, while still
|
// it self-heals on the next request after the source recovers, while still
|
||||||
@@ -466,6 +495,210 @@ export class CategoriesService {
|
|||||||
return tree;
|
return tree;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalog sources a decoded vehicle has categories from, ordered with the
|
||||||
|
* default first (eper-pekidi — the dealer-familiar native ePER catalog). Drives
|
||||||
|
* the per-vehicle source switcher (ePER ⇄ Diğer); a single source → no switcher.
|
||||||
|
*/
|
||||||
|
async getCatalogSources(vehicleId: string): Promise<string[]> {
|
||||||
|
const rows = await this.db
|
||||||
|
.selectDistinct({ source: categories.source })
|
||||||
|
.from(categories)
|
||||||
|
.where(eq(categories.vehicleId, vehicleId));
|
||||||
|
const present = rows.map((r) => r.source);
|
||||||
|
const order = ["eper-pekidi", "parts-catalogs", "emex", "pl24"];
|
||||||
|
return present.sort((a, b) => {
|
||||||
|
const ia = order.indexOf(a);
|
||||||
|
const ib = order.indexOf(b);
|
||||||
|
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unified canonical taxonomy (20 top buckets), source/brand-independent.
|
||||||
|
* Used for the browse landing + as the fixed set of headings every vehicle's
|
||||||
|
* catalog folds into.
|
||||||
|
*/
|
||||||
|
async getCanonicalCategories() {
|
||||||
|
return this.db
|
||||||
|
.select({
|
||||||
|
id: canonicalCategories.id,
|
||||||
|
slug: canonicalCategories.slug,
|
||||||
|
name: canonicalCategories.name,
|
||||||
|
})
|
||||||
|
.from(canonicalCategories)
|
||||||
|
.where(isNull(canonicalCategories.parentId))
|
||||||
|
.orderBy(canonicalCategories.sortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full 2-level canonical taxonomy — the source-independent category structure
|
||||||
|
* a B2C API consumer browses by: 20 top buckets, each with its curated
|
||||||
|
* sub-leaves (Fren → Balata/Disk/Kaliper…). `mappedCategories` shows how many
|
||||||
|
* source categories currently fall in each node (population signal); sub-leaves
|
||||||
|
* with 0 are still listed so the contract is stable. Sub-mapping is completed
|
||||||
|
* over time in the super-panel, so counts only grow.
|
||||||
|
*/
|
||||||
|
async getCanonicalTaxonomy() {
|
||||||
|
const nodes = await this.db
|
||||||
|
.select({
|
||||||
|
id: canonicalCategories.id,
|
||||||
|
parentId: canonicalCategories.parentId,
|
||||||
|
slug: canonicalCategories.slug,
|
||||||
|
name: canonicalCategories.name,
|
||||||
|
sortOrder: canonicalCategories.sortOrder,
|
||||||
|
})
|
||||||
|
.from(canonicalCategories)
|
||||||
|
.orderBy(canonicalCategories.sortOrder);
|
||||||
|
|
||||||
|
// population per node: top buckets count canonical_category_id, sub-leaves
|
||||||
|
// count canonical_subcategory_id. One grouped scan each (both columns indexed).
|
||||||
|
const topCounts = await this.db
|
||||||
|
.select({ id: categories.canonicalCategoryId, n: sql<number>`count(*)::int` })
|
||||||
|
.from(categories)
|
||||||
|
.where(isNotNull(categories.canonicalCategoryId))
|
||||||
|
.groupBy(categories.canonicalCategoryId);
|
||||||
|
const subCounts = await this.db
|
||||||
|
.select({ id: categories.canonicalSubcategoryId, n: sql<number>`count(*)::int` })
|
||||||
|
.from(categories)
|
||||||
|
.where(isNotNull(categories.canonicalSubcategoryId))
|
||||||
|
.groupBy(categories.canonicalSubcategoryId);
|
||||||
|
const countById = new Map<string, number>();
|
||||||
|
for (const r of [...topCounts, ...subCounts]) if (r.id) countById.set(r.id, r.n);
|
||||||
|
|
||||||
|
const subsByParent = new Map<string, typeof nodes>();
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (!n.parentId) continue;
|
||||||
|
const arr = subsByParent.get(n.parentId) ?? [];
|
||||||
|
arr.push(n);
|
||||||
|
subsByParent.set(n.parentId, arr);
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
.filter((n) => !n.parentId)
|
||||||
|
.map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
slug: b.slug,
|
||||||
|
name: b.name,
|
||||||
|
mappedCategories: countById.get(b.id) ?? 0,
|
||||||
|
subcategories: (subsByParent.get(b.id) ?? []).map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
slug: s.slug,
|
||||||
|
name: s.name,
|
||||||
|
mappedCategories: countById.get(s.id) ?? 0,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified browse: a vehicle's part-holding leaf categories grouped under the
|
||||||
|
* 20 canonical buckets, so VW and Opel (whose raw OEM trees differ wildly) are
|
||||||
|
* navigated through one identical set of headings. Each entry lists the source
|
||||||
|
* leaf categories that fall in that bucket for this vehicle, with part counts;
|
||||||
|
* the UI drills into a leaf via the existing /categories/:id endpoint.
|
||||||
|
*
|
||||||
|
* Bucketing uses the materialized categories.canonical_category_id when set,
|
||||||
|
* falling back to on-the-fly mapping (shared canonical-lexicon) for categories
|
||||||
|
* decoded since the last backfill. Categories with no bucket land under "Diğer"
|
||||||
|
* so nothing is hidden.
|
||||||
|
*/
|
||||||
|
async getCanonicalTree(vehicleId: string) {
|
||||||
|
// Ensure the vehicle's raw source tree is populated (lazy decode + cache).
|
||||||
|
await this.getCategoryTree(vehicleId);
|
||||||
|
|
||||||
|
const buckets = await this.getCanonicalCategories();
|
||||||
|
const subLeaves = await this.db
|
||||||
|
.select({
|
||||||
|
id: canonicalCategories.id,
|
||||||
|
parentId: canonicalCategories.parentId,
|
||||||
|
slug: canonicalCategories.slug,
|
||||||
|
name: canonicalCategories.name,
|
||||||
|
})
|
||||||
|
.from(canonicalCategories)
|
||||||
|
.where(isNotNull(canonicalCategories.parentId))
|
||||||
|
.orderBy(canonicalCategories.sortOrder);
|
||||||
|
|
||||||
|
const cats = await this.db
|
||||||
|
.select({
|
||||||
|
id: categories.id,
|
||||||
|
name: categories.name,
|
||||||
|
source: categories.source,
|
||||||
|
canonicalId: categories.canonicalCategoryId,
|
||||||
|
subId: categories.canonicalSubcategoryId,
|
||||||
|
parentId: categories.parentId,
|
||||||
|
})
|
||||||
|
.from(categories)
|
||||||
|
.where(eq(categories.vehicleId, vehicleId));
|
||||||
|
|
||||||
|
const partCountRows = await this.db
|
||||||
|
.select({ categoryId: parts.categoryId, count: sql<number>`count(*)::int` })
|
||||||
|
.from(parts)
|
||||||
|
.where(eq(parts.vehicleId, vehicleId))
|
||||||
|
.groupBy(parts.categoryId);
|
||||||
|
const partCounts = new Map(partCountRows.map((r) => [r.categoryId, r.count]));
|
||||||
|
|
||||||
|
const {
|
||||||
|
input: shown,
|
||||||
|
counts: shownCounts,
|
||||||
|
hasChildren,
|
||||||
|
} = buildCanonicalInput(cats, partCounts, buckets);
|
||||||
|
const groups = groupCategoriesByCanonical(shown, shownCounts, buckets, {
|
||||||
|
requireParts: false,
|
||||||
|
subLeaves,
|
||||||
|
hasChildren,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Overlay the per-brand structure template: buckets / sub-leaves this brand
|
||||||
|
// is known to have but this vehicle hasn't deep-drilled yet appear as empty
|
||||||
|
// entries, so the FULL canonical structure shows immediately after decode.
|
||||||
|
const veh = await this.db
|
||||||
|
.select({ brand: vehicles.brandName, source: vehicles.source })
|
||||||
|
.from(vehicles)
|
||||||
|
.where(eq(vehicles.id, vehicleId))
|
||||||
|
.limit(1);
|
||||||
|
const brand = veh[0]?.brand;
|
||||||
|
const source = cats[0]?.source ?? veh[0]?.source;
|
||||||
|
if (!brand || !source) return groups;
|
||||||
|
const template = await this.db
|
||||||
|
.select({
|
||||||
|
bucketId: canonicalStructureTemplate.canonicalCategoryId,
|
||||||
|
subId: canonicalStructureTemplate.canonicalSubcategoryId,
|
||||||
|
})
|
||||||
|
.from(canonicalStructureTemplate)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(canonicalStructureTemplate.source, source),
|
||||||
|
eq(canonicalStructureTemplate.brand, brand),
|
||||||
|
gte(canonicalStructureTemplate.vehicleCount, 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const templateEntriesRaw = await this.db
|
||||||
|
.select({
|
||||||
|
bucketId: canonicalTemplateEntries.canonicalCategoryId,
|
||||||
|
subId: canonicalTemplateEntries.canonicalSubcategoryId,
|
||||||
|
name: canonicalTemplateEntries.name,
|
||||||
|
vehicleCount: canonicalTemplateEntries.vehicleCount,
|
||||||
|
})
|
||||||
|
.from(canonicalTemplateEntries)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(canonicalTemplateEntries.source, source),
|
||||||
|
eq(canonicalTemplateEntries.brand, brand),
|
||||||
|
gte(canonicalTemplateEntries.vehicleCount, 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Cap placeholders at the MOST-SHARED 40 per bucket — some brands carry
|
||||||
|
// hundreds of model-variant names (pl24 BMW elektrik: 500+) that would
|
||||||
|
// drown the view; the widely-shared ones are the real "brand structure".
|
||||||
|
templateEntriesRaw.sort((a, z) => z.vehicleCount - a.vehicleCount);
|
||||||
|
const perBucket = new Map<string, number>();
|
||||||
|
const templateEntries = templateEntriesRaw.filter((e) => {
|
||||||
|
const n = (perBucket.get(e.bucketId) ?? 0) + 1;
|
||||||
|
perBucket.set(e.bucketId, n);
|
||||||
|
return n <= 40;
|
||||||
|
});
|
||||||
|
return overlayTemplate(groups, template, buckets, subLeaves, templateEntries);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get sub-categories (sub-groups) for a category.
|
* Get sub-categories (sub-groups) for a category.
|
||||||
* Fetches from PL24 on-demand if not cached.
|
* Fetches from PL24 on-demand if not cached.
|
||||||
|
|||||||
96
apps/api/src/common/guards/api-key.guard.ts
Normal file
96
apps/api/src/common/guards/api-key.guard.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import {
|
||||||
|
type CanActivate,
|
||||||
|
type ExecutionContext,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { type ApiKeyRecord, getApiKeyApi } from "../../auth/auth";
|
||||||
|
import { DATABASE, type Database } from "../../database/database.provider";
|
||||||
|
import { users } from "../../database/schema/core";
|
||||||
|
|
||||||
|
export type VerifiedApiKey = ApiKeyRecord;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public API (/api/v1) kimlik doğrulaması. Cookie tabanlı AuthGuard'dan
|
||||||
|
* tamamen ayrı: anahtar `Authorization: Bearer sase_...` veya `x-api-key`
|
||||||
|
* header'ında gelir, better-auth apiKey plugin'i ile doğrulanır (hash
|
||||||
|
* karşılaştırma + enabled/expiry/anahtar-başına rate-limit). Geçerliyse
|
||||||
|
* anahtarın sahibi kullanıcı yüklenir ve `request.user` + `request.apiKey`
|
||||||
|
* doldurulur — böylece mevcut servisler (decodeVin'in abonelik/marka
|
||||||
|
* yetki kontrolü dahil) hiçbir değişiklik olmadan çalışır.
|
||||||
|
*
|
||||||
|
* Controller'lar bu guard'ı `@Public()` ile birlikte kullanmalı; @Public
|
||||||
|
* global AuthGuard'ı atlatır, kimlik işini bu guard devralır.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ApiKeyGuard implements CanActivate {
|
||||||
|
constructor(@Inject(DATABASE) private readonly db: Database) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const rawKey = this.extractKey(request.headers);
|
||||||
|
if (!rawKey) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
"API anahtarı gerekli. Anahtarınızı 'Authorization: Bearer <anahtar>' header'ında gönderin.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<ReturnType<typeof getApiKeyApi>["verifyApiKey"]>>;
|
||||||
|
try {
|
||||||
|
result = await getApiKeyApi().verifyApiKey({ body: { key: rawKey } });
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException("Geçersiz API anahtarı.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.valid || !result.key) {
|
||||||
|
const code = result.error?.code ?? "INVALID_API_KEY";
|
||||||
|
if (code === "RATE_LIMITED" || code === "USAGE_EXCEEDED" || code === "TOO_MANY_REQUESTS") {
|
||||||
|
throw new HttpException(
|
||||||
|
"İstek limiti aşıldı. Lütfen bekleyip tekrar deneyin.",
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (code === "KEY_EXPIRED") {
|
||||||
|
throw new UnauthorizedException("API anahtarının süresi dolmuş.");
|
||||||
|
}
|
||||||
|
if (code === "KEY_DISABLED") {
|
||||||
|
throw new UnauthorizedException("API anahtarı devre dışı bırakılmış.");
|
||||||
|
}
|
||||||
|
throw new UnauthorizedException("Geçersiz API anahtarı.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = result.key;
|
||||||
|
const [user] = await this.db.select().from(users).where(eq(users.id, key.userId)).limit(1);
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException("API anahtarının sahibi bulunamadı.");
|
||||||
|
}
|
||||||
|
if (user.status !== "active") {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
"Hesabınız askıya alınmış durumda. Destek ile iletişime geçin.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.user = user;
|
||||||
|
request.apiKey = key;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorization: Bearer <key> (tercih edilen) veya x-api-key header'ı. */
|
||||||
|
private extractKey(headers: Record<string, unknown>): string | null {
|
||||||
|
const authHeader = headers.authorization;
|
||||||
|
if (typeof authHeader === "string" && authHeader.length > 0) {
|
||||||
|
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||||
|
const value = (match ? match[1] : authHeader).trim();
|
||||||
|
if (value.length > 0) return value;
|
||||||
|
}
|
||||||
|
const apiKeyHeader = headers["x-api-key"];
|
||||||
|
if (typeof apiKeyHeader === "string" && apiKeyHeader.trim().length > 0) {
|
||||||
|
return apiKeyHeader.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
type CanActivate,
|
type CanActivate,
|
||||||
type ExecutionContext,
|
type ExecutionContext,
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { timingSafeEqual } from "node:crypto";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InternalTokenGuard implements CanActivate {
|
export class InternalTokenGuard implements CanActivate {
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ export default () => ({
|
|||||||
enabled: process.env.P_DB_ENABLED === "true",
|
enabled: process.env.P_DB_ENABLED === "true",
|
||||||
url: process.env.P_DB_URL,
|
url: process.env.P_DB_URL,
|
||||||
},
|
},
|
||||||
|
supplierPrices: {
|
||||||
|
// Tedarikçi fiyat geçmişi (takip MySQL, Tailscale). Kapalı/boş URL →
|
||||||
|
// part-prices uçları { matched: false } / boş batch döner; UI fiyat
|
||||||
|
// bölümünü hiç render etmez. Tedarikçi kimliği API cevabına asla çıkmaz —
|
||||||
|
// yalnızca stoktaki tekliflerin p50/p95/p99 dağılımı servis edilir.
|
||||||
|
enabled: process.env.SUPPLIER_PRICE_DB_ENABLED === "true",
|
||||||
|
url: process.env.SUPPLIER_PRICE_DB_URL,
|
||||||
|
},
|
||||||
otel: {
|
otel: {
|
||||||
enabled: process.env.OTEL_ENABLED === "true",
|
enabled: process.env.OTEL_ENABLED === "true",
|
||||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
|
date,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
jsonb,
|
jsonb,
|
||||||
numeric,
|
numeric,
|
||||||
pgTable,
|
pgTable,
|
||||||
|
primaryKey,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
@@ -37,6 +39,10 @@ export const users = pgTable(
|
|||||||
// subscription yet (referrer had no active/trial sub at grant time).
|
// subscription yet (referrer had no active/trial sub at grant time).
|
||||||
// Consumed when the user next starts a trial or activates a subscription.
|
// Consumed when the user next starts a trial or activates a subscription.
|
||||||
referralCreditDays: integer("referral_credit_days").default(0).notNull(),
|
referralCreditDays: integer("referral_credit_days").default(0).notNull(),
|
||||||
|
// Stripe Customer backing this user's recurring billing. Set on the first
|
||||||
|
// completed checkout and reused on later checkouts so saved cards and
|
||||||
|
// invoices stay on one customer record.
|
||||||
|
stripeCustomerId: text("stripe_customer_id"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
@@ -110,6 +116,46 @@ export const verifications = pgTable(
|
|||||||
(table) => [index("verifications_identifier_idx").on(table.identifier)],
|
(table) => [index("verifications_identifier_idx").on(table.identifier)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Better Auth: API Keys (public API) ──────────────
|
||||||
|
// Backing table for the better-auth `apiKey` plugin (model "apikey", plural).
|
||||||
|
// Column set mirrors the plugin's schema exactly — the drizzle adapter matches
|
||||||
|
// by property name, so renaming a property here breaks key verification.
|
||||||
|
// `key` holds the SHA256 hash of the secret; the plaintext is shown once at
|
||||||
|
// creation. `metadata` is a JSON string (enableMetadata) carrying
|
||||||
|
// per-key overrides such as dailyDecodeLimit.
|
||||||
|
export const apikeys = pgTable(
|
||||||
|
"apikeys",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name"),
|
||||||
|
start: text("start"),
|
||||||
|
prefix: text("prefix"),
|
||||||
|
key: text("key").notNull(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
refillInterval: integer("refill_interval"),
|
||||||
|
refillAmount: integer("refill_amount"),
|
||||||
|
lastRefillAt: timestamp("last_refill_at", { withTimezone: true }),
|
||||||
|
enabled: boolean("enabled").default(true).notNull(),
|
||||||
|
rateLimitEnabled: boolean("rate_limit_enabled").default(true).notNull(),
|
||||||
|
rateLimitTimeWindow: integer("rate_limit_time_window"),
|
||||||
|
rateLimitMax: integer("rate_limit_max"),
|
||||||
|
requestCount: integer("request_count").default(0).notNull(),
|
||||||
|
remaining: integer("remaining"),
|
||||||
|
lastRequest: timestamp("last_request", { withTimezone: true }),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
permissions: text("permissions"),
|
||||||
|
metadata: text("metadata"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("apikeys_key_idx").on(table.key),
|
||||||
|
index("apikeys_user_id_idx").on(table.userId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Brands ─────────────────────────────────────────
|
// ─── Brands ─────────────────────────────────────────
|
||||||
export const brands = pgTable(
|
export const brands = pgTable(
|
||||||
"brands",
|
"brands",
|
||||||
@@ -153,12 +199,17 @@ export const userSubscriptions = pgTable(
|
|||||||
startDate: timestamp("start_date", { withTimezone: true }),
|
startDate: timestamp("start_date", { withTimezone: true }),
|
||||||
endDate: timestamp("end_date", { withTimezone: true }),
|
endDate: timestamp("end_date", { withTimezone: true }),
|
||||||
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
|
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
|
||||||
|
// Stripe Subscription id for recurring (mode:"subscription") billing.
|
||||||
|
// NULL for trials and legacy one-time purchases. Renewal invoices resolve
|
||||||
|
// our row through this.
|
||||||
|
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("user_subscriptions_user_id_idx").on(table.userId),
|
index("user_subscriptions_user_id_idx").on(table.userId),
|
||||||
index("user_subscriptions_status_idx").on(table.status),
|
index("user_subscriptions_status_idx").on(table.status),
|
||||||
|
index("user_subscriptions_stripe_sub_idx").on(table.stripeSubscriptionId),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -227,6 +278,9 @@ export const payments = pgTable(
|
|||||||
iyzicoPaymentId: text("iyzico_payment_id"),
|
iyzicoPaymentId: text("iyzico_payment_id"),
|
||||||
stripeSessionId: text("stripe_session_id"),
|
stripeSessionId: text("stripe_session_id"),
|
||||||
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
||||||
|
// Stripe Invoice behind a recurring charge (renewals). Dedupe key against
|
||||||
|
// invoice.paid webhook retries.
|
||||||
|
stripeInvoiceId: text("stripe_invoice_id"),
|
||||||
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
|
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
|
||||||
bankAccountId: uuid("bank_account_id").references(() => bankAccounts.id),
|
bankAccountId: uuid("bank_account_id").references(() => bankAccounts.id),
|
||||||
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
|
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
|
||||||
@@ -239,6 +293,7 @@ export const payments = pgTable(
|
|||||||
index("payments_user_id_idx").on(table.userId),
|
index("payments_user_id_idx").on(table.userId),
|
||||||
index("payments_status_idx").on(table.status),
|
index("payments_status_idx").on(table.status),
|
||||||
index("payments_stripe_session_id_idx").on(table.stripeSessionId),
|
index("payments_stripe_session_id_idx").on(table.stripeSessionId),
|
||||||
|
index("payments_stripe_invoice_id_idx").on(table.stripeInvoiceId),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -322,6 +377,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) ──
|
// ─── Vehicles (shared config — one record per VIN) ──
|
||||||
export const vehicles = pgTable(
|
export const vehicles = pgTable(
|
||||||
"vehicles",
|
"vehicles",
|
||||||
@@ -390,12 +471,27 @@ export const categories = pgTable(
|
|||||||
hasParts: boolean("has_parts"),
|
hasParts: boolean("has_parts"),
|
||||||
unavailable: boolean("unavailable").default(false).notNull(),
|
unavailable: boolean("unavailable").default(false).notNull(),
|
||||||
source: varchar("source", { length: 20 }).default("pl24").notNull(),
|
source: varchar("source", { length: 20 }).default("pl24").notNull(),
|
||||||
|
// Materialized result of the name-level canonical mapping (see
|
||||||
|
// category_canonical_map). Nullable: null = not yet mapped / unmappable.
|
||||||
|
// Lets callers filter/browse this raw node under the unified taxonomy
|
||||||
|
// without walking the map table per row.
|
||||||
|
canonicalCategoryId: uuid("canonical_category_id"),
|
||||||
|
// Second canonical level: a canonical_categories row whose parentId ==
|
||||||
|
// canonicalCategoryId (e.g. Fren → Balata). Nullable: null = mapped to the
|
||||||
|
// top bucket only / no sub-leaf matched → SP manual review completes these.
|
||||||
|
canonicalSubcategoryId: uuid("canonical_subcategory_id"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("categories_vehicle_id_idx").on(table.vehicleId),
|
index("categories_vehicle_id_idx").on(table.vehicleId),
|
||||||
index("categories_catalog_vehicle_id_idx").on(table.catalogVehicleId),
|
index("categories_catalog_vehicle_id_idx").on(table.catalogVehicleId),
|
||||||
index("categories_parent_id_idx").on(table.parentId),
|
index("categories_parent_id_idx").on(table.parentId),
|
||||||
|
index("categories_canonical_category_id_idx").on(table.canonicalCategoryId),
|
||||||
|
index("categories_canonical_subcategory_id_idx").on(table.canonicalSubcategoryId),
|
||||||
|
// NULLS NOT DISTINCT since migration 0030 (raw SQL — drizzle's uniqueIndex
|
||||||
|
// can't express it): emex/pcat rows have NULL catalog_vehicle_id (pl24 rows
|
||||||
|
// NULL vehicle_id), and with default NULLs-distinct semantics decode retries
|
||||||
|
// slipped past onConflictDoNothing and duplicated whole category trees.
|
||||||
uniqueIndex("categories_vehicle_name_source_idx").on(
|
uniqueIndex("categories_vehicle_name_source_idx").on(
|
||||||
table.vehicleId,
|
table.vehicleId,
|
||||||
table.catalogVehicleId,
|
table.catalogVehicleId,
|
||||||
@@ -770,3 +866,186 @@ export const oemExpertRewards = pgTable(
|
|||||||
index("oem_expert_rewards_user_id_idx").on(table.userId),
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,11 @@
|
|||||||
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Browser, BrowserContext, Page } from "playwright";
|
import { Browser, BrowserContext, Page } from "playwright";
|
||||||
|
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
|
||||||
|
import {
|
||||||
|
classifyTransportError,
|
||||||
|
isProxyConnectFailure,
|
||||||
|
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||||
|
|
||||||
const SESSION_TTL_MS = 25 * 60 * 1000; // 25 minutes
|
const SESSION_TTL_MS = 25 * 60 * 1000; // 25 minutes
|
||||||
const MAX_CONCURRENT_PAGES = 3;
|
const MAX_CONCURRENT_PAGES = 3;
|
||||||
@@ -76,8 +81,16 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly floxyPass: string;
|
private readonly floxyPass: string;
|
||||||
private readonly floxyLifetime: number;
|
private readonly floxyLifetime: number;
|
||||||
private startedAt = 0;
|
private startedAt = 0;
|
||||||
|
// Provider the live browser was actually launched with. Tracks failover: when
|
||||||
|
// the configured provider is "floxy" but the shared health gate is tripped,
|
||||||
|
// (re)launch picks "dataimpulse" instead. Whole-browser scope because Playwright
|
||||||
|
// binds the proxy at launch — a relaunch is what swaps the exit.
|
||||||
|
private activeProvider: "floxy" | "dataimpulse" | "none" = "none";
|
||||||
|
|
||||||
constructor(private configService: ConfigService) {
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private readonly proxyHealth: ProxyHealthService,
|
||||||
|
) {
|
||||||
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
|
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
|
||||||
|
|
||||||
this.useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
|
this.useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
|
||||||
@@ -206,7 +219,11 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||||
if (executablePath) launchOptions.executablePath = executablePath;
|
if (executablePath) launchOptions.executablePath = executablePath;
|
||||||
|
|
||||||
if (this.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
|
// 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.
|
// for the whole chained scrape; a fresh id is minted on each (re)launch.
|
||||||
const sid = Math.random().toString(36).slice(2, 10);
|
const sid = Math.random().toString(36).slice(2, 10);
|
||||||
@@ -222,14 +239,18 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Using Floxy residential proxy: ${this.floxyHost}:${this.floxyPort} (sticky ${this.floxyLifetime}s)`,
|
`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();
|
const port = this.randomProxyPort();
|
||||||
launchOptions.proxy = {
|
launchOptions.proxy = {
|
||||||
server: `http://${this.proxyHost}:${port}`,
|
server: `http://${this.proxyHost}:${port}`,
|
||||||
username: this.proxyUsername,
|
username: this.proxyUsername,
|
||||||
password: this.proxyPassword,
|
password: this.proxyPassword,
|
||||||
};
|
};
|
||||||
this.logger.log(`Using 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);
|
this.browser = await chromium.launch(launchOptions);
|
||||||
@@ -270,22 +291,67 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
await this.launchBrowser();
|
await this.launchBrowser();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Provider for the next launch: DataImpulse while the Floxy gate is tripped. */
|
||||||
|
private pickLaunchProvider(): "floxy" | "dataimpulse" | "none" {
|
||||||
|
if (this.proxyProvider === "floxy" && this.proxyHealth.isFloxyDown()) {
|
||||||
|
return "dataimpulse"; // DI creds are always present (env defaults) when proxy on
|
||||||
|
}
|
||||||
|
return this.proxyProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async relaunchBrowser(): Promise<void> {
|
||||||
|
await this.closeBrowser();
|
||||||
|
await this.launchBrowser();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Visit baseUrl to establish/refresh ASP.NET session cookie
|
* Trip Floxy failover from a scrape-level transport failure (a connect error
|
||||||
|
* that surfaced AFTER the session was established, so ensureSession's own gate
|
||||||
|
* didn't catch it). Opens the shared cooldown and forces the next acquirePage
|
||||||
|
* to re-establish the session — which switches the browser to DataImpulse.
|
||||||
|
*/
|
||||||
|
async tripFloxyFailover(reason?: string): Promise<void> {
|
||||||
|
this.proxyHealth.reportFloxyFailure(reason);
|
||||||
|
if (this.activeProvider === "floxy") {
|
||||||
|
this.sessionExpiry = 0; // force ensureSession → provider switch on next acquire
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visit baseUrl to establish/refresh ASP.NET session cookie. Doubles as the
|
||||||
|
* Floxy health gate for the browser path: only at a session boundary, switch
|
||||||
|
* the browser's proxy to match pickLaunchProvider() (fail over to DataImpulse
|
||||||
|
* when Floxy is down, probe Floxy again once the gate clears). A connect
|
||||||
|
* failure during the probe trips the gate and retries once on DataImpulse.
|
||||||
*/
|
*/
|
||||||
private async ensureSession(): Promise<void> {
|
private async ensureSession(): Promise<void> {
|
||||||
|
// Only re-evaluate the provider when establishing a fresh session, so an
|
||||||
|
// in-flight scrape on a working browser is never interrupted mid-flow.
|
||||||
|
if (Date.now() >= this.sessionExpiry && this.proxyProvider !== "none") {
|
||||||
|
const desired = this.pickLaunchProvider();
|
||||||
|
if (this.browser?.isConnected() && desired !== this.activeProvider) {
|
||||||
|
this.logger.log(`EMEX switching browser proxy: ${this.activeProvider} → ${desired}`);
|
||||||
|
await this.relaunchBrowser();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Date.now() < this.sessionExpiry) return;
|
if (Date.now() < this.sessionExpiry) return;
|
||||||
|
|
||||||
this.logger.log("Establishing EMEX session...");
|
// Up to 2 attempts: a Floxy tunnel failure trips the gate, relaunches on
|
||||||
|
// 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");
|
if (!this.context) throw new Error("EMEX browser context not initialized");
|
||||||
const ctx = this.context;
|
const ctx = this.context;
|
||||||
const page = await ctx.newPage();
|
const page = await ctx.newPage();
|
||||||
try {
|
try {
|
||||||
|
this.logger.log(`Establishing EMEX session (${this.activeProvider})...`);
|
||||||
await page.goto(EMEX_BASE_URL, {
|
await page.goto(EMEX_BASE_URL, {
|
||||||
waitUntil: "networkidle",
|
waitUntil: "networkidle",
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (this.activeProvider === "floxy") this.proxyHealth.reportFloxySuccess();
|
||||||
|
|
||||||
const cookies = await ctx.cookies();
|
const cookies = await ctx.cookies();
|
||||||
const session = cookies.find((c) => c.name === "ASP.NET_SessionId");
|
const session = cookies.find((c) => c.name === "ASP.NET_SessionId");
|
||||||
if (session) {
|
if (session) {
|
||||||
@@ -296,8 +362,23 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// Still set a short TTL to avoid hammering
|
// Still set a short TTL to avoid hammering
|
||||||
this.sessionExpiry = Date.now() + 60_000;
|
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 {
|
} finally {
|
||||||
await page.close();
|
await page.close().catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ const browser = {} as never;
|
|||||||
const redis = { set: vi.fn() } as never;
|
const redis = { set: vi.fn() } as never;
|
||||||
const posthog = {} as never;
|
const posthog = {} as never;
|
||||||
const proxyTelemetry = { record: vi.fn() } as never;
|
const proxyTelemetry = { record: vi.fn() } as never;
|
||||||
|
const proxyHealth = {
|
||||||
|
isFloxyDown: vi.fn(() => false),
|
||||||
|
reportFloxyFailure: vi.fn(),
|
||||||
|
reportFloxySuccess: vi.fn(),
|
||||||
|
} as never;
|
||||||
|
|
||||||
describe("EmexService proxy-port coercion (regression)", () => {
|
describe("EmexService proxy-port coercion (regression)", () => {
|
||||||
it("does not throw 'Invalid URL' when ports arrive as strings over a real range", () => {
|
it("does not throw 'Invalid URL' when ports arrive as strings over a real range", () => {
|
||||||
@@ -29,7 +34,9 @@ describe("EmexService proxy-port coercion (regression)", () => {
|
|||||||
EMEX_PROXY_PORT_END: "10099",
|
EMEX_PROXY_PORT_END: "10099",
|
||||||
});
|
});
|
||||||
// Pre-fix: threw "Invalid URL" here (port "4510001" > 65535).
|
// Pre-fix: threw "Invalid URL" here (port "4510001" > 65535).
|
||||||
expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
|
expect(
|
||||||
|
() => new EmexService(config, browser, redis, posthog, proxyTelemetry, proxyHealth),
|
||||||
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("constructs with a single-port range (string env)", () => {
|
it("constructs with a single-port range (string env)", () => {
|
||||||
@@ -38,7 +45,9 @@ describe("EmexService proxy-port coercion (regression)", () => {
|
|||||||
EMEX_PROXY_PORT_START: "823",
|
EMEX_PROXY_PORT_START: "823",
|
||||||
EMEX_PROXY_PORT_END: "823",
|
EMEX_PROXY_PORT_END: "823",
|
||||||
});
|
});
|
||||||
expect(() => new EmexService(config, browser, redis, posthog, 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", () => {
|
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_START: "not-a-number",
|
||||||
EMEX_PROXY_PORT_END: "999999",
|
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", () => {
|
it("constructs cleanly with the proxy disabled", () => {
|
||||||
const config = makeConfig({ EMEX_USE_PROXY: "false" });
|
const config = makeConfig({ EMEX_USE_PROXY: "false" });
|
||||||
expect(() => new EmexService(config, browser, redis, posthog, 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 { isBackfillContext } from "../../jobs/prefetch-context";
|
||||||
import { PostHogService } from "../../posthog/posthog.service";
|
import { PostHogService } from "../../posthog/posthog.service";
|
||||||
import { RedisService } from "../../redis/redis.service";
|
import { RedisService } from "../../redis/redis.service";
|
||||||
|
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
|
||||||
import {
|
import {
|
||||||
ProxyTelemetryService,
|
ProxyTelemetryService,
|
||||||
classifyTransportError,
|
classifyTransportError,
|
||||||
|
isProxyConnectFailure,
|
||||||
} from "../proxy-telemetry/proxy-telemetry.service";
|
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||||
import { parseUnitLeaves, parseVehicleTree } from "./emex-tree.parser";
|
import { parseUnitLeaves, parseVehicleTree } from "./emex-tree.parser";
|
||||||
import { EmexBrowserService } from "./emex.browser";
|
import { EmexBrowserService } from "./emex.browser";
|
||||||
@@ -147,6 +149,7 @@ export class EmexService {
|
|||||||
private redis: RedisService,
|
private redis: RedisService,
|
||||||
private posthog: PostHogService,
|
private posthog: PostHogService,
|
||||||
private proxyTelemetry: ProxyTelemetryService,
|
private proxyTelemetry: ProxyTelemetryService,
|
||||||
|
private proxyHealth: ProxyHealthService,
|
||||||
) {
|
) {
|
||||||
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
|
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
|
||||||
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
|
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
|
||||||
@@ -383,8 +386,14 @@ export class EmexService {
|
|||||||
// and a different proxy IP must not "retry" it away.
|
// and a different proxy IP must not "retry" it away.
|
||||||
const schedule: Array<"dataimpulse" | "floxy"> = [];
|
const schedule: Array<"dataimpulse" | "floxy"> = [];
|
||||||
if (this.emexProxyProvider === "floxy") {
|
if (this.emexProxyProvider === "floxy") {
|
||||||
|
if (this.emexFloxy && this.proxyHealth.isFloxyDown() && this.emexProxy) {
|
||||||
|
// Floxy gate tripped → DataImpulse-first (rotating ports cover its ~50%
|
||||||
|
// dead ports), with one Floxy probe last so a recovery is still detected.
|
||||||
|
schedule.push("dataimpulse", "dataimpulse", "floxy");
|
||||||
|
} else {
|
||||||
if (this.emexFloxy) schedule.push("floxy", "floxy");
|
if (this.emexFloxy) schedule.push("floxy", "floxy");
|
||||||
if (this.emexProxy) schedule.push("dataimpulse");
|
if (this.emexProxy) schedule.push("dataimpulse");
|
||||||
|
}
|
||||||
} else if (this.emexProxyProvider === "dataimpulse") {
|
} else if (this.emexProxyProvider === "dataimpulse") {
|
||||||
if (this.emexProxy) schedule.push("dataimpulse", "dataimpulse", "dataimpulse");
|
if (this.emexProxy) schedule.push("dataimpulse", "dataimpulse", "dataimpulse");
|
||||||
if (this.emexFloxy) schedule.push("floxy", "floxy");
|
if (this.emexFloxy) schedule.push("floxy", "floxy");
|
||||||
@@ -417,6 +426,7 @@ export class EmexService {
|
|||||||
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
|
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
|
||||||
}
|
}
|
||||||
if (provider === "floxy") {
|
if (provider === "floxy") {
|
||||||
|
this.proxyHealth.reportFloxySuccess(); // Floxy answered — clear cooldown
|
||||||
this.logger.log(`EMEX fetch via Floxy fallback succeeded for ${url}`);
|
this.logger.log(`EMEX fetch via Floxy fallback succeeded for ${url}`);
|
||||||
}
|
}
|
||||||
return await res.text();
|
return await res.text();
|
||||||
@@ -446,6 +456,11 @@ export class EmexService {
|
|||||||
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|other side closed|terminated|UND_ERR/i.test(
|
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|other side closed|terminated|UND_ERR/i.test(
|
||||||
`${e.message} ${String(e.cause ?? "")}`,
|
`${e.message} ${String(e.cause ?? "")}`,
|
||||||
));
|
));
|
||||||
|
// A Floxy connect failure (dead exit / tunnel refused) trips the shared
|
||||||
|
// gate so EMEX + pcat fail over to DataImpulse for the cooldown window.
|
||||||
|
if (provider === "floxy" && isProxyConnectFailure(e)) {
|
||||||
|
this.proxyHealth.reportFloxyFailure(`emex http: ${classifyTransportError(e)}`);
|
||||||
|
}
|
||||||
if (!transient || attempt === maxAttempts) break;
|
if (!transient || attempt === maxAttempts) break;
|
||||||
// A Floxy transport failure means the current sticky exit IP is dead —
|
// A Floxy transport failure means the current sticky exit IP is dead —
|
||||||
// roll to a fresh session so the next Floxy attempt gets a new IP.
|
// roll to a fresh session so the next Floxy attempt gets a new IP.
|
||||||
@@ -909,6 +924,12 @@ export class EmexService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
|
|
||||||
|
// Floxy tunnel dead mid-scrape → trip failover so the next acquirePage
|
||||||
|
// re-establishes the session on DataImpulse.
|
||||||
|
if (isProxyConnectFailure(err)) {
|
||||||
|
void this.browserService.tripFloxyFailover(`emex decode: ${classifyTransportError(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
err instanceof BadRequestException ||
|
err instanceof BadRequestException ||
|
||||||
err instanceof ServiceUnavailableException ||
|
err instanceof ServiceUnavailableException ||
|
||||||
@@ -1052,6 +1073,10 @@ export class EmexService {
|
|||||||
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
|
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
|
// Floxy tunnel dead mid-scrape → trip failover for the next acquirePage.
|
||||||
|
if (isProxyConnectFailure(err)) {
|
||||||
|
void this.browserService.tripFloxyFailover(`emex parts: ${classifyTransportError(err)}`);
|
||||||
|
}
|
||||||
this.logger.error(`Failed to fetch category parts: ${err.message}`);
|
this.logger.error(`Failed to fetch category parts: ${err.message}`);
|
||||||
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
|
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -135,8 +135,14 @@ export class PSourceDbService implements OnModuleInit, OnModuleDestroy {
|
|||||||
SELECT
|
SELECT
|
||||||
a.id::text AS id,
|
a.id::text AS id,
|
||||||
b.name AS brand,
|
b.name AS brand,
|
||||||
a.article_number AS article_number,
|
-- Snapshot'ta kolonlar şaşırtmacalı: articles.article_number %96
|
||||||
a.name AS name,
|
-- 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,
|
a.spare_info AS spare_info,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(json_build_object('brand', o.brand, 'code', o.code))
|
SELECT json_agg(json_build_object('brand', o.brand, 'code', o.code))
|
||||||
@@ -167,7 +173,7 @@ export class PSourceDbService implements OnModuleInit, OnModuleDestroy {
|
|||||||
FROM hit
|
FROM hit
|
||||||
JOIN articles a ON a.id = hit.article_id
|
JOIN articles a ON a.id = hit.article_id
|
||||||
JOIN article_brands b ON b.id = a.brand_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;
|
if (rows.length === 0) return miss;
|
||||||
|
|||||||
@@ -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,9 +23,11 @@ import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@ne
|
|||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Browser, BrowserContext } from "playwright";
|
import { Browser, BrowserContext } from "playwright";
|
||||||
import { RedisService } from "../../redis/redis.service";
|
import { RedisService } from "../../redis/redis.service";
|
||||||
|
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
|
||||||
import {
|
import {
|
||||||
ProxyTelemetryService,
|
ProxyTelemetryService,
|
||||||
classifyTransportError,
|
classifyTransportError,
|
||||||
|
isProxyConnectFailure,
|
||||||
resolveExitIp,
|
resolveExitIp,
|
||||||
} from "../proxy-telemetry/proxy-telemetry.service";
|
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||||
import { JwtSlot, PcatJwtToken, PcatSession } from "./parts-catalogs.types";
|
import { JwtSlot, PcatJwtToken, PcatSession } from "./parts-catalogs.types";
|
||||||
@@ -185,11 +187,20 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly diHost: string;
|
private readonly diHost: string;
|
||||||
private readonly diUser: string;
|
private readonly diUser: string;
|
||||||
private readonly diPass: string;
|
private readonly diPass: string;
|
||||||
|
// When a proxied capture dies on a tunnel/connect failure (residential balance
|
||||||
|
// exhausted, DataImpulse ports dead, both providers down), retry that capture
|
||||||
|
// DIRECT from the server IP instead of starving the warm pool. Safe because
|
||||||
|
// the TWS- token is not IP-bound and capture is low-frequency (≤ MAX_POOL_SIZE
|
||||||
|
// refreshes / ~8.5min). This is the survival path for a full proxy outage —
|
||||||
|
// the exact failure seen 2026-06-11 (Floxy 402) and 2026-07-03 (Floxy tunnel
|
||||||
|
// down). Disable with PCAT_CAPTURE_ALLOW_DIRECT=false.
|
||||||
|
private readonly captureAllowDirect: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
private readonly proxyTelemetry: ProxyTelemetryService,
|
private readonly proxyTelemetry: ProxyTelemetryService,
|
||||||
|
private readonly proxyHealth: ProxyHealthService,
|
||||||
) {
|
) {
|
||||||
const cfg = this.configService;
|
const cfg = this.configService;
|
||||||
|
|
||||||
@@ -232,6 +243,10 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.diUser = cfg.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
|
this.diUser = cfg.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
|
||||||
this.diPass = cfg.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS);
|
this.diPass = cfg.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS);
|
||||||
|
|
||||||
|
this.captureAllowDirect =
|
||||||
|
cfg.get<string>("PCAT_CAPTURE_ALLOW_DIRECT", "true") !== "false" &&
|
||||||
|
this.proxyProvider !== "none";
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
this.proxyProvider === "floxy"
|
this.proxyProvider === "floxy"
|
||||||
? `PCAT transport: floxy residential ${this.floxyHost}:${this.floxyPort} (call=rotating, capture=sticky ${this.floxyCaptureLifetime}s)`
|
? `PCAT transport: floxy residential ${this.floxyHost}:${this.floxyPort} (call=rotating, capture=sticky ${this.floxyCaptureLifetime}s)`
|
||||||
@@ -254,17 +269,29 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// Telemetry identity: sticky Floxy session id or DataImpulse port. Null on
|
// Telemetry identity: sticky Floxy session id or DataImpulse port. Null on
|
||||||
// rotating legs (exit IP changes per request — nothing stable to attribute).
|
// rotating legs (exit IP changes per request — nothing stable to attribute).
|
||||||
sessionKey: string | null;
|
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")
|
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 port = this.allocatePort();
|
||||||
const server = `http://${this.diHost}:${port}`;
|
const server = `http://${this.diHost}:${port}`;
|
||||||
return {
|
return {
|
||||||
proxyUrl: `http://${this.diUser}:${this.diPass}@${this.diHost}:${port}`,
|
proxyUrl: `http://${this.diUser}:${this.diPass}@${this.diHost}:${port}`,
|
||||||
proxyConfig: { server, username: this.diUser, password: this.diPass },
|
proxyConfig: { server, username: this.diUser, password: this.diPass },
|
||||||
sessionKey: `di:${port}`,
|
sessionKey: `di:${port}`,
|
||||||
|
provider: "dataimpulse",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +308,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
proxyUrl: `http://${this.floxyUser}:${password}@${this.floxyHost}:${this.floxyPort}`,
|
proxyUrl: `http://${this.floxyUser}:${password}@${this.floxyHost}:${this.floxyPort}`,
|
||||||
proxyConfig: { server, username: this.floxyUser, password },
|
proxyConfig: { server, username: this.floxyUser, password },
|
||||||
sessionKey,
|
sessionKey,
|
||||||
|
provider: "floxy",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,6 +415,20 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
// ─── Pool management ──────────────────────────────────────
|
// ─── Pool management ──────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim the pool to MAX_POOL_SIZE, evicting the oldest slots (by capturedAt) and
|
||||||
|
* clearing their refresh timers. Idempotent; a no-op when at/under the cap.
|
||||||
|
*/
|
||||||
|
private enforcePoolCap(): void {
|
||||||
|
if (this.pool.length <= MAX_POOL_SIZE) return;
|
||||||
|
this.pool.sort((a, b) => a.capturedAt - b.capturedAt); // oldest first
|
||||||
|
const evicted = this.pool.splice(0, this.pool.length - MAX_POOL_SIZE);
|
||||||
|
for (const s of evicted) this.clearSlotTimer(s);
|
||||||
|
this.logger.warn(
|
||||||
|
`JWT pool: over cap, evicted ${evicted.length} oldest slot(s), size now ${this.pool.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private pickValidSlot(): JwtSlot | null {
|
private pickValidSlot(): JwtSlot | null {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
// Remove expired slots
|
// Remove expired slots
|
||||||
@@ -461,8 +503,15 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// Mark site as used
|
// Mark site as used
|
||||||
this.siteLastUsedAt.set(siteUrl, Date.now());
|
this.siteLastUsedAt.set(siteUrl, Date.now());
|
||||||
|
|
||||||
// Add to pool
|
// Add to pool, then enforce the hard cap. A slot-accounting race in the
|
||||||
|
// refresh path (concurrent captures via the semaphore, or a refresh
|
||||||
|
// whose old slot was already removed) could otherwise let the pool grow
|
||||||
|
// past MAX_POOL_SIZE — observed at 32-39 on prod, which multiplies
|
||||||
|
// capture load and partner-site ban exposure for no benefit. Evicting
|
||||||
|
// the oldest slots keeps the freshest MAX_POOL_SIZE and makes the
|
||||||
|
// documented invariant hold regardless of the race.
|
||||||
this.pool.push(slot);
|
this.pool.push(slot);
|
||||||
|
this.enforcePoolCap();
|
||||||
|
|
||||||
// Publish to Redis so other containers can hydrate from this slot.
|
// Publish to Redis so other containers can hydrate from this slot.
|
||||||
this.persistToRedis(slot).catch(() => {});
|
this.persistToRedis(slot).catch(() => {});
|
||||||
@@ -731,13 +780,28 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
// ─── JWT capture via Playwright ───────────────────────────
|
// ─── JWT capture via Playwright ───────────────────────────
|
||||||
|
|
||||||
private async attemptCapture(siteUrl: string): Promise<PcatJwtToken | null> {
|
private async attemptCapture(siteUrl: string, forceDirect = false): Promise<PcatJwtToken | null> {
|
||||||
let context: BrowserContext | null = null;
|
let context: BrowserContext | null = null;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
// Sticky capture proxy: one residential exit IP pinned for the lifetime of
|
// Sticky capture proxy: one residential exit IP pinned for the lifetime of
|
||||||
// this capture so the partner site + widget see a coherent session.
|
// this capture so the partner site + widget see a coherent session.
|
||||||
const { proxyConfig, proxyUrl, sessionKey } = this.buildProxy("capture");
|
// forceDirect (set on retry after a proxy tunnel failure) skips the proxy —
|
||||||
|
// see captureAllowDirect: the token is not IP-bound so a direct capture is a
|
||||||
|
// safe last resort that keeps the pool alive through a full proxy outage.
|
||||||
|
const { proxyConfig, proxyUrl, sessionKey, provider } = forceDirect
|
||||||
|
? {
|
||||||
|
proxyConfig: null,
|
||||||
|
proxyUrl: null as string | null,
|
||||||
|
sessionKey: null as string | null,
|
||||||
|
provider: "none" as ProxyProvider,
|
||||||
|
}
|
||||||
|
: this.buildProxy("capture");
|
||||||
|
|
||||||
|
// Set when a proxied navigation dies on a tunnel/connect failure and direct
|
||||||
|
// capture is allowed — we fall through to a direct retry instead of counting
|
||||||
|
// this as a dead site.
|
||||||
|
let retryDirect = false;
|
||||||
|
|
||||||
// The session is sticky, so a parallel ipify probe exits from the SAME IP
|
// The session is sticky, so a parallel ipify probe exits from the SAME IP
|
||||||
// the capture will use — that's what makes banned-IP tracking concrete.
|
// the capture will use — that's what makes banned-IP tracking concrete.
|
||||||
@@ -747,7 +811,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
void exitIpPromise.then((exitIp) =>
|
void exitIpPromise.then((exitIp) =>
|
||||||
this.proxyTelemetry.record({
|
this.proxyTelemetry.record({
|
||||||
service: "pcat_capture",
|
service: "pcat_capture",
|
||||||
provider: this.proxyProvider,
|
provider,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
exitIp,
|
exitIp,
|
||||||
targetHost: new URL(siteUrl).hostname,
|
targetHost: new URL(siteUrl).hostname,
|
||||||
@@ -834,8 +898,21 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// constants above.
|
// constants above.
|
||||||
gotoOk = false;
|
gotoOk = false;
|
||||||
this.logger.debug(`Navigation ended: ${(navErr as Error).message?.slice(0, 80)}`);
|
this.logger.debug(`Navigation ended: ${(navErr as Error).message?.slice(0, 80)}`);
|
||||||
|
const connectFailed = isProxyConnectFailure(navErr);
|
||||||
|
// A tunnel/connect failure here (not a plain slow-site timeout) means the
|
||||||
|
// Floxy exit is dead — trip the shared gate so this + EMEX fail over to
|
||||||
|
// DataImpulse for the cooldown window.
|
||||||
|
if (provider === "floxy" && connectFailed) {
|
||||||
|
this.proxyHealth.reportFloxyFailure(`pcat capture: ${classifyTransportError(navErr)}`);
|
||||||
|
}
|
||||||
|
// Proxy tunnel is dead (all providers). Rather than return an empty
|
||||||
|
// capture and starve the pool, retry this same site direct.
|
||||||
|
if (connectFailed && proxyConfig && this.captureAllowDirect && !forceDirect) {
|
||||||
|
retryDirect = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!retryDirect) {
|
||||||
// Poll for token — short cap after healthy goto, brief grace after fail
|
// Poll for token — short cap after healthy goto, brief grace after fail
|
||||||
const pollMax = gotoOk ? CAPTURE_POLL_AFTER_OK : CAPTURE_POLL_AFTER_FAIL;
|
const pollMax = gotoOk ? CAPTURE_POLL_AFTER_OK : CAPTURE_POLL_AFTER_FAIL;
|
||||||
for (let i = 0; i < pollMax; i++) {
|
for (let i = 0; i < pollMax; i++) {
|
||||||
@@ -847,7 +924,11 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
if (capturedToken) {
|
if (capturedToken) {
|
||||||
logCapture(true);
|
logCapture(true);
|
||||||
this.logger.log(`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`);
|
// 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;
|
return capturedToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,10 +937,19 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
logCapture(false, "capture_timeout");
|
logCapture(false, "capture_timeout");
|
||||||
this.logger.debug(`No token after ${elapsed}ms from ${siteUrl}`);
|
this.logger.debug(`No token after ${elapsed}ms from ${siteUrl}`);
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logCapture(false, classifyTransportError(err));
|
logCapture(false, classifyTransportError(err));
|
||||||
|
const connectFailed = isProxyConnectFailure(err);
|
||||||
|
if (provider === "floxy" && connectFailed) {
|
||||||
|
this.proxyHealth.reportFloxyFailure(`pcat capture: ${classifyTransportError(err)}`);
|
||||||
|
}
|
||||||
|
if (connectFailed && proxyConfig && this.captureAllowDirect && !forceDirect) {
|
||||||
|
retryDirect = true;
|
||||||
|
} else {
|
||||||
this.logger.warn(`Token capture error: ${(err as Error).message}`);
|
this.logger.warn(`Token capture error: ${(err as Error).message}`);
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (context) {
|
if (context) {
|
||||||
try {
|
try {
|
||||||
@@ -872,6 +962,13 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reached only when a proxied attempt hit a dead tunnel and direct is
|
||||||
|
// allowed — retry the same site straight from the server IP.
|
||||||
|
this.logger.warn(
|
||||||
|
`Capture proxy tunnel dead (${provider}) for ${new URL(siteUrl).hostname} — retrying direct`,
|
||||||
|
);
|
||||||
|
return this.attemptCapture(siteUrl, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Browser lifecycle ───────────────────────────────────
|
// ─── Browser lifecycle ───────────────────────────────────
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import { Injectable, Logger } from "@nestjs/common";
|
|||||||
import { isBackfillContext } from "../../jobs/prefetch-context";
|
import { isBackfillContext } from "../../jobs/prefetch-context";
|
||||||
import { PostHogService } from "../../posthog/posthog.service";
|
import { PostHogService } from "../../posthog/posthog.service";
|
||||||
import { RedisService } from "../../redis/redis.service";
|
import { RedisService } from "../../redis/redis.service";
|
||||||
|
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
|
||||||
import {
|
import {
|
||||||
ProxyTelemetryService,
|
ProxyTelemetryService,
|
||||||
classifyTransportError,
|
classifyTransportError,
|
||||||
describeProxyUrl,
|
describeProxyUrl,
|
||||||
|
isProxyConnectFailure,
|
||||||
} from "../proxy-telemetry/proxy-telemetry.service";
|
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||||
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
|
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
|
||||||
import {
|
import {
|
||||||
@@ -68,6 +70,7 @@ export class PartsCatalogsService {
|
|||||||
private redis: RedisService,
|
private redis: RedisService,
|
||||||
private posthog: PostHogService,
|
private posthog: PostHogService,
|
||||||
private proxyTelemetry: ProxyTelemetryService,
|
private proxyTelemetry: ProxyTelemetryService,
|
||||||
|
private proxyHealth: ProxyHealthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Mark parts-catalogs as actively used (5min TTL) to defer prefetch worker */
|
/** Mark parts-catalogs as actively used (5min TTL) to defer prefetch worker */
|
||||||
@@ -324,6 +327,10 @@ export class PartsCatalogsService {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Token proven good on this IP — clear any accrued failure streak.
|
// Token proven good on this IP — clear any accrued failure streak.
|
||||||
session._slot.failCount = 0;
|
session._slot.failCount = 0;
|
||||||
|
// Floxy answered at transport level — clear any failover cooldown.
|
||||||
|
if (describeProxyUrl(session.proxyUrl).provider === "floxy") {
|
||||||
|
this.proxyHealth.reportFloxySuccess();
|
||||||
|
}
|
||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +359,11 @@ export class PartsCatalogsService {
|
|||||||
success: false,
|
success: false,
|
||||||
durationMs: Date.now() - attemptStart,
|
durationMs: Date.now() - attemptStart,
|
||||||
});
|
});
|
||||||
|
// Floxy exit refused/closed the CONNECT → trip the shared gate so the
|
||||||
|
// next capture/call (and EMEX) fails over to DataImpulse.
|
||||||
|
if (describeProxyUrl(session.proxyUrl).provider === "floxy" && isProxyConnectFailure(e)) {
|
||||||
|
this.proxyHealth.reportFloxyFailure(`pcat call: ${classifyTransportError(e)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Retry transient TRANSPORT failures only: request timeouts and undici
|
// Retry transient TRANSPORT failures only: request timeouts and undici
|
||||||
// network errors ("TypeError: fetch failed" — a dropped/reset DataImpulse
|
// network errors ("TypeError: fetch failed" — a dropped/reset DataImpulse
|
||||||
|
|||||||
@@ -91,6 +91,20 @@ describe("PL24FordLegacyService.parseFordGroupsFromHtml — nav-crumb junk filte
|
|||||||
expect(groups.some((g) => g.linkPath.includes("eu.nissan.biz"))).toBe(false);
|
expect(groups.some((g) => g.linkPath.includes("eu.nissan.biz"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("appends the Hyundai illustration description to the bare figure code", () => {
|
||||||
|
// Real Hyundai row shape: first <td> is the figure code (in a link), the
|
||||||
|
// second <td> is the human description — which the parser used to drop.
|
||||||
|
const html = [
|
||||||
|
"<table>",
|
||||||
|
'<tr class="tc-data-row" url="image-board.action?mainGroup=EN&subGroup=20-201A&illustration=1&lang=tr">' +
|
||||||
|
'<td class="identifier"><a href="image-board.action?subGroup=20-201A">20-201A .1</a></td>' +
|
||||||
|
"<td>SUB ENGINE ASSY</td><td></td></tr>",
|
||||||
|
"</table>",
|
||||||
|
].join("");
|
||||||
|
const groups = pg.parseFordGroupsFromHtml(html, "hyundai_parts", "");
|
||||||
|
expect(groups[0]?.nameTr).toBe("20-201A .1 SUB ENGINE ASSY");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps Volvo vin-group.action?...group1=... (real category) but drops the bare breadcrumb", () => {
|
it("keeps Volvo vin-group.action?...group1=... (real category) but drops the bare breadcrumb", () => {
|
||||||
const html = [
|
const html = [
|
||||||
'<tr class="tc-data-row" url="vin-group.action?group1=2&lang=tr" caption="Motor"><td>Motor</td></tr>',
|
'<tr class="tc-data-row" url="vin-group.action?group1=2&lang=tr" caption="Motor"><td>Motor</td></tr>',
|
||||||
|
|||||||
@@ -3663,9 +3663,10 @@ export class PL24FordLegacyService {
|
|||||||
seen.add(url);
|
seen.add(url);
|
||||||
|
|
||||||
const captionMatch = segment.match(/\bcaption="([^"]+)"/);
|
const captionMatch = segment.match(/\bcaption="([^"]+)"/);
|
||||||
// Try to extract any text from <td> elements, including those with nested tags
|
// Collect all <td> texts (nested tags stripped). The first non-empty one
|
||||||
let tdName = "";
|
// is the primary name for most brands.
|
||||||
const tdPattern = /<td[^>]*>([\s\S]*?)<\/td>/g;
|
const tdPattern = /<td[^>]*>([\s\S]*?)<\/td>/g;
|
||||||
|
const tdTexts: string[] = [];
|
||||||
for (const tdMatch2 of segment.matchAll(tdPattern)) {
|
for (const tdMatch2 of segment.matchAll(tdPattern)) {
|
||||||
const text = tdMatch2[1]
|
const text = tdMatch2[1]
|
||||||
.replace(/<[^>]+>/g, " ")
|
.replace(/<[^>]+>/g, " ")
|
||||||
@@ -3674,10 +3675,22 @@ export class PL24FordLegacyService {
|
|||||||
.replace(/\xa0/g, " ")
|
.replace(/\xa0/g, " ")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
if (text.length >= 3) {
|
if (text.length >= 2) tdTexts.push(text);
|
||||||
tdName = text;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
let tdName = tdTexts.find((t) => t.length >= 3) ?? "";
|
||||||
|
// Hyundai/Kia illustration rows put a bare figure code in the first cell
|
||||||
|
// ("09-091 .1") and the human description in the next ("SUB ENGINE ASSY").
|
||||||
|
// When the chosen name is code-only, append the first later cell that has
|
||||||
|
// real words so the leaf is nameable (and canonically mappable via ENGINE).
|
||||||
|
// Figure code like "09-091", "20-201A .1" (optional letter suffix + " .N"),
|
||||||
|
// or a purely numeric/dotted code.
|
||||||
|
const isCodeOnly = (s: string) => {
|
||||||
|
const t = s.trim();
|
||||||
|
return /^\d{1,3}-\d{1,4}[a-z]?(\s*\.\s*\d+)?$/i.test(t) || /^[\d.\s-]+$/.test(t);
|
||||||
|
};
|
||||||
|
if (tdName && isCodeOnly(tdName)) {
|
||||||
|
const desc = tdTexts.find((t) => t !== tdName && /[a-zA-ZğüşıöçĞÜŞİÖÇ]{3,}/.test(t));
|
||||||
|
if (desc) tdName = `${tdName} ${desc}`.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caption may contain HTML-encoded content (e.g. <div class="restrictionBlock"...>)
|
// Caption may contain HTML-encoded content (e.g. <div class="restrictionBlock"...>)
|
||||||
|
|||||||
@@ -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 { Module } from "@nestjs/common";
|
||||||
|
import { ProxyHealthService } from "./proxy-health.service";
|
||||||
import { ProxyTelemetryService } from "./proxy-telemetry.service";
|
import { ProxyTelemetryService } from "./proxy-telemetry.service";
|
||||||
|
|
||||||
// Imported by PartsCatalogsModule and EmexModule; Nest module caching makes the
|
// 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({
|
@Module({
|
||||||
providers: [ProxyTelemetryService],
|
providers: [ProxyTelemetryService, ProxyHealthService],
|
||||||
exports: [ProxyTelemetryService],
|
exports: [ProxyTelemetryService, ProxyHealthService],
|
||||||
})
|
})
|
||||||
export class ProxyTelemetryModule {}
|
export class ProxyTelemetryModule {}
|
||||||
|
|||||||
@@ -52,6 +52,25 @@ export function classifyTransportError(err: unknown): string {
|
|||||||
return "transport";
|
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
|
* Derive provider + session key from a proxy URL the way the integrations
|
||||||
* build them: Floxy passwords may embed `_session-<id>_lifetime-<n>` (sticky);
|
* build them: Floxy passwords may embed `_session-<id>_lifetime-<n>` (sticky);
|
||||||
@@ -79,10 +98,7 @@ export function describeProxyUrl(proxyUrl: string | null | undefined): {
|
|||||||
* probe through the same proxy. Only meaningful for sticky sessions — on a
|
* probe through the same proxy. Only meaningful for sticky sessions — on a
|
||||||
* rotating leg the probe and the real request exit from different IPs.
|
* rotating leg the probe and the real request exit from different IPs.
|
||||||
*/
|
*/
|
||||||
export async function resolveExitIp(
|
export async function resolveExitIp(proxyUrl: string, timeoutMs = 5_000): Promise<string | null> {
|
||||||
proxyUrl: string,
|
|
||||||
timeoutMs = 5_000,
|
|
||||||
): Promise<string | null> {
|
|
||||||
try {
|
try {
|
||||||
const { ProxyAgent } = await import("undici");
|
const { ProxyAgent } = await import("undici");
|
||||||
const res = await fetch("https://api.ipify.org?format=text", {
|
const res = await fetch("https://api.ipify.org?format=text", {
|
||||||
@@ -138,7 +154,9 @@ export class ProxyTelemetryService implements OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Telemetry must never matter more than the product. Drop the batch.
|
// 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 {
|
} finally {
|
||||||
this.flushing = false;
|
this.flushing = false;
|
||||||
}
|
}
|
||||||
|
|||||||
446
apps/api/src/integrations/vinpin/vinpin-driver.service.ts
Normal file
446
apps/api/src/integrations/vinpin/vinpin-driver.service.ts
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
/**
|
||||||
|
* Vinpin ePER Driver — warm-session browser driver (decode-oracle only).
|
||||||
|
*
|
||||||
|
* Drives the Vinpin ePER (Fiat) web catalog to decode a VIN the normal chain
|
||||||
|
* (PL24/pcat/emex) couldn't identify. Modeled on emex.browser.ts: ONE persistent
|
||||||
|
* Chromium + context + page kept alive for the app lifetime, lazy-launched,
|
||||||
|
* auto-recovering on disconnect.
|
||||||
|
*
|
||||||
|
* Differences from emex.browser:
|
||||||
|
* - The ePER UI is a pixel CANVAS (Blast/Horizon) with no stable selectors and
|
||||||
|
* no DOM-readable text, so navigation is coordinate-driven AND every screen
|
||||||
|
* transition is verified with a VISION LOOP: screenshot → OCR → check keyword
|
||||||
|
* → retry on miss (see vinpin.ocr + VINPIN_OCR keyword sets).
|
||||||
|
* - A single Vinpin seat → a hard mutex serializes decodes (one at a time).
|
||||||
|
* - The decode-modal text is read by OCR'ing a screenshot of the modal region
|
||||||
|
* (clipboard is unreliable on the Blast canvas — proven dead end).
|
||||||
|
*
|
||||||
|
* SAFETY CONTRACT: this driver must NEVER break its caller. When VINPIN_ENABLED
|
||||||
|
* is false, creds are missing, the browser fails to launch, or anything throws,
|
||||||
|
* `decode()` returns null (logged) — it never propagates.
|
||||||
|
*
|
||||||
|
* ⚠️ RUNTIME NOTE (Docker/xvfb): the Blast canvas was only VERIFIED working
|
||||||
|
* HEADFUL (chromium under a virtual display) with software GL — so the deployed
|
||||||
|
* worker should set VINPIN_HEADFUL=true and run under `xvfb-run -a` (a 1600x900
|
||||||
|
* screen). Launch honours PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH like emex. OCR
|
||||||
|
* additionally wants `ffmpeg` on PATH for upscaling (soft dep — degrades
|
||||||
|
* gracefully) and lets tesseract.js fetch+cache eng traineddata on first use.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger, type OnModuleDestroy } from "@nestjs/common";
|
||||||
|
import type { Browser, BrowserContext, Page } from "playwright";
|
||||||
|
import {
|
||||||
|
VINPIN_COORDS,
|
||||||
|
VINPIN_MODAL_REGION,
|
||||||
|
VINPIN_OCR,
|
||||||
|
VINPIN_VIEWPORT,
|
||||||
|
VINPIN_WAITS,
|
||||||
|
} from "./vinpin.constants";
|
||||||
|
import { ocrRegion, terminateOcr } from "./vinpin.ocr";
|
||||||
|
import { type VinpinParsed, isUsableParse, parseVinpinModal } from "./vinpin.parser";
|
||||||
|
|
||||||
|
export interface VinpinDecodeResult {
|
||||||
|
model: string | null;
|
||||||
|
sincom: string | null;
|
||||||
|
trim: string | null;
|
||||||
|
modelYear: string | null;
|
||||||
|
engine: string | null;
|
||||||
|
/** Opaque raw capture (modal text + meta) for debugging/audit. */
|
||||||
|
raw: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VinpinConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
url: string;
|
||||||
|
user: string | undefined;
|
||||||
|
pass: string | undefined;
|
||||||
|
maxAttempts: number;
|
||||||
|
headful: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConfig(): VinpinConfig {
|
||||||
|
return {
|
||||||
|
enabled: process.env.VINPIN_ENABLED === "true",
|
||||||
|
url: process.env.VINPIN_URL || "https://login.tr.vinpin.online",
|
||||||
|
user: process.env.VINPIN_USER || undefined,
|
||||||
|
pass: process.env.VINPIN_PASS || undefined,
|
||||||
|
maxAttempts: Number(process.env.VINPIN_DECODE_MAX_ATTEMPTS) || 3,
|
||||||
|
headful: process.env.VINPIN_HEADFUL === "true",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VinpinDriverService implements OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(VinpinDriverService.name);
|
||||||
|
|
||||||
|
private browser: Browser | null = null;
|
||||||
|
private context: BrowserContext | null = null;
|
||||||
|
private page: Page | null = null;
|
||||||
|
private loggedIn = false;
|
||||||
|
|
||||||
|
// Single Vinpin seat → serialize all decodes through one promise chain.
|
||||||
|
private lock: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
await this.close();
|
||||||
|
await terminateOcr();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a VIN through Vinpin ePER. Returns the parsed vehicle, or null when
|
||||||
|
* disabled / not found / on any failure (never throws to the caller).
|
||||||
|
*/
|
||||||
|
async decode(vin: string): Promise<VinpinDecodeResult | null> {
|
||||||
|
const cfg = readConfig();
|
||||||
|
if (!cfg.enabled) {
|
||||||
|
this.logger.debug("decode skipped — VINPIN_ENABLED is false");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!cfg.user || !cfg.pass) {
|
||||||
|
this.logger.warn("decode skipped — VINPIN_USER/VINPIN_PASS not set");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Serialize behind the single-seat lock.
|
||||||
|
const run = this.lock.then(() => this.decodeLocked(vin, cfg));
|
||||||
|
// Keep the chain alive even if this run rejects (it shouldn't — defensive).
|
||||||
|
this.lock = run.catch(() => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Health snapshot for monitoring. */
|
||||||
|
healthCheck(): { browserConnected: boolean; loggedIn: boolean } {
|
||||||
|
return {
|
||||||
|
browserConnected: this.browser?.isConnected() ?? false,
|
||||||
|
loggedIn: this.loggedIn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async decodeLocked(vin: string, cfg: VinpinConfig): Promise<VinpinDecodeResult | null> {
|
||||||
|
for (let attempt = 1; attempt <= cfg.maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
await this.ensureReady(cfg);
|
||||||
|
const page = this.page;
|
||||||
|
if (!page) throw new Error("Vinpin page not initialized");
|
||||||
|
|
||||||
|
const text = await this.runVinFlow(page, vin);
|
||||||
|
const parsed = parseVinpinModal(text);
|
||||||
|
|
||||||
|
if (isUsableParse(parsed)) {
|
||||||
|
this.logger.log(
|
||||||
|
`decoded ${vin} → model="${parsed.model}" sincom="${parsed.sincom}" (attempt ${attempt})`,
|
||||||
|
);
|
||||||
|
return this.toResult(parsed, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.warn(
|
||||||
|
`attempt ${attempt}/${cfg.maxAttempts}: no usable decode for ${vin} (text len ${text?.length ?? 0})`,
|
||||||
|
);
|
||||||
|
// Empty/garbled — re-establish the session on the next attempt.
|
||||||
|
await this.close();
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as Error;
|
||||||
|
this.logger.warn(`attempt ${attempt}/${cfg.maxAttempts} failed for ${vin}: ${e.message}`);
|
||||||
|
// Hard failure → tear the browser down so the next attempt re-establishes.
|
||||||
|
await this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.warn(`giving up on ${vin} after ${cfg.maxAttempts} attempts → not found`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toResult(parsed: VinpinParsed, rawText: string | null): VinpinDecodeResult {
|
||||||
|
return {
|
||||||
|
model: parsed.model,
|
||||||
|
sincom: parsed.sincom,
|
||||||
|
trim: parsed.trim,
|
||||||
|
modelYear: parsed.modelYear,
|
||||||
|
engine: parsed.engine,
|
||||||
|
raw: { source: "vinpin-eper", text: rawText },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the browser+context+page are alive AND we're logged in and sitting on
|
||||||
|
* the Fiat ePER search page. (Re)establishes whatever is missing.
|
||||||
|
*/
|
||||||
|
private async ensureReady(cfg: VinpinConfig): Promise<void> {
|
||||||
|
if (!this.browser?.isConnected() || !this.context || !this.page || this.page.isClosed()) {
|
||||||
|
await this.launch(cfg);
|
||||||
|
}
|
||||||
|
if (!this.loggedIn) {
|
||||||
|
await this.establishSession(cfg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async launch(cfg: VinpinConfig): Promise<void> {
|
||||||
|
await this.close();
|
||||||
|
const { chromium } = await import("playwright");
|
||||||
|
|
||||||
|
const launchOptions: Record<string, unknown> = {
|
||||||
|
headless: !cfg.headful,
|
||||||
|
args: [
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-setuid-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--ignore-certificate-errors",
|
||||||
|
// Software GL so the Blast/Horizon canvas renders without a real GPU
|
||||||
|
// (verified working under xvfb). Without these the catalog canvas stays
|
||||||
|
// blank and OCR reads nothing.
|
||||||
|
"--use-gl=angle",
|
||||||
|
"--use-angle=swiftshader",
|
||||||
|
"--enable-unsafe-swiftshader",
|
||||||
|
`--window-size=${VINPIN_VIEWPORT.width},${VINPIN_VIEWPORT.height}`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||||
|
if (executablePath) launchOptions.executablePath = executablePath;
|
||||||
|
|
||||||
|
this.browser = await chromium.launch(launchOptions);
|
||||||
|
this.context = await this.browser.newContext({
|
||||||
|
viewport: { width: VINPIN_VIEWPORT.width, height: VINPIN_VIEWPORT.height },
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
permissions: ["clipboard-read", "clipboard-write"],
|
||||||
|
userAgent:
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
});
|
||||||
|
this.page = await this.context.newPage();
|
||||||
|
this.loggedIn = false;
|
||||||
|
|
||||||
|
this.browser.on("disconnected", () => {
|
||||||
|
this.logger.warn("Vinpin browser disconnected — will relaunch on next decode");
|
||||||
|
this.browser = null;
|
||||||
|
this.context = null;
|
||||||
|
this.page = null;
|
||||||
|
this.loggedIn = false;
|
||||||
|
});
|
||||||
|
this.logger.log(`Vinpin browser launched (headless=${!cfg.headful})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log in and navigate to the Fiat ePER search page, VISION-VERIFYING each step.
|
||||||
|
* Coordinates/waits verified live @ 1600x900.
|
||||||
|
*/
|
||||||
|
private async establishSession(cfg: VinpinConfig): Promise<void> {
|
||||||
|
const page = this.page;
|
||||||
|
if (!page) throw new Error("Vinpin page not initialized");
|
||||||
|
|
||||||
|
// a. Login (reliable DOM path).
|
||||||
|
await page.goto(cfg.url, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||||
|
await page.waitForTimeout(VINPIN_WAITS.afterGoto);
|
||||||
|
// First visible text input = username.
|
||||||
|
const userInput = page.locator('input[type="text"]:visible, input:not([type]):visible').first();
|
||||||
|
await userInput.fill(cfg.user ?? "");
|
||||||
|
await page
|
||||||
|
.locator('input[type="password"]')
|
||||||
|
.first()
|
||||||
|
.fill(cfg.pass ?? "");
|
||||||
|
const submit = page.locator('button:has-text("Oturum"), [type="submit"]').first();
|
||||||
|
await submit.click();
|
||||||
|
await page.waitForTimeout(VINPIN_WAITS.afterLogin);
|
||||||
|
|
||||||
|
// a2. Bring VinPower up. The PERMANENT (paid) seat does NOT auto-launch — it
|
||||||
|
// lands on the Horizon app launcher and needs the VINPIN app clicked. The
|
||||||
|
// TRIAL seat auto-launches straight to the brand grid. Detect & reconcile.
|
||||||
|
await this.ensureBrandGrid(page, cfg);
|
||||||
|
|
||||||
|
// b. Open Fiat ePER — canvas click is flaky, so retry + OCR-verify it opened.
|
||||||
|
let opened = false;
|
||||||
|
for (let i = 0; i < 4 && !opened; i++) {
|
||||||
|
// A deliberate mousedown/up (not a synthetic click) lands on the canvas tile.
|
||||||
|
await page.mouse.move(VINPIN_COORDS.fiatBrand.x, VINPIN_COORDS.fiatBrand.y);
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.waitForTimeout(80);
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(VINPIN_WAITS.afterFiatOpen);
|
||||||
|
const t = await ocrRegion(page);
|
||||||
|
opened = VINPIN_OCR.eperOpen.test(t);
|
||||||
|
if (!opened) this.logger.warn(`Fiat ePER open attempt ${i + 1}/4 — not yet (retrying)`);
|
||||||
|
}
|
||||||
|
if (!opened) throw new Error("Fiat ePER did not open after retries");
|
||||||
|
|
||||||
|
// c. Dismiss the spurious Russian-language dialog (click its OK button).
|
||||||
|
await page.mouse.click(VINPIN_COORDS.langDialogDismiss.x, VINPIN_COORDS.langDialogDismiss.y);
|
||||||
|
await page.waitForTimeout(VINPIN_WAITS.afterLangDismiss);
|
||||||
|
|
||||||
|
// d. Reach the Spare Parts CATALOGUE (carries the VIN identification panel).
|
||||||
|
// The seat opens either straight onto the catalogue or onto the portal
|
||||||
|
// home — detect which, and click the Spare Parts tile when on the portal.
|
||||||
|
let onCatalogue = false;
|
||||||
|
for (let i = 0; i < 4 && !onCatalogue; i++) {
|
||||||
|
const t = await ocrRegion(page);
|
||||||
|
onCatalogue = VINPIN_OCR.catalogueReady.test(t);
|
||||||
|
if (onCatalogue) break;
|
||||||
|
this.logger.warn(`not on Spare Parts catalogue (attempt ${i + 1}/4) — opening it`);
|
||||||
|
await page.mouse.click(VINPIN_COORDS.sparePartsTile.x, VINPIN_COORDS.sparePartsTile.y);
|
||||||
|
await page.waitForTimeout(VINPIN_WAITS.afterSearchOpen);
|
||||||
|
}
|
||||||
|
if (!onCatalogue) throw new Error("could not reach Spare Parts Catalogue (VIN panel)");
|
||||||
|
|
||||||
|
this.loggedIn = true;
|
||||||
|
this.logger.log("Vinpin session established (Fiat ePER Spare Parts catalogue)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring the session to the VinPower brand grid (the state the Fiat-tile flow
|
||||||
|
* expects). Backward-compatible across both seats:
|
||||||
|
* - TRIAL seat auto-launches VinPower → the grid is already up → no-op.
|
||||||
|
* - PERMANENT seat lands on the Horizon HTML-Access app launcher → click the
|
||||||
|
* "VINPIN" app to launch VinPower, satisfy its secondary login (username
|
||||||
|
* pre-filled, password blank), and wait for the grid.
|
||||||
|
* OCR-gated with a small retry budget. Never throws — if the grid can't be
|
||||||
|
* confirmed, the caller's own ePER-open retry loop still runs.
|
||||||
|
*/
|
||||||
|
private async ensureBrandGrid(page: Page, cfg: VinpinConfig): Promise<void> {
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const t = await ocrRegion(page);
|
||||||
|
if (VINPIN_OCR.brandGrid.test(t)) {
|
||||||
|
if (i > 0) this.logger.log("VinPower brand grid up");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If VinPower's own login dialog is up, satisfy it (never re-click the app
|
||||||
|
// tile mid-login — that would double-launch).
|
||||||
|
if (await this.submitVinPowerLogin(page, cfg)) {
|
||||||
|
await page.waitForTimeout(VINPIN_WAITS.afterVinPowerLogin);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// On the launcher → click the VINPIN app to launch VinPower.
|
||||||
|
if (VINPIN_OCR.launcher.test(t)) {
|
||||||
|
this.logger.log(`app-launcher detected — launching VINPIN app (try ${i + 1}/4)`);
|
||||||
|
await page.mouse.click(VINPIN_COORDS.vinpinApp.x, VINPIN_COORDS.vinpinApp.y);
|
||||||
|
await page.waitForTimeout(VINPIN_WAITS.afterVinpinLaunch);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Neither grid, login, nor launcher — a "Disconnected" dialog or a blank
|
||||||
|
// connecting screen. Dismiss any dialog and wait for the next transition.
|
||||||
|
await this.dismissDisconnected(page);
|
||||||
|
await page.waitForTimeout(VINPIN_WAITS.afterVinpinLaunch);
|
||||||
|
}
|
||||||
|
this.logger.warn("VinPower brand grid not confirmed — letting the ePER-open loop try anyway");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Satisfy VinPower's own login dialog (permanent seat, shown after launching
|
||||||
|
* the VINPIN app: username pre-filled, password blank). Returns true when it
|
||||||
|
* found & submitted the dialog. DOM path (the dialog is real HTML in the
|
||||||
|
* webclient frame) with a coordinate fallback. No dialog → false (trial seat /
|
||||||
|
* already authenticated).
|
||||||
|
*/
|
||||||
|
private async submitVinPowerLogin(page: Page, cfg: VinpinConfig): Promise<boolean> {
|
||||||
|
for (const frame of page.frames()) {
|
||||||
|
try {
|
||||||
|
const pw = frame.locator('input[type="password"]:visible').first();
|
||||||
|
if ((await pw.count()) === 0 || !(await pw.isVisible().catch(() => false))) continue;
|
||||||
|
await pw.fill(cfg.pass ?? "");
|
||||||
|
const btn = frame.locator('button:has-text("Oturum"), [type="submit"]').first();
|
||||||
|
if ((await btn.count()) > 0) {
|
||||||
|
await btn.click().catch(() => {});
|
||||||
|
} else {
|
||||||
|
await page.mouse.click(VINPIN_COORDS.vinpowerSubmit.x, VINPIN_COORDS.vinpowerSubmit.y);
|
||||||
|
}
|
||||||
|
this.logger.log("submitted VinPower login dialog");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// ignore — try the next frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dismiss the Horizon "Disconnected" dialog if present (best-effort). */
|
||||||
|
private async dismissDisconnected(page: Page): Promise<void> {
|
||||||
|
try {
|
||||||
|
const btn = page.locator('button:has-text("Close"), button:has-text("Kapat")').first();
|
||||||
|
if ((await btn.count()) > 0 && (await btn.isVisible().catch(() => false))) {
|
||||||
|
await btn.click().catch(() => {});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one VIN decode on an already-established warm session. Enters the VIN in
|
||||||
|
* the right-panel field, submits, and OCRs the decode-modal region. Returns the
|
||||||
|
* modal text, or null on a genuine "vehicle not found" (which it also clears so
|
||||||
|
* the next decode in the warm session is unaffected).
|
||||||
|
*/
|
||||||
|
private async runVinFlow(page: Page, vin: string): Promise<string | null> {
|
||||||
|
// Clear any leftover alert/modal from a previous decode in this warm session.
|
||||||
|
await page.keyboard.press("Escape").catch(() => {});
|
||||||
|
await page.waitForTimeout(VINPIN_WAITS.afterAlertDismiss);
|
||||||
|
|
||||||
|
// Focus + clear the VIN field, then type the VIN.
|
||||||
|
await page.mouse.dblclick(VINPIN_COORDS.vinField.x, VINPIN_COORDS.vinField.y);
|
||||||
|
await page.waitForTimeout(350);
|
||||||
|
await page.keyboard.press("Control+A").catch(() => {});
|
||||||
|
await page.keyboard.press("Delete").catch(() => {});
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.keyboard.type(vin, { delay: 50 });
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
// Submit (arrow) and wait for the decode modal.
|
||||||
|
await page.mouse.click(VINPIN_COORDS.vinSubmitArrow.x, VINPIN_COORDS.vinSubmitArrow.y);
|
||||||
|
await page.waitForTimeout(VINPIN_WAITS.afterVinSubmit);
|
||||||
|
|
||||||
|
const text = await this.extractModalText(page);
|
||||||
|
|
||||||
|
// Genuine not-found: ePER shows a "vehicles not found" alert over the bare
|
||||||
|
// catalogue (no model/prod-date). Dismiss it and report null cleanly.
|
||||||
|
const hasResult = Boolean(text) && VINPIN_OCR.modalHit.test(text ?? "");
|
||||||
|
if (!hasResult) {
|
||||||
|
await page.mouse
|
||||||
|
.click(VINPIN_COORDS.notFoundOk.x, VINPIN_COORDS.notFoundOk.y)
|
||||||
|
.catch(() => {});
|
||||||
|
await page.keyboard.press("Escape").catch(() => {});
|
||||||
|
await page.keyboard.press("Enter").catch(() => {});
|
||||||
|
await page.waitForTimeout(VINPIN_WAITS.afterAlertDismiss);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the decode-modal text by OCR'ing a screenshot of the modal region. The
|
||||||
|
* Blast canvas exposes no DOM text and the clipboard is unreliable, so the
|
||||||
|
* screenshot is the only dependable read channel (see vinpin.ocr).
|
||||||
|
*/
|
||||||
|
private async extractModalText(page: Page): Promise<string | null> {
|
||||||
|
const text = await ocrRegion(page, VINPIN_MODAL_REGION);
|
||||||
|
return text.length > 0 ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async close(): Promise<void> {
|
||||||
|
this.loggedIn = false;
|
||||||
|
try {
|
||||||
|
if (this.page && !this.page.isClosed()) await this.page.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (this.context) await this.context.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (this.browser) await this.browser.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
this.page = null;
|
||||||
|
this.context = null;
|
||||||
|
this.browser = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Worker singleton ────────────────────────────────────────
|
||||||
|
// The vinpin-decode BullMQ processor runs in the standalone worker (no Nest DI),
|
||||||
|
// so it grabs a process-wide singleton instead of a provider. One instance =
|
||||||
|
// one warm browser = the single Vinpin seat.
|
||||||
|
let workerSingleton: VinpinDriverService | null = null;
|
||||||
|
export function getVinpinDriver(): VinpinDriverService {
|
||||||
|
if (!workerSingleton) workerSingleton = new VinpinDriverService();
|
||||||
|
return workerSingleton;
|
||||||
|
}
|
||||||
137
apps/api/src/integrations/vinpin/vinpin.constants.ts
Normal file
137
apps/api/src/integrations/vinpin/vinpin.constants.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 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 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Screenshot clip (px) of the decode-result modal body, fed to OCR. Captures
|
||||||
|
* the model line ("6J - TIPO - EGEA (2015-2021)"), SINCOM, trim, prod-date and
|
||||||
|
* the echoed VIN. Verified live. */
|
||||||
|
export const VINPIN_MODAL_REGION = { x: 250, y: 120, width: 820, height: 480 } as const;
|
||||||
|
|
||||||
|
/** 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). */
|
||||||
|
notFound: /не\s*найден|not\s*found|Catalogue/i,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Wait budgets (ms) for each step. Verified live @ 1600x900. */
|
||||||
|
export const VINPIN_WAITS = {
|
||||||
|
afterGoto: 7_000,
|
||||||
|
afterLogin: 20_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: 700,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brands for which the Vinpin fallback is enabled. Fiat-only for now (the
|
||||||
|
* Egea/NM4356 no-catalog cluster); a Set so it's trivially extensible later.
|
||||||
|
* Compared case-insensitively against the canonical browse brand.
|
||||||
|
*/
|
||||||
|
export const VINPIN_BRAND_ALLOWLIST = new Set<string>(["fiat"]);
|
||||||
|
|
||||||
|
export function isVinpinBrandAllowed(brand: string | null | undefined): boolean {
|
||||||
|
if (!brand) return false;
|
||||||
|
return VINPIN_BRAND_ALLOWLIST.has(brand.trim().toLowerCase());
|
||||||
|
}
|
||||||
88
apps/api/src/integrations/vinpin/vinpin.matcher.spec.ts
Normal file
88
apps/api/src/integrations/vinpin/vinpin.matcher.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
148
apps/api/src/integrations/vinpin/vinpin.matcher.ts
Normal file
148
apps/api/src/integrations/vinpin/vinpin.matcher.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecodedForMatch {
|
||||||
|
model: string | null;
|
||||||
|
modelYear: 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()
|
||||||
|
.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). Among qualifiers, prefer year-range overlap,
|
||||||
|
* then the most specific (fewest extra tokens). 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; 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;
|
||||||
|
|
||||||
|
let score = 1000; // base for any qualifying candidate
|
||||||
|
const range = parseYearRange(candText);
|
||||||
|
if (decYear && Number.isFinite(decYear) && range) {
|
||||||
|
score += yearInRange(decYear, range) ? 500 : -200;
|
||||||
|
}
|
||||||
|
// Tiebreak: fewer extra tokens = more specific match.
|
||||||
|
const extra = candTokens.filter((t) => !decTokens.includes(t)).length;
|
||||||
|
score -= extra;
|
||||||
|
|
||||||
|
// Final tiebreak on equal score: prefer the newer catalog. Without a year
|
||||||
|
// signal an ambiguous model (e.g. bare "TIPO") must never fall back onto an
|
||||||
|
// ancient generation — modern is the overwhelmingly likelier intent.
|
||||||
|
const startYear = range?.start ?? 0;
|
||||||
|
if (!best || score > best.score || (score === best.score && startYear > best.startYear)) {
|
||||||
|
best = { id: cand.id, score, 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 Fiat catalog_vehicle for a decoded model + year.
|
||||||
|
* Returns the catalog_vehicle id, or null when nothing matches.
|
||||||
|
*/
|
||||||
|
async match(decoded: DecodedForMatch): Promise<string | null> {
|
||||||
|
if (!decoded.model) return null;
|
||||||
|
const rows = await this.db
|
||||||
|
.select({
|
||||||
|
id: catalogVehicles.id,
|
||||||
|
model: catalogVehicles.model,
|
||||||
|
year: catalogVehicles.year,
|
||||||
|
})
|
||||||
|
.from(catalogVehicles)
|
||||||
|
.where(
|
||||||
|
and(sql`lower(${catalogVehicles.brandName}) = 'fiat'`, eq(catalogVehicles.source, "pl24")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const id = pickBestCatalogMatch(decoded, rows as CatalogCandidate[]);
|
||||||
|
this.logger.log(
|
||||||
|
`match model="${decoded.model}" year=${decoded.modelYear ?? "?"} → ${id ?? "null"} (over ${rows.length} Fiat catalog vehicles)`,
|
||||||
|
);
|
||||||
|
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 {}
|
||||||
146
apps/api/src/integrations/vinpin/vinpin.ocr.ts
Normal file
146
apps/api/src/integrations/vinpin/vinpin.ocr.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* 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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
52
apps/api/src/integrations/vinpin/vinpin.parser.spec.ts
Normal file
52
apps/api/src/integrations/vinpin/vinpin.parser.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isUsableParse, 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
|
||||||
|
});
|
||||||
|
});
|
||||||
108
apps/api/src/integrations/vinpin/vinpin.parser.ts
Normal file
108
apps/api/src/integrations/vinpin/vinpin.parser.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* 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 } 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);
|
||||||
|
}
|
||||||
314
apps/api/src/internal-admin/api-keys.controller.ts
Normal file
314
apps/api/src/internal-admin/api-keys.controller.ts
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { desc, eq, inArray } from "drizzle-orm";
|
||||||
|
import { getApiKeyApi } from "../auth/auth";
|
||||||
|
import { Public } from "../common/decorators/public.decorator";
|
||||||
|
import { InternalTokenGuard } from "../common/guards/internal-token.guard";
|
||||||
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
|
import { apikeys, users } from "../database/schema/core";
|
||||||
|
import { PublicApiQuotaService } from "../public-api/public-api-quota.service";
|
||||||
|
|
||||||
|
// better-auth createApiKey `expiresIn`'i SANİYE bekler (ms değil).
|
||||||
|
const DAY_SECONDS = 24 * 60 * 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public API anahtarı yönetimi — kapalı beta döneminde anahtarlar self-service
|
||||||
|
* değil, buradan (Süper Panel / curl + x-internal-token) elle üretilir.
|
||||||
|
* Anahtarın düz metni YALNIZCA create cevabında döner; DB'de hash saklanır.
|
||||||
|
*/
|
||||||
|
@Controller("internal/admin/api-keys")
|
||||||
|
@Public()
|
||||||
|
@UseGuards(InternalTokenGuard)
|
||||||
|
export class ApiKeysAdminController {
|
||||||
|
constructor(
|
||||||
|
@Inject(DATABASE) private readonly db: Database,
|
||||||
|
private readonly quota: PublicApiQuotaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anahtar üret. Kullanıcı `userId` veya `email` ile seçilir (anahtar o
|
||||||
|
* kullanıcının aboneliği/marka yetkisiyle çalışır). `dailyDecodeLimit`
|
||||||
|
* anahtar metadata'sına yazılır; verilmezse env/100 varsayılanı geçerli.
|
||||||
|
*
|
||||||
|
* `publishable: true` → widget anahtarı (sase_pk_): müşteri sayfasında
|
||||||
|
* görünür, /widget shell'i yalnız bunları kabul eder ve `domains`
|
||||||
|
* listesinden frame-ancestors CSP kilidi üretir. domains zorunludur
|
||||||
|
* (boş liste = hiçbir 3. parti sitede render olmaz, sadece test).
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async create(
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
userId?: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
dailyDecodeLimit?: number;
|
||||||
|
expiresInDays?: number;
|
||||||
|
rateLimitMax?: number;
|
||||||
|
test?: boolean;
|
||||||
|
publishable?: boolean;
|
||||||
|
domains?: string[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const user = await this.resolveUser(body);
|
||||||
|
|
||||||
|
if (
|
||||||
|
body.dailyDecodeLimit !== undefined &&
|
||||||
|
(typeof body.dailyDecodeLimit !== "number" || body.dailyDecodeLimit <= 0)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException("dailyDecodeLimit pozitif bir sayı olmalı");
|
||||||
|
}
|
||||||
|
if (body.publishable && body.domains !== undefined && !Array.isArray(body.domains)) {
|
||||||
|
throw new BadRequestException('domains bir dizi olmalı (ör. ["musteri.com"])');
|
||||||
|
}
|
||||||
|
if (body.domains && !body.publishable) {
|
||||||
|
throw new BadRequestException("domains yalnız publishable anahtarlarda kullanılır");
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata: Record<string, unknown> = {};
|
||||||
|
if (body.dailyDecodeLimit) metadata.dailyDecodeLimit = Math.floor(body.dailyDecodeLimit);
|
||||||
|
if (body.publishable) {
|
||||||
|
metadata.publishable = true;
|
||||||
|
metadata.domains = (body.domains ?? []).filter((d) => typeof d === "string" && d.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await getApiKeyApi().createApiKey({
|
||||||
|
body: {
|
||||||
|
userId: user.id,
|
||||||
|
name: body.name ?? `${user.email} public-api`,
|
||||||
|
prefix: body.publishable ? "sase_pk_" : body.test ? "sase_test_" : "sase_live_",
|
||||||
|
...(body.expiresInDays ? { expiresIn: body.expiresInDays * DAY_SECONDS } : {}),
|
||||||
|
...(body.rateLimitMax
|
||||||
|
? { rateLimitEnabled: true, rateLimitTimeWindow: 60_000, rateLimitMax: body.rateLimitMax }
|
||||||
|
: {}),
|
||||||
|
...(Object.keys(metadata).length ? { metadata } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: created.id,
|
||||||
|
// Düz metin anahtar — sadece bu cevapta görünür, bir daha üretilemez.
|
||||||
|
key: created.key,
|
||||||
|
start: created.start,
|
||||||
|
user: { id: user.id, email: user.email },
|
||||||
|
name: created.name,
|
||||||
|
expiresAt: created.expiresAt,
|
||||||
|
dailyDecodeLimit: body.dailyDecodeLimit ?? null,
|
||||||
|
...(body.publishable ? { publishable: true, domains: metadata.domains } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anahtarları listele (hash asla dönmez). userId/email verilirse o
|
||||||
|
* kullanıcıya filtrelenir; verilmezse TÜM anahtarlar döner (Süper Panel
|
||||||
|
* genel görünümü). Her satırda sahibi kullanıcı da bulunur.
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
async list(@Query("userId") userId?: string, @Query("email") email?: string) {
|
||||||
|
const user = userId || email ? await this.resolveUser({ userId, email }) : null;
|
||||||
|
const rows = await this.db
|
||||||
|
.select({
|
||||||
|
id: apikeys.id,
|
||||||
|
name: apikeys.name,
|
||||||
|
start: apikeys.start,
|
||||||
|
prefix: apikeys.prefix,
|
||||||
|
enabled: apikeys.enabled,
|
||||||
|
rateLimitMax: apikeys.rateLimitMax,
|
||||||
|
requestCount: apikeys.requestCount,
|
||||||
|
lastRequest: apikeys.lastRequest,
|
||||||
|
expiresAt: apikeys.expiresAt,
|
||||||
|
createdAt: apikeys.createdAt,
|
||||||
|
metadata: apikeys.metadata,
|
||||||
|
userId: apikeys.userId,
|
||||||
|
userEmail: users.email,
|
||||||
|
})
|
||||||
|
.from(apikeys)
|
||||||
|
.innerJoin(users, eq(apikeys.userId, users.id))
|
||||||
|
.where(user ? eq(apikeys.userId, user.id) : undefined)
|
||||||
|
.orderBy(desc(apikeys.createdAt));
|
||||||
|
return {
|
||||||
|
...(user ? { user: { id: user.id, email: user.email } } : {}),
|
||||||
|
keys: rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Günlük decode-kotası anlık durumu (Redis sayaçları). `ids` virgüllü
|
||||||
|
* liste; verilmezse tüm anahtarlar. Kota günü TR gecesi döndüğü için
|
||||||
|
* `used` yalnız bugünü yansıtır — tarihçe tutulmaz.
|
||||||
|
*/
|
||||||
|
@Get("usage")
|
||||||
|
async usage(@Query("ids") ids?: string) {
|
||||||
|
const idList = (ids ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ id: apikeys.id, metadata: apikeys.metadata, enabled: apikeys.enabled })
|
||||||
|
.from(apikeys)
|
||||||
|
.where(idList.length ? inArray(apikeys.id, idList) : undefined);
|
||||||
|
const items = await Promise.all(
|
||||||
|
rows.map(async (row) => {
|
||||||
|
const limit = this.quota.resolveLimit(parseKeyMetadata(row.metadata));
|
||||||
|
const state = await this.quota.getState(row.id, limit);
|
||||||
|
return { id: row.id, enabled: row.enabled, ...state };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anahtar ayarlarını güncelle. Alan verilmezse dokunulmaz; `null`
|
||||||
|
* geçilen alan temizlenir (dailyDecodeLimit → env varsayılanına döner,
|
||||||
|
* expiresInDays → süresiz, rateLimitMax → plugin varsayılanı 120/dk).
|
||||||
|
*/
|
||||||
|
@Patch(":id")
|
||||||
|
async update(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
name?: string;
|
||||||
|
dailyDecodeLimit?: number | null;
|
||||||
|
domains?: string[];
|
||||||
|
rateLimitMax?: number | null;
|
||||||
|
expiresInDays?: number | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const [row] = await this.db.select().from(apikeys).where(eq(apikeys.id, id)).limit(1);
|
||||||
|
if (!row) throw new NotFoundException("API anahtarı bulunamadı");
|
||||||
|
|
||||||
|
const metadata = parseKeyMetadata(row.metadata);
|
||||||
|
let metadataTouched = false;
|
||||||
|
const set: Partial<typeof apikeys.$inferInsert> = { updatedAt: new Date() };
|
||||||
|
|
||||||
|
if (body.name !== undefined) {
|
||||||
|
const name = String(body.name).trim();
|
||||||
|
if (!name || name.length > 32) {
|
||||||
|
throw new BadRequestException("name 1-32 karakter olmalı");
|
||||||
|
}
|
||||||
|
set.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.dailyDecodeLimit !== undefined) {
|
||||||
|
if (body.dailyDecodeLimit === null) {
|
||||||
|
// JSON.stringify undefined alanı düşürür → env varsayılanına döner.
|
||||||
|
metadata.dailyDecodeLimit = undefined;
|
||||||
|
} else if (typeof body.dailyDecodeLimit !== "number" || body.dailyDecodeLimit <= 0) {
|
||||||
|
throw new BadRequestException("dailyDecodeLimit pozitif bir sayı veya null olmalı");
|
||||||
|
} else {
|
||||||
|
metadata.dailyDecodeLimit = Math.floor(body.dailyDecodeLimit);
|
||||||
|
}
|
||||||
|
metadataTouched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.domains !== undefined) {
|
||||||
|
if (!metadata.publishable) {
|
||||||
|
throw new BadRequestException("domains yalnız publishable anahtarlarda kullanılır");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(body.domains)) {
|
||||||
|
throw new BadRequestException('domains bir dizi olmalı (ör. ["musteri.com"])');
|
||||||
|
}
|
||||||
|
metadata.domains = body.domains.filter((d) => typeof d === "string" && d.trim());
|
||||||
|
metadataTouched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.rateLimitMax !== undefined) {
|
||||||
|
if (body.rateLimitMax === null) {
|
||||||
|
set.rateLimitMax = null;
|
||||||
|
set.rateLimitTimeWindow = null;
|
||||||
|
} else if (typeof body.rateLimitMax !== "number" || body.rateLimitMax <= 0) {
|
||||||
|
throw new BadRequestException("rateLimitMax pozitif bir sayı veya null olmalı");
|
||||||
|
} else {
|
||||||
|
set.rateLimitEnabled = true;
|
||||||
|
set.rateLimitTimeWindow = 60_000;
|
||||||
|
set.rateLimitMax = Math.floor(body.rateLimitMax);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.expiresInDays !== undefined) {
|
||||||
|
if (body.expiresInDays === null) {
|
||||||
|
set.expiresAt = null;
|
||||||
|
} else if (typeof body.expiresInDays !== "number" || body.expiresInDays <= 0) {
|
||||||
|
throw new BadRequestException("expiresInDays pozitif bir sayı veya null olmalı");
|
||||||
|
} else {
|
||||||
|
set.expiresAt = new Date(Date.now() + body.expiresInDays * DAY_SECONDS * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// better-auth şema transform'u tek JSON.stringify yazar — aynı format.
|
||||||
|
if (metadataTouched) set.metadata = JSON.stringify(metadata);
|
||||||
|
|
||||||
|
const [updated] = await this.db.update(apikeys).set(set).where(eq(apikeys.id, id)).returning({
|
||||||
|
id: apikeys.id,
|
||||||
|
name: apikeys.name,
|
||||||
|
enabled: apikeys.enabled,
|
||||||
|
rateLimitMax: apikeys.rateLimitMax,
|
||||||
|
expiresAt: apikeys.expiresAt,
|
||||||
|
metadata: apikeys.metadata,
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anahtarı devre dışı bırak (geri açmak için enable). */
|
||||||
|
@Post(":id/disable")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async disable(@Param("id") id: string) {
|
||||||
|
return this.setEnabled(id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/enable")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async enable(@Param("id") id: string) {
|
||||||
|
return this.setEnabled(id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setEnabled(id: string, enabled: boolean) {
|
||||||
|
const [row] = await this.db
|
||||||
|
.update(apikeys)
|
||||||
|
.set({ enabled, updatedAt: new Date() })
|
||||||
|
.where(eq(apikeys.id, id))
|
||||||
|
.returning({ id: apikeys.id, enabled: apikeys.enabled });
|
||||||
|
if (!row) throw new NotFoundException("API anahtarı bulunamadı");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveUser(input: { userId?: string; email?: string }) {
|
||||||
|
if (!input.userId && !input.email) {
|
||||||
|
throw new BadRequestException("userId veya email gerekli");
|
||||||
|
}
|
||||||
|
const [user] = await this.db
|
||||||
|
.select({ id: users.id, email: users.email })
|
||||||
|
.from(users)
|
||||||
|
.where(input.userId ? eq(users.id, input.userId) : eq(users.email, input.email as string))
|
||||||
|
.limit(1);
|
||||||
|
if (!user) throw new NotFoundException("Kullanıcı bulunamadı");
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** apikeys.metadata text kolonu — better-auth tek JSON.stringify ile yazar. */
|
||||||
|
function parseKeyMetadata(raw: string | null): Record<string, unknown> {
|
||||||
|
if (!raw) return {};
|
||||||
|
try {
|
||||||
|
let value: unknown = JSON.parse(raw);
|
||||||
|
// Bazı better-auth sürümleri çift stringify yapabiliyor; toleranslı ol.
|
||||||
|
if (typeof value === "string") value = JSON.parse(value);
|
||||||
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,10 +71,7 @@ export class BillingController {
|
|||||||
|
|
||||||
@Post(":id/cancel")
|
@Post(":id/cancel")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async cancel(
|
async cancel(@Param("id") id: string, @Body() body: { reason?: string; founderId?: string }) {
|
||||||
@Param("id") id: string,
|
|
||||||
@Body() body: { reason?: string; founderId?: string },
|
|
||||||
) {
|
|
||||||
this.requireFounder(body);
|
this.requireFounder(body);
|
||||||
this.requireReason(body, "cancel");
|
this.requireReason(body, "cancel");
|
||||||
return this.billing.cancel({
|
return this.billing.cancel({
|
||||||
@@ -86,10 +83,7 @@ export class BillingController {
|
|||||||
|
|
||||||
@Post(":id/resume")
|
@Post(":id/resume")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async resume(
|
async resume(@Param("id") id: string, @Body() body: { reason?: string; founderId?: string }) {
|
||||||
@Param("id") id: string,
|
|
||||||
@Body() body: { reason?: string; founderId?: string },
|
|
||||||
) {
|
|
||||||
this.requireFounder(body);
|
this.requireFounder(body);
|
||||||
this.requireReason(body, "resume");
|
this.requireReason(body, "resume");
|
||||||
return this.billing.resume({
|
return this.billing.resume({
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export class BillingService {
|
|||||||
reason: string;
|
reason: string;
|
||||||
founderId: string;
|
founderId: string;
|
||||||
}) {
|
}) {
|
||||||
if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 90) {
|
if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 3650) {
|
||||||
throw new BadRequestException("days must be 1..90");
|
throw new BadRequestException("days must be 1..3650");
|
||||||
}
|
}
|
||||||
const [sub] = await this.db
|
const [sub] = await this.db
|
||||||
.select()
|
.select()
|
||||||
@@ -126,9 +126,7 @@ export class BillingService {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
if (!sub) throw new NotFoundException("Subscription bulunamadı");
|
if (!sub) throw new NotFoundException("Subscription bulunamadı");
|
||||||
if (sub.status !== "active" && sub.status !== "trial") {
|
if (sub.status !== "active" && sub.status !== "trial") {
|
||||||
throw new ConflictException(
|
throw new ConflictException(`'${sub.status}' subscription için plan değişimi yapılamaz`);
|
||||||
`'${sub.status}' subscription için plan değişimi yapılamaz`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (sub.planId === input.newPlanId) {
|
if (sub.planId === input.newPlanId) {
|
||||||
throw new ConflictException("Subscription zaten bu planda");
|
throw new ConflictException("Subscription zaten bu planda");
|
||||||
@@ -234,16 +232,10 @@ export class BillingService {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
if (!sub) throw new NotFoundException("Subscription bulunamadı");
|
if (!sub) throw new NotFoundException("Subscription bulunamadı");
|
||||||
if (sub.status !== "active" && sub.status !== "trial") {
|
if (sub.status !== "active" && sub.status !== "trial") {
|
||||||
throw new ConflictException(
|
throw new ConflictException(`'${sub.status}' subscription için brand atama yapılamaz`);
|
||||||
`'${sub.status}' subscription için brand atama yapılamaz`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [plan] = await this.db
|
const [plan] = await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1);
|
||||||
.select()
|
|
||||||
.from(plans)
|
|
||||||
.where(eq(plans.id, sub.planId))
|
|
||||||
.limit(1);
|
|
||||||
if (!plan) throw new NotFoundException("Plan bulunamadı");
|
if (!plan) throw new NotFoundException("Plan bulunamadı");
|
||||||
|
|
||||||
// brandCount 0 = "all brands" (Full plan): don't accept a custom list;
|
// brandCount 0 = "all brands" (Full plan): don't accept a custom list;
|
||||||
@@ -282,9 +274,7 @@ export class BillingService {
|
|||||||
.from(userBrands)
|
.from(userBrands)
|
||||||
.where(eq(userBrands.subscriptionId, input.subscriptionId));
|
.where(eq(userBrands.subscriptionId, input.subscriptionId));
|
||||||
|
|
||||||
await this.db
|
await this.db.delete(userBrands).where(eq(userBrands.subscriptionId, input.subscriptionId));
|
||||||
.delete(userBrands)
|
|
||||||
.where(eq(userBrands.subscriptionId, input.subscriptionId));
|
|
||||||
|
|
||||||
if (input.brandIds.length > 0) {
|
if (input.brandIds.length > 0) {
|
||||||
await this.db.insert(userBrands).values(
|
await this.db.insert(userBrands).values(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -15,7 +16,6 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
||||||
import { Public } from "../common/decorators/public.decorator";
|
import { Public } from "../common/decorators/public.decorator";
|
||||||
import { InternalTokenGuard } from "../common/guards/internal-token.guard";
|
import { InternalTokenGuard } from "../common/guards/internal-token.guard";
|
||||||
import { ImpersonationService } from "./impersonation.service";
|
import { ImpersonationService } from "./impersonation.service";
|
||||||
@@ -66,9 +66,8 @@ export class ImpersonationController {
|
|||||||
ttlMinutes: body.ttlMinutes ?? 15,
|
ttlMinutes: body.ttlMinutes ?? 15,
|
||||||
reason,
|
reason,
|
||||||
ipAddress: this.getIp(req),
|
ipAddress: this.getIp(req),
|
||||||
userAgent: typeof req.headers["user-agent"] === "string"
|
userAgent:
|
||||||
? req.headers["user-agent"]
|
typeof req.headers["user-agent"] === "string" ? req.headers["user-agent"] : undefined,
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseUrl = this.configService.get<string>("auth.url") ?? "";
|
const baseUrl = this.configService.get<string>("auth.url") ?? "";
|
||||||
@@ -149,8 +148,7 @@ export class ImpersonationController {
|
|||||||
|
|
||||||
private consumeSecret(): string {
|
private consumeSecret(): string {
|
||||||
const secret =
|
const secret =
|
||||||
process.env.INTERNAL_IMPERSONATION_SECRET ??
|
process.env.INTERNAL_IMPERSONATION_SECRET ?? this.configService.get<string>("auth.secret");
|
||||||
this.configService.get<string>("auth.secret");
|
|
||||||
if (!secret) throw new Error("INTERNAL_IMPERSONATION_SECRET or auth.secret required");
|
if (!secret) throw new Error("INTERNAL_IMPERSONATION_SECRET or auth.secret required");
|
||||||
return secret;
|
return secret;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,10 +32,7 @@ export class ImpersonationService {
|
|||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}> {
|
}> {
|
||||||
const ttl = Math.min(
|
const ttl = Math.min(Math.max(params.ttlMinutes || DEFAULT_TTL_MINUTES, 1), MAX_TTL_MINUTES);
|
||||||
Math.max(params.ttlMinutes || DEFAULT_TTL_MINUTES, 1),
|
|
||||||
MAX_TTL_MINUTES,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify target exists (and isn't an admin — defense in depth)
|
// Verify target exists (and isn't an admin — defense in depth)
|
||||||
const [target] = await this.db
|
const [target] = await this.db
|
||||||
@@ -78,9 +75,7 @@ export class ImpersonationService {
|
|||||||
const cookieValue = encodeURIComponent(signedValue);
|
const cookieValue = encodeURIComponent(signedValue);
|
||||||
|
|
||||||
const isHttps = (this.configService.get<string>("auth.url") ?? "").startsWith("https://");
|
const isHttps = (this.configService.get<string>("auth.url") ?? "").startsWith("https://");
|
||||||
const cookieName = isHttps
|
const cookieName = isHttps ? SESSION_COOKIE_NAME_SECURE : SESSION_COOKIE_NAME_INSECURE;
|
||||||
? SESSION_COOKIE_NAME_SECURE
|
|
||||||
: SESSION_COOKIE_NAME_INSECURE;
|
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Impersonation session created: founder=${params.founderId} target=${params.targetUserId} ttl=${ttl}m reason="${params.reason.slice(0, 80)}" sessionId=${sessionId.slice(0, 8)}…`,
|
`Impersonation session created: founder=${params.founderId} target=${params.targetUserId} ttl=${ttl}m reason="${params.reason.slice(0, 80)}" sessionId=${sessionId.slice(0, 8)}…`,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { StripeModule } from "../payments/stripe/stripe.module";
|
import { StripeModule } from "../payments/stripe/stripe.module";
|
||||||
|
import { PublicApiQuotaService } from "../public-api/public-api-quota.service";
|
||||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||||
|
import { ApiKeysAdminController } from "./api-keys.controller";
|
||||||
import { BillingController } from "./billing.controller";
|
import { BillingController } from "./billing.controller";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
import { ImpersonationController } from "./impersonation.controller";
|
import { ImpersonationController } from "./impersonation.controller";
|
||||||
@@ -14,6 +16,7 @@ import { InternalVehiclesService } from "./vehicles.service";
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [SubscriptionsModule, StripeModule],
|
imports: [SubscriptionsModule, StripeModule],
|
||||||
controllers: [
|
controllers: [
|
||||||
|
ApiKeysAdminController,
|
||||||
ImpersonationController,
|
ImpersonationController,
|
||||||
LifecycleController,
|
LifecycleController,
|
||||||
BillingController,
|
BillingController,
|
||||||
@@ -25,6 +28,9 @@ import { InternalVehiclesService } from "./vehicles.service";
|
|||||||
LifecycleService,
|
LifecycleService,
|
||||||
BillingService,
|
BillingService,
|
||||||
InternalVehiclesService,
|
InternalVehiclesService,
|
||||||
|
// Redis kota sayaçlarını /internal/admin/api-keys/usage için okur
|
||||||
|
// (RedisModule @Global olduğundan bağımlılığı hazır).
|
||||||
|
PublicApiQuotaService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class InternalAdminModule {}
|
export class InternalAdminModule {}
|
||||||
|
|||||||
276
apps/api/src/jobs/__tests__/canonical-lexicon.spec.ts
Normal file
276
apps/api/src/jobs/__tests__/canonical-lexicon.spec.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { PRIORITY, classifyNode, mapToCanonical } from "../canonical-lexicon";
|
||||||
|
|
||||||
|
// Inputs are already NORMALIZE_SQL-folded (ASCII, lowercase, collapsed spaces).
|
||||||
|
|
||||||
|
describe("classifyNode", () => {
|
||||||
|
it("flags Opel model-code selector nodes as structural", () => {
|
||||||
|
expect(classifyNode("j82 ascona-c 1982 - 1988")).toBe("structural");
|
||||||
|
expect(classifyNode("c06 bls 2006 - 2009")).toBe("structural");
|
||||||
|
expect(classifyNode("b16 astra-k 2016 - 2021")).toBe("structural");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags bare year-range nodes as structural", () => {
|
||||||
|
expect(classifyNode("astra-h 2004 - 2014")).toBe("structural");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags MAN truck model descriptors as structural", () => {
|
||||||
|
expect(classifyNode("12.155 star")).toBe("structural");
|
||||||
|
expect(classifyNode("10/11.150 hocl/r")).toBe("structural");
|
||||||
|
expect(classifyNode("18t hocl-sd")).toBe("structural");
|
||||||
|
// a real category starting with digits+space (not a truck code) stays part
|
||||||
|
expect(classifyNode("4 silindirli motor")).toBe("part");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags MAN bus/coachbuilder model names as structural", () => {
|
||||||
|
expect(classifyNode("lion's city 12m")).toBe("structural");
|
||||||
|
expect(classifyNode("nl 202 muenchen")).toBe("structural");
|
||||||
|
expect(classifyNode("ng 242/272")).toBe("structural");
|
||||||
|
expect(classifyNode("man 292/332/362 h")).toBe("structural");
|
||||||
|
expect(classifyNode("n 1216 hd cityliner")).toBe("structural");
|
||||||
|
expect(classifyNode("aufbau caetano")).toBe("structural");
|
||||||
|
// "LPG" bus models (NL 205 M11 LPG) had wrongly mapped to Yakıt — now caught
|
||||||
|
expect(classifyNode("nl 205 m11 lpg")).toBe("structural");
|
||||||
|
// guard: real electrical part still maps, not swallowed by "n ?\d" etc.
|
||||||
|
expect(mapToCanonical("elektr. sistemler, gost. cih.").slug).toBe("elektrik");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags bare Mercedes catalog codes structural and index pages junk", () => {
|
||||||
|
expect(classifyNode("c442")).toBe("structural");
|
||||||
|
expect(classifyNode("c105011")).toBe("structural");
|
||||||
|
expect(classifyNode("sa listesi")).toBe("junk");
|
||||||
|
expect(classifyNode("ilk on resim aciklama sayfasi")).toBe("junk");
|
||||||
|
// descriptor codes that carry a part word must stay part (map via keyword)
|
||||||
|
expect(classifyNode("d765300(22h), ls 7 f, mb servo direksiyon")).toBe("part");
|
||||||
|
expect(classifyNode("conta takimi")).toBe("part");
|
||||||
|
expect(classifyNode("debriyaj balatasi")).toBe("part");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags empty / doc nodes as junk", () => {
|
||||||
|
expect(classifyNode("")).toBe("junk");
|
||||||
|
expect(classifyNode("ab")).toBe("junk");
|
||||||
|
expect(classifyNode("teknik literatur talimatlar")).toBe("junk");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags VIN markers and bare engine/order codes as structural", () => {
|
||||||
|
expect(classifyNode("wvwzzz9nz5-180000")).toBe("structural");
|
||||||
|
expect(classifyNode("z16dt[lfu]")).toBe("structural");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a real part that merely carries a date-validity marker", () => {
|
||||||
|
// ">>" alone must not reclassify a genuine part name
|
||||||
|
expect(classifyNode("pencere camlari d >> - 14.07.2019")).toBe("part");
|
||||||
|
expect(classifyNode("gosterge tablosu d - 30.05.2011>> - 27.05.2012")).toBe("part");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps real part-category names as part", () => {
|
||||||
|
expect(classifyNode("fren balata on")).toBe("part");
|
||||||
|
expect(classifyNode("eksantrik mili, supaplar (1,4/1,6ltr.)")).toBe("part");
|
||||||
|
expect(classifyNode("yag pompasi yag filtresi")).toBe("part");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapToCanonical", () => {
|
||||||
|
it("maps clear names to the expected bucket", () => {
|
||||||
|
expect(mapToCanonical("fren balata on").slug).toBe("fren");
|
||||||
|
expect(mapToCanonical("eksantrik mili supaplar").slug).toBe("motor");
|
||||||
|
expect(mapToCanonical("far temizleme sistemi").slug).toBe("aydinlatma");
|
||||||
|
expect(mapToCanonical("5-vitesli duz sanziman").slug).toBe("sanziman");
|
||||||
|
expect(mapToCanonical("koltuk sirtlik dolgu").slug).toBe("ic-doseme");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps expanded-lexicon terms (egzoz, karoseri, bcm)", () => {
|
||||||
|
expect(mapToCanonical("kat konvertorlu egz borusu").slug).toBe("yakit-sistemi");
|
||||||
|
expect(mapToCanonical("pikap karoserisi").slug).toBe("panjur-panel");
|
||||||
|
expect(mapToCanonical("merkezi elektrik kontrol unitesi bcm").slug).toBe("elektrik");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps short-stem inflections (yağı, mili, kardan)", () => {
|
||||||
|
expect(mapToCanonical("hps yagi").slug).toBe("yaglar-sivilar");
|
||||||
|
expect(mapToCanonical("kardan mili").slug).toBe("sanziman");
|
||||||
|
expect(mapToCanonical("araba akusu").slug).toBe("elektrik");
|
||||||
|
// regression: a strong term still wins over the newly-added weak inflection
|
||||||
|
expect(mapToCanonical("eksantrik mili supaplar").slug).toBe("motor");
|
||||||
|
expect(mapToCanonical("yag filtresi").slug).toBe("filtreler");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a root named exactly like its canonical bucket", () => {
|
||||||
|
expect(mapToCanonical("suspansiyon, sasi").slug).toBe("suspansiyon");
|
||||||
|
expect(mapToCanonical("elektrik").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("aydinlatma").slug).toBe("aydinlatma");
|
||||||
|
expect(mapToCanonical("direksiyon").slug).toBe("direksiyon");
|
||||||
|
expect(mapToCanonical("motor").slug).toBe("motor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps pcat shared-taxonomy roots", () => {
|
||||||
|
expect(mapToCanonical("jenerator").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("yaglama sistemi").slug).toBe("yaglar-sivilar");
|
||||||
|
expect(mapToCanonical("karo, arac cami").slug).toBe("panjur-panel");
|
||||||
|
expect(mapToCanonical("guc aktarim organlari").slug).toBe("sanziman");
|
||||||
|
expect(mapToCanonical("cerceve").slug).toBe("panjur-panel");
|
||||||
|
// regression: şasi stays under süspansiyon when paired with it
|
||||||
|
expect(mapToCanonical("suspansiyon, sasi").slug).toBe("suspansiyon");
|
||||||
|
expect(mapToCanonical("sasi kabugu ve plakalar").slug).toBe("panjur-panel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the new wheel + accessory buckets and the resolved ambiguous roots", () => {
|
||||||
|
expect(mapToCanonical("jantlar, lastikler").slug).toBe("jant-lastik");
|
||||||
|
expect(mapToCanonical("gobek ve tekerlekler").slug).toBe("jant-lastik");
|
||||||
|
expect(mapToCanonical("aksesuarlar ve cesitli").slug).toBe("aksesuar");
|
||||||
|
expect(mapToCanonical("hava sistemi").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("bakim parcalari").slug).toBe("filtreler");
|
||||||
|
// "hava yastığı" (airbag) must beat sogutma's weak "hava"
|
||||||
|
expect(mapToCanonical("hava yastigi surucu tarafi").slug).toBe("direksiyon");
|
||||||
|
// "hava filtresi" still filtreler; "…Yağlar Araç Bakım Ürünleri" still yakıt
|
||||||
|
expect(mapToCanonical("hava filtresi").slug).toBe("filtreler");
|
||||||
|
expect(mapToCanonical("yakitlar ve yaglar arac bakim urunleri").slug).toBe("yakit-sistemi");
|
||||||
|
// a real part with an "aksesuar" mention still wins over the catch-all
|
||||||
|
expect(mapToCanonical("fren aksesuarlari").slug).toBe("fren");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps English category names (pl24 Asian brands / untranslated leaves)", () => {
|
||||||
|
expect(mapToCanonical("engine").slug).toBe("motor");
|
||||||
|
expect(mapToCanonical("transmission").slug).toBe("sanziman");
|
||||||
|
expect(mapToCanonical("brake").slug).toBe("fren");
|
||||||
|
expect(mapToCanonical("chassis").slug).toBe("panjur-panel");
|
||||||
|
expect(mapToCanonical("axle & suspension").slug).toBe("suspansiyon");
|
||||||
|
expect(mapToCanonical("floor covering").slug).toBe("ic-doseme");
|
||||||
|
expect(mapToCanonical("body seats").slug).toBe("ic-doseme");
|
||||||
|
// system word beats the weak location word in a phrase
|
||||||
|
expect(mapToCanonical("body electrical").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("engine mechanical").slug).toBe("motor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps Opel spelling variants", () => {
|
||||||
|
expect(mapToCanonical("otomatik sanzuman").slug).toBe("sanziman");
|
||||||
|
expect(mapToCanonical("on dingil ve suspensiyon").slug).toBe("suspansiyon");
|
||||||
|
expect(mapToCanonical("disli secici kolu").slug).toBe("sanziman");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the Mercedes long-tail (English + Turkish part names)", () => {
|
||||||
|
expect(mapToCanonical("propeller shaft").slug).toBe("sanziman");
|
||||||
|
expect(mapToCanonical("radiator").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("sliding roof").slug).toBe("panjur-panel");
|
||||||
|
expect(mapToCanonical("driver's door").slug).toBe("dis-kaporta");
|
||||||
|
expect(mapToCanonical("windows").slug).toBe("dis-kaporta");
|
||||||
|
expect(mapToCanonical("timing").slug).toBe("triger-zincir-rulman");
|
||||||
|
expect(mapToCanonical("carburetor").slug).toBe("yakit-sistemi");
|
||||||
|
expect(mapToCanonical("pedal sistemi").slug).toBe("fren");
|
||||||
|
expect(mapToCanonical("arka kapak").slug).toBe("dis-kaporta");
|
||||||
|
expect(mapToCanonical("ortu").slug).toBe("ic-doseme");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps BMW electronics + lighting and Ford trans/engine variants", () => {
|
||||||
|
expect(mapToCanonical("audio, navigasyon, elektronik sistem").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("iletisim sistemi").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("distance systems, cruise control").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("isik verme").slug).toBe("aydinlatma");
|
||||||
|
expect(mapToCanonical("tvmt6 - 6 speed manual trans").slug).toBe("sanziman");
|
||||||
|
expect(mapToCanonical("t20dd0j - 2.0l diesel panther").slug).toBe("motor");
|
||||||
|
expect(mapToCanonical("govde ve boya").slug).toBe("panjur-panel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags BMW service/doc pseudo-pages as junk", () => {
|
||||||
|
expect(classifyNode("teknik dokuman")).toBe("junk");
|
||||||
|
expect(classifyNode("servis ve onarim kapsamlari")).toBe("junk");
|
||||||
|
expect(classifyNode("value parts&packages servisi ve onarim")).toBe("junk");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps small-brand terms (English + Turkish) added for max coverage", () => {
|
||||||
|
expect(mapToCanonical("diagnostic devices").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("in car entertainment").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("genel arac elektrigi").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("heater,a/c & ventilation").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("braking system").slug).toBe("fren");
|
||||||
|
expect(mapToCanonical("exterior fittings and sunroof").slug).toBe("dis-kaporta");
|
||||||
|
expect(mapToCanonical("occupant restraints").slug).toBe("ic-doseme");
|
||||||
|
expect(mapToCanonical("aracta konfor ve yasam").slug).toBe("ic-doseme");
|
||||||
|
expect(mapToCanonical("yapistiricilar / onarim kitleri").slug).toBe("yaglar-sivilar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps Kollar / Ön Takım to süspansiyon without breaking stronger matches", () => {
|
||||||
|
expect(mapToCanonical("kollar").slug).toBe("suspansiyon");
|
||||||
|
expect(mapToCanonical("on takim").slug).toBe("suspansiyon");
|
||||||
|
expect(mapToCanonical("arka takim").slug).toBe("suspansiyon");
|
||||||
|
// strong words still win over the weak axle terms
|
||||||
|
expect(mapToCanonical("conta takimi").slug).toBe("motor");
|
||||||
|
expect(mapToCanonical("vites kollari").slug).toBe("sanziman");
|
||||||
|
// "kol demiri" (armrest) must NOT become süspansiyon (we didn't add bare "kol")
|
||||||
|
expect(mapToCanonical("kol demirleri").slug).not.toBe("suspansiyon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps emex high-frequency terms", () => {
|
||||||
|
expect(mapToCanonical("stabilizer / baglanti elemanlari").slug).toBe("suspansiyon");
|
||||||
|
expect(mapToCanonical("sok absorber").slug).toBe("suspansiyon");
|
||||||
|
expect(mapToCanonical("isi esanjoru, ic isitma").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("kondansator").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("ventilator").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("uflec / parcalari").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("yer halilari ve halilar").slug).toBe("ic-doseme");
|
||||||
|
expect(mapToCanonical("dis kilitler").slug).toBe("dis-kaporta");
|
||||||
|
expect(mapToCanonical("stepne donanimlari").slug).toBe("jant-lastik");
|
||||||
|
expect(mapToCanonical("yagmurluklar").slug).toBe("dis-kaporta");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps emex low-brand terms (deep-dive additions)", () => {
|
||||||
|
expect(mapToCanonical("buharlastirici").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("kurutucu").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("aktuatorler").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("selenoid anahtar").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("anten").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("lambda kontrol").slug).toBe("yakit-sistemi");
|
||||||
|
expect(mapToCanonical("rolanti kontrolu").slug).toBe("yakit-sistemi");
|
||||||
|
expect(mapToCanonical("kanat parcalari").slug).toBe("panjur-panel");
|
||||||
|
expect(mapToCanonical("yan capraz kiris").slug).toBe("panjur-panel");
|
||||||
|
expect(mapToCanonical("burkma cubugu").slug).toBe("suspansiyon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("phrase overrides fix adjective+head mis-buckets", () => {
|
||||||
|
// "<system> yağı" = fluid, not the system
|
||||||
|
expect(mapToCanonical("motor yagi").slug).toBe("yaglar-sivilar");
|
||||||
|
expect(mapToCanonical("sanziman yagi").slug).toBe("yaglar-sivilar");
|
||||||
|
// but the PARTS keep their bucket (end-anchor excludes them)
|
||||||
|
expect(mapToCanonical("motor yagi filtresi").slug).toBe("filtreler");
|
||||||
|
expect(mapToCanonical("motor yagi pompasi").slug).toBe("motor");
|
||||||
|
// body electric motors → elektrik, not engine
|
||||||
|
expect(mapToCanonical("cam silecek motoru").slug).toBe("elektrik");
|
||||||
|
expect(mapToCanonical("cam motoru").slug).toBe("elektrik");
|
||||||
|
// timing gear → triger, not şanzıman
|
||||||
|
expect(mapToCanonical("zamanlama dislileri").slug).toBe("triger-zincir-rulman");
|
||||||
|
// engine cooling → soğutma; turbocharger → motor; drive belt → triger
|
||||||
|
expect(mapToCanonical("ic motor sogutma sistemi").slug).toBe("sogutma");
|
||||||
|
expect(mapToCanonical("emme manifoldu ve turbo sarjli").slug).toBe("motor");
|
||||||
|
expect(mapToCanonical("ic motor tahrik kayisi").slug).toBe("triger-zincir-rulman");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no keyword matches", () => {
|
||||||
|
expect(mapToCanonical("romork duzenegi cikarilabilir").slug).toBeNull();
|
||||||
|
// genuinely generic Mercedes names stay unmapped (better than wrong bucket)
|
||||||
|
expect(mapToCanonical("ayar").slug).toBeNull();
|
||||||
|
expect(mapToCanonical("genel parcalar").slug).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a strong head-noun beats a coincidental weak term (not ambiguous)", () => {
|
||||||
|
// 'filtre' (strong, filtreler) outweighs 'yag' (weak, yaglar-sivilar)
|
||||||
|
const m = mapToCanonical("yag filtresi");
|
||||||
|
expect(m.slug).toBe("filtreler");
|
||||||
|
expect(m.ambiguous).toBe(false);
|
||||||
|
// 'fren' (strong) outweighs 'hidrolik' (weak) — the old logic flagged this ambiguous
|
||||||
|
const f = mapToCanonical("fren hidrolik deposu");
|
||||||
|
expect(f.slug).toBe("fren");
|
||||||
|
expect(f.ambiguous).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a genuine two-strong tie as ambiguous", () => {
|
||||||
|
// fren(fren+balata) vs debriyaj(debriyaj+baski) — both score 6
|
||||||
|
const m = mapToCanonical("fren balata debriyaj baski");
|
||||||
|
expect(m.ambiguous).toBe(true);
|
||||||
|
expect(m.confidence).toBeLessThan(0.6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only returns slugs present in the priority list", () => {
|
||||||
|
const m = mapToCanonical("motor blok");
|
||||||
|
expect(m.slug).not.toBeNull();
|
||||||
|
expect(PRIORITY).toContain(m.slug);
|
||||||
|
});
|
||||||
|
});
|
||||||
62
apps/api/src/jobs/__tests__/canonical-sublexicon.spec.ts
Normal file
62
apps/api/src/jobs/__tests__/canonical-sublexicon.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { SUB_LEXICON, allSubLeaves, mapToSubcategory } from "../canonical-sublexicon";
|
||||||
|
|
||||||
|
describe("mapToSubcategory", () => {
|
||||||
|
it("maps Fren part names to the right sub-leaf (incl. Turkish variants)", () => {
|
||||||
|
expect(mapToSubcategory("on fren balatasi", "fren")).toBe("fren-balata");
|
||||||
|
expect(mapToSubcategory("fren diski on", "fren")).toBe("fren-disk");
|
||||||
|
expect(mapToSubcategory("fren kaliyeri", "fren")).toBe("fren-kaliper"); // kaliyer variant
|
||||||
|
expect(mapToSubcategory("fren kampanasi", "fren")).toBe("fren-kampana");
|
||||||
|
expect(mapToSubcategory("abs sensoru", "fren")).toBe("fren-abs");
|
||||||
|
expect(mapToSubcategory("fren hidroligi", "fren")).toBe("fren-hidrolik"); // hidroliği variant
|
||||||
|
expect(mapToSubcategory("el fren teli", "fren")).toBe("fren-elfren");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches assembly hardware in the donanım leaf, not a part leaf", () => {
|
||||||
|
expect(mapToSubcategory("baglanti civatasi", "fren")).toBe("fren-donanim");
|
||||||
|
expect(mapToSubcategory("klips", "fren")).toBe("fren-donanim");
|
||||||
|
// a real part keyword still wins over hardware words in the same name
|
||||||
|
expect(mapToSubcategory("fren balatasi klipsi", "fren")).toBe("fren-balata");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps across other high-value buckets", () => {
|
||||||
|
expect(mapToSubcategory("yag filtresi", "filtreler")).toBe("filtre-yag");
|
||||||
|
expect(mapToSubcategory("hava filtresi", "filtreler")).toBe("filtre-hava");
|
||||||
|
expect(mapToSubcategory("on far", "aydinlatma")).toBe("ayd-far");
|
||||||
|
expect(mapToSubcategory("stop lambasi", "aydinlatma")).toBe("ayd-stop");
|
||||||
|
expect(mapToSubcategory("on amortisor", "suspansiyon")).toBe("susp-amortisor");
|
||||||
|
expect(mapToSubcategory("silindir kapak contasi", "motor")).toBe("motor-silindirkapak");
|
||||||
|
expect(mapToSubcategory("debriyaj baski balata seti", "debriyaj")).toBe("debriyaj-balata");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps queue-shrink additions (2026-07-06)", () => {
|
||||||
|
expect(mapToSubcategory("sok absorber", "suspansiyon")).toBe("susp-amortisor");
|
||||||
|
expect(mapToSubcategory("guneslik", "ic-doseme")).toBe("ic-tavan");
|
||||||
|
expect(mapToSubcategory("gosterge paneli", "elektrik")).toBe("elektrik-modul");
|
||||||
|
expect(mapToSubcategory("anten", "elektrik")).toBe("elektrik-modul");
|
||||||
|
expect(mapToSubcategory("korna", "elektrik")).toBe("elektrik-korna");
|
||||||
|
expect(mapToSubcategory("silecek lastigi", "elektrik")).toBe("elektrik-silecek");
|
||||||
|
expect(mapToSubcategory("silecek motoru", "elektrik")).toBe("elektrik-motor"); // motor wins
|
||||||
|
expect(mapToSubcategory("havalandirma ve isitma", "sogutma")).toBe("sogutma-kalorifer");
|
||||||
|
expect(mapToSubcategory("isi esanjoru", "sogutma")).toBe("sogutma-kalorifer");
|
||||||
|
expect(mapToSubcategory("yag tavasi", "yaglar-sivilar")).toBe("yag-sistemi");
|
||||||
|
expect(mapToSubcategory("kisa dingil", "suspansiyon")).toBe("susp-aks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unknown bucket or unmatched name (→ SP manual)", () => {
|
||||||
|
expect(mapToSubcategory("balata", "aksesuar")).toBeNull(); // bucket not sub-split
|
||||||
|
expect(mapToSubcategory("belirtilmemis", "fren")).toBeNull();
|
||||||
|
expect(mapToSubcategory("", "fren")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every sub-leaf slug is globally unique and bucket-prefixed", () => {
|
||||||
|
const leaves = allSubLeaves();
|
||||||
|
const slugs = leaves.map((l) => l.slug);
|
||||||
|
expect(new Set(slugs).size).toBe(slugs.length); // no dupes
|
||||||
|
for (const l of leaves) {
|
||||||
|
// slug starts with a stem of its bucket (fren-*, motor-*, ayd-*, susp-*, …)
|
||||||
|
expect(l.slug).toMatch(/^[a-z]+-/);
|
||||||
|
}
|
||||||
|
expect(Object.keys(SUB_LEXICON).length).toBeGreaterThanOrEqual(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,7 +25,16 @@ export const QUEUE_NAMES = {
|
|||||||
SUBSCRIPTION_EXPIRY: "subscription-expiry",
|
SUBSCRIPTION_EXPIRY: "subscription-expiry",
|
||||||
QUERY_CLEANUP: "query-cleanup",
|
QUERY_CLEANUP: "query-cleanup",
|
||||||
CATALOG_PREFETCH: "catalog-prefetch",
|
CATALOG_PREFETCH: "catalog-prefetch",
|
||||||
|
// Dedicated lane for freshly-decoded vehicles: the main queue's deep-drill
|
||||||
|
// backlog runs in a production>consumption regime for hours at a time, where
|
||||||
|
// lifo tail-insertion starves ANY job pushed a moment earlier — including new
|
||||||
|
// vehicles' init chains (BMW 320i sat 58min at 0 parts). A separate queue +
|
||||||
|
// worker gives fresh decodes their own consumption budget.
|
||||||
|
CATALOG_PREFETCH_FAST: "catalog-prefetch-fast",
|
||||||
TRANSLATION: "translation",
|
TRANSLATION: "translation",
|
||||||
LIFECYCLE_EMAIL: "lifecycle-email",
|
LIFECYCLE_EMAIL: "lifecycle-email",
|
||||||
EXPERT_REWARDS: "expert-rewards",
|
EXPERT_REWARDS: "expert-rewards",
|
||||||
|
PART_PRICE_REFRESH: "part-price-refresh",
|
||||||
|
VINPIN_DECODE: "vinpin-decode",
|
||||||
|
CANONICAL_BACKFILL: "canonical-backfill",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
367
apps/api/src/jobs/canonical-lexicon.ts
Normal file
367
apps/api/src/jobs/canonical-lexicon.ts
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
// Canonical taxonomy mapping — shared logic for the canonical-backfill job.
|
||||||
|
//
|
||||||
|
// Two-stage mapping (proven on the VW+Opel dev pilot 2026-07-04):
|
||||||
|
// 1. classifyNode — is this raw OEM node an actual part-category, or a
|
||||||
|
// vehicle-selector (model/year/VIN/engine-code) node, or junk? Opel's pl24
|
||||||
|
// tree is ~87% model-code nodes ("j82 ascona-c 1982-1988"); those must NOT
|
||||||
|
// be mapped.
|
||||||
|
// 2. mapToCanonical — WEIGHTED keyword-match the surviving part-categories to
|
||||||
|
// one of the 20 canonical top buckets (migration 0024 seed). Strong
|
||||||
|
// head-nouns weigh 3, supporting terms 1; the score margin decides the
|
||||||
|
// winner and flags near-ties as ambiguous (rather than the old equal-count
|
||||||
|
// tie-break, which over-flagged).
|
||||||
|
//
|
||||||
|
// Names arrive already folded by NORMALIZE_SQL (Turkish → ASCII, lowercased,
|
||||||
|
// whitespace collapsed) so the same expression can drive the SELECT that builds
|
||||||
|
// the map and the UPDATE that materializes categories.canonical_category_id.
|
||||||
|
|
||||||
|
// Keep in exact sync with the UPDATE join in canonical-backfill.processor.ts —
|
||||||
|
// both sides must fold identically or the join misses.
|
||||||
|
export const NORMALIZE_SQL =
|
||||||
|
"btrim(regexp_replace(translate(lower(name),'çşğıöü','csgiou'),'\\s+',' ','g'))";
|
||||||
|
|
||||||
|
const STRONG_WEIGHT = 3;
|
||||||
|
const WEAK_WEIGHT = 1;
|
||||||
|
|
||||||
|
// JS equivalent of NORMALIZE_SQL, for on-the-fly serving-side mapping of
|
||||||
|
// categories whose canonical_category_id isn't materialized yet (freshly
|
||||||
|
// decoded vehicles between backfills). Must fold like the SQL side.
|
||||||
|
const TR_FOLD: Record<string, string> = {
|
||||||
|
ç: "c",
|
||||||
|
ş: "s",
|
||||||
|
ğ: "g",
|
||||||
|
ı: "i",
|
||||||
|
ö: "o",
|
||||||
|
ü: "u",
|
||||||
|
};
|
||||||
|
export function foldName(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[çşğıöü]/g, (c) => TR_FOLD[c] ?? c)
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Bucket {
|
||||||
|
strong: string[];
|
||||||
|
weak: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// slug → { strong head-nouns, weak supporting terms }. slug MUST match a
|
||||||
|
// canonical_categories.slug seeded in migration 0024. Weak terms are the ones
|
||||||
|
// that recur across buckets (yag, hidrolik, cam, panel, rot, kayis…) so they
|
||||||
|
// only tip the balance, never carry a match alone.
|
||||||
|
export const LEXICON: Record<string, Bucket> = {
|
||||||
|
// English keywords (engine/body/brake…) are mixed in per bucket: many pl24
|
||||||
|
// Asian-brand catalogs (Hyundai/Kia/Nissan/Toyota) and untranslated emex/pcat
|
||||||
|
// leaves ship English category names. System words are strong, location words
|
||||||
|
// (body/floor/axle) weak so "BODY ELECTRICAL" → elektrik, not panjur.
|
||||||
|
motor: {
|
||||||
|
// "conta" (gasket) strong: mostly engine, and beats the weak "takim" so
|
||||||
|
// "conta takımı" → motor (not süspansiyon).
|
||||||
|
strong:
|
||||||
|
"motor silindir krank piston segman biyel supap subap eksantrik karter kulbutor conta engine".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
// "diesel"/"dohc" Ford engine variants; "mekanik" (Turkish mechanical);
|
||||||
|
// "aggregate" (MAN engine/genset unit).
|
||||||
|
weak: "blok manifold yatak kompresyon bolmesi kam turbo mechanical mekanik diesel dohc aggregate".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"yakit-sistemi": {
|
||||||
|
// 'depo' is weak, not strong: "deposu" (reservoir) recurs in fren-hidrolik-
|
||||||
|
// deposu / cam-suyu-deposu etc.; "yakit" carries the fuel-tank signal.
|
||||||
|
strong:
|
||||||
|
"yakit enjektor enjeksiyon karburator carburetor egzoz egz konvertor katalitik susturucu fuel exhaust".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
weak: "benzin mazot lpg rail dagitici depo lambda rolanti".split(" "),
|
||||||
|
},
|
||||||
|
"atesleme-sistemi": {
|
||||||
|
strong: "buji bobin atesleme distributor kizdirma ignition".split(" "),
|
||||||
|
weak: [],
|
||||||
|
},
|
||||||
|
"triger-zincir-rulman": {
|
||||||
|
strong: "triger timing".split(" "),
|
||||||
|
weak: "zincir gergi kayis rulman".split(" "),
|
||||||
|
},
|
||||||
|
sogutma: {
|
||||||
|
// emex adds: esanjor (ısı eşanjörü), kondansator, ventilator, kompresor
|
||||||
|
// (AC), sogutucu; "uflec" (blower) weak.
|
||||||
|
strong:
|
||||||
|
"radyator termostat klima kalorifer intercooler evaporator buharlastirici kurutucu devirdaim fan cooling heater ventilation ventilator esanjor kondansator kompresor sogutucu".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
// "hava" weak so "Hava Sistemi" → soğutma (user decision) but "hava filtresi"
|
||||||
|
// (filtre strong) / "hava yastığı" (yastigi strong, direksiyon) still win.
|
||||||
|
weak: "sogutma isitici hortum hava heating radiator uflec".split(" "),
|
||||||
|
},
|
||||||
|
filtreler: {
|
||||||
|
strong: "filtre filter".split(" "),
|
||||||
|
// "bakım parçaları / rutin bakım" → filtreler (user decision); weak so a
|
||||||
|
// stronger term (yakıt/yağ) still wins on "…Araç Bakım Ürünleri" roots.
|
||||||
|
weak: "bakim".split(" "),
|
||||||
|
},
|
||||||
|
fren: {
|
||||||
|
strong: "fren balata kaliper kampana disk abs brake braking".split(" "),
|
||||||
|
// "pedal" weak → Mercedes "PEDAL SİSTEMİ" (pedal cluster) lands in fren.
|
||||||
|
weak: "mustur diskli pedal".split(" "),
|
||||||
|
},
|
||||||
|
debriyaj: {
|
||||||
|
strong: "debriyaj volan baski clutch".split(" "),
|
||||||
|
weak: "kavrama".split(" "),
|
||||||
|
},
|
||||||
|
sanziman: {
|
||||||
|
// "sanzuman"/"disli" are Opel spelling variants (şanzıman / dişli).
|
||||||
|
strong: "sanziman sanzuman vites diferansiyel kardan aktarim transmission gearbox".split(" "),
|
||||||
|
// 3-char stems (mil…) don't prefix-match their inflections, so list them.
|
||||||
|
// "speed"/"spd" for Ford trans variants ("6 Speed Manual Trans", "8 Spd Auto").
|
||||||
|
weak: "flans kavrama saft mil mili miller gear disli propeller tahrik speed spd".split(" "),
|
||||||
|
},
|
||||||
|
suspansiyon: {
|
||||||
|
// "suspensiyon" Opel variant; "dingil"=axle; "stabilizator/stabilizer"=anti-
|
||||||
|
// roll bar; "absorber"=(şok) absorber (emex).
|
||||||
|
strong:
|
||||||
|
"suspansiyon suspensiyon amortisor salincak rotil helezon viraj mafsal suspension stabilizator stabilizer absorber".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
// "takoz/takozu"=strut/engine mount; "kollar"=(salıncak) control arms;
|
||||||
|
// "takim"=(ön/arka) axle assembly. Weak so strong words win: "conta takımı"
|
||||||
|
// → motor, "vites kolları" → şanzıman, but standalone "Kollar"/"Ön Takım" →
|
||||||
|
// süspansiyon. NOT "kol" (singular): "kol demiri" (armrest) is ic-doseme.
|
||||||
|
weak: "rot aks dingil burc yay bilya rulman tasiyici zemberek denge axle takoz takozu kollar takim burkma".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
direksiyon: {
|
||||||
|
// "yastigi" strong so "hava yastığı" (airbag) beats the sogutma "hava" weak.
|
||||||
|
strong: "direksiyon kremayer airbag yastigi steering".split(" "),
|
||||||
|
weak: "rot hidrolik".split(" "),
|
||||||
|
},
|
||||||
|
elektrik: {
|
||||||
|
strong:
|
||||||
|
"elektrik elektr elektrigi alternator jenerator mars sarj role sigorta sensor korna bcm ecu beyin modul aku akusu enstruman gosterge electric electrical electricity audio ses navigasyon elektronik iletisim multimedya diagnostic entertainment bilgisayar aktuator selenoid anten".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
weak: "kablo sinyal silecek tesisat salter salteri verici vericisi cruise".split(" "),
|
||||||
|
},
|
||||||
|
aydinlatma: {
|
||||||
|
// far/farlar/fari — 3-char "far" only matches exactly; add inflections.
|
||||||
|
// "isik" = BMW "Işık verme" (lighting).
|
||||||
|
strong: "aydinlatma isik far farlar fari stop lamba ampul sis reflektor lighting lamp".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
weak: "sinyal".split(" "),
|
||||||
|
},
|
||||||
|
"dis-kaporta": {
|
||||||
|
strong: "ayna".split(" "),
|
||||||
|
weak: "cam camlar camlari fitil krikosu kapi kapak kapaklar mentese cikma bagaj camlik mirror glass door window sunroof exterior locking kilit yagmurluk".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
tampon: {
|
||||||
|
strong: "tampon izgara".split(" "),
|
||||||
|
weak: "grill cekme".split(" "),
|
||||||
|
},
|
||||||
|
"panjur-panel": {
|
||||||
|
strong: "panjur kaput kaporta davlumbaz marspiyel camurluk karoser karo cerceve".split(" "),
|
||||||
|
// "sasi"/"body"/"chassis" weak so system words (electrical…) win in phrases
|
||||||
|
// like "BODY ELECTRICAL"; "süspansiyon, şasi" also stays a clean süspansiyon.
|
||||||
|
weak: "panel tavan sac canak direk sasi govde kanat kiris body chassis roof wall underpan".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"ic-doseme": {
|
||||||
|
// "koltugu" = seat with possessive suffix (koltuk→koltuğu, k→ğ mutation
|
||||||
|
// breaks the prefix match, so list it explicitly).
|
||||||
|
strong:
|
||||||
|
"koltuk koltugu doseme sirtlik arkalik guneslik minder torpido konsol konfor hali seat seats trim restraint occupant".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
weak: "kaplama zemin baslik kemer emniyet panel bardaklik dayama dolgu floor covering interior ortu halilar".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"yaglar-sivilar": {
|
||||||
|
strong: "antifriz gres adblue".split(" "),
|
||||||
|
// "yag" (3 chars) misses "yagi/yaglar/yagli" under the >=4 prefix rule.
|
||||||
|
weak: "yag yagi yaglar yagli yaglama hidrolik sprey katki madeni sivi oil yapistirici".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"jant-lastik": {
|
||||||
|
strong: "jant lastik tekerlek gobek stepne wheel tyre tire".split(" "),
|
||||||
|
weak: "hub".split(" "),
|
||||||
|
},
|
||||||
|
aksesuar: {
|
||||||
|
strong: "aksesuar accessory accessories".split(" "),
|
||||||
|
weak: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deterministic tie-break for exactly-equal scores: earlier = more specific.
|
||||||
|
export const PRIORITY: string[] = [
|
||||||
|
"atesleme-sistemi",
|
||||||
|
"triger-zincir-rulman",
|
||||||
|
"filtreler",
|
||||||
|
"fren",
|
||||||
|
"debriyaj",
|
||||||
|
"sanziman",
|
||||||
|
"aydinlatma",
|
||||||
|
"ic-doseme",
|
||||||
|
"sogutma",
|
||||||
|
"yakit-sistemi",
|
||||||
|
"yaglar-sivilar",
|
||||||
|
"direksiyon",
|
||||||
|
"suspansiyon",
|
||||||
|
"jant-lastik",
|
||||||
|
"tampon",
|
||||||
|
"panjur-panel",
|
||||||
|
"dis-kaporta",
|
||||||
|
"elektrik",
|
||||||
|
"motor",
|
||||||
|
// Catch-all last: a name with any real part keyword wins over "aksesuar".
|
||||||
|
"aksesuar",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Model-code prefix: Opel j82/c06/tf0/b16. 1-3 letters + 2 digits at start.
|
||||||
|
const MODEL_CODE = /^[a-z]{1,3}\d{2}\b/;
|
||||||
|
// VW/OEM VIN prefixes (wvwzzz…, wvgzzz…) — decode markers, not categories.
|
||||||
|
const VIN_LIKE = /\b[a-z]{2,3}zzz|w[a-z]{2}\d{3,}/;
|
||||||
|
// Pure "YYYY - YYYY" range with little else = a year/model selector node.
|
||||||
|
const YEAR_RANGE_ONLY = /^\D*(19|20)\d{2}\s*-\s*(19|20)\d{2}\D*$/;
|
||||||
|
const YEAR_RANGE_ANY = /\b(19|20)\d{2}\s*-\s*(19|20)\d{2}\b/;
|
||||||
|
// Bare engine/order code like "z16dt", "z16dt[lfu]" — a single token with digits.
|
||||||
|
const CODE_TOKEN = /^[a-z]{1,3}\d{1,3}[a-z]{0,4}(\[[a-z]+\])?$/;
|
||||||
|
// Mercedes BARE catalog codes only ("C442", "C105011"). Anchored so descriptor
|
||||||
|
// nodes that also carry a part name ("D765300(22H), LS 7 F, MB SERVO DİREKSİYON"
|
||||||
|
// → direksiyon) still map via keyword instead of being wrongly dropped.
|
||||||
|
const MB_CATALOG_CODE = /^[cd]\d{3,}$/;
|
||||||
|
// MAN truck model descriptors: "12.155 STAR", "10/11.150 HOCL/R", "18T HOCL-SD".
|
||||||
|
// (Bare "14250"-style codes are NOT matched — ^\d{4,}/^\d{4,5} would hit 362 pl24
|
||||||
|
// + 2800 emex mapped rows.)
|
||||||
|
const TRUCK_MODEL_CODE = /^\d{1,2}[./]\d|^\d{2}t[ -]/i;
|
||||||
|
// MAN bus/truck model + coachbuilder names (LION'S CITY, SKYLINER, CITYLINER,
|
||||||
|
// NL 202, ND 313, NG 242, MAN 292, AUFBAU CAETANO). Vehicle selectors, not part
|
||||||
|
// categories — verified 0 emex/pcat mapped rows collide; the few pl24 hits are
|
||||||
|
// LION'S/LPG models that had wrongly matched a part keyword.
|
||||||
|
const MAN_MODEL_CODE =
|
||||||
|
/^(nd|ng|nl|ngt|man|el|gs|le|sg|sl|sr|sue|uel|rh|nm|nmt|nue|sd) \d|^(maz|mftpl)\b|^266m|lion|skyliner|starliner|cityliner|tourliner|trendliner|jetliner|centroliner|intercity|neoplan|sinotruk|doromat|aufbau|goeppel|gorica|joskin|neoman|caetano|newcar|new flyer|gelenk|maribor|slovenien/i;
|
||||||
|
const DOC_JUNK = new Set([
|
||||||
|
"teknik literatur talimatlar",
|
||||||
|
"aletler",
|
||||||
|
"kargo tahditi",
|
||||||
|
"kriko alet",
|
||||||
|
"preliminary remarks",
|
||||||
|
]);
|
||||||
|
// Catalog index / service / doc pseudo-pages — not part categories. Mercedes
|
||||||
|
// "SA LİSTESİ"/"…resim açıklama sayfası"; BMW "Teknik doküman", "Servis ve
|
||||||
|
// onarım kapsamları", "Value Parts&Packages servisi", "Parça Onarım Servisi".
|
||||||
|
const DOC_PAGE =
|
||||||
|
/resim aciklama|^sa listesi$|genel bakis tablosu|teknik dokuman|servis ve onarim|onarim servisi|onarim kapsam|value parts|servis aralik|ek onarim/;
|
||||||
|
|
||||||
|
export type NodeKind = "part" | "structural" | "junk";
|
||||||
|
|
||||||
|
function wordTokens(folded: string): string[] {
|
||||||
|
return folded.split(" ").filter((w) => w.length > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** folded = output of NORMALIZE_SQL. */
|
||||||
|
export function classifyNode(folded: string): NodeKind {
|
||||||
|
const f = folded.trim();
|
||||||
|
if (!f || f.length < 3) return "junk";
|
||||||
|
if (DOC_JUNK.has(f) || DOC_PAGE.test(f)) return "junk";
|
||||||
|
if (MB_CATALOG_CODE.test(f) || TRUCK_MODEL_CODE.test(f) || MAN_MODEL_CODE.test(f))
|
||||||
|
return "structural";
|
||||||
|
// NOTE: don't treat a bare ">>" marker as structural — real VW part names
|
||||||
|
// carry validity-date markers ("pencere camlari d >> - 14.07.2019"); VIN_LIKE
|
||||||
|
// below catches the actual VIN nodes ("wvwzzz…") without dropping real parts.
|
||||||
|
if (VIN_LIKE.test(f)) return "structural";
|
||||||
|
if (MODEL_CODE.test(f)) return "structural";
|
||||||
|
if (YEAR_RANGE_ONLY.test(f)) return "structural";
|
||||||
|
const words = wordTokens(f);
|
||||||
|
// Single bare code token (engine/order code) → selector, not a category.
|
||||||
|
if (words.length <= 1 && CODE_TOKEN.test(f)) return "structural";
|
||||||
|
// year range + very few real words → still a selector ("astra-h 2004 - 2014")
|
||||||
|
if (YEAR_RANGE_ANY.test(f) && words.length <= 4) return "structural";
|
||||||
|
return "part";
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokens(folded: string): string[] {
|
||||||
|
return folded.split(/[^a-z0-9]+/).filter((t) => t.length > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitCount(toks: string[], keywords: string[]): number {
|
||||||
|
let c = 0;
|
||||||
|
for (const t of toks) {
|
||||||
|
for (const k of keywords) {
|
||||||
|
if (t === k || (k.length >= 4 && t.startsWith(k))) {
|
||||||
|
c++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanonicalMatch {
|
||||||
|
slug: string | null;
|
||||||
|
ambiguous: boolean;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weighted keyword-map a part-category folded name to a canonical top slug.
|
||||||
|
* Score = 3·(strong hits) + 1·(weak hits) per bucket. The winner is the top
|
||||||
|
* score; a runner-up within 1 point (no strong keyword decisively separated the
|
||||||
|
* two) is flagged ambiguous. Confidence scales with the winning margin.
|
||||||
|
*/
|
||||||
|
// High-confidence PHRASE overrides, checked before the bag-of-words score.
|
||||||
|
// They fix "adjective + head-noun" cases where a strong keyword wins on the
|
||||||
|
// modifier instead of the head: "motor yağı" scores motor(3) but the head noun
|
||||||
|
// is the oil. Order doesn't matter (first match wins); keep each tight.
|
||||||
|
const OVERRIDES: [RegExp, string][] = [
|
||||||
|
// "<system> yağı/yağları" (head = oil) → fluid. END-ANCHORED so the PARTS
|
||||||
|
// "motor yağı filtresi / pompası / soğutucusu" are NOT captured (they keep
|
||||||
|
// scoring to filtre / motor / soğutma).
|
||||||
|
[/(motor|sanziman|diferansiyel|direksiyon|hidrolik|fren|torpido) yag(i|lari)$/, "yaglar-sivilar"],
|
||||||
|
// body/electric motors (window/wiper/blower), not the engine.
|
||||||
|
[/(cam|silecek|kalorifer|kaput) motor/, "elektrik"],
|
||||||
|
// timing gear/chain/belt → triger, not şanzıman via the weak "dişli".
|
||||||
|
[/zamanlama (disli|zincir|kayis|seti|gergi|kasnak)/, "triger-zincir-rulman"],
|
||||||
|
// "(IC) Motor Soğutma …" = engine COOLING; head is soğutma, "motor" is the
|
||||||
|
// subsystem qualifier (pcat's "IC Motor <system>" prefix inflates motor).
|
||||||
|
[/motor sogutma/, "sogutma"],
|
||||||
|
// "türbo şarj(lı)" = turbocharger → engine, not elektrik via "şarj" (charge).
|
||||||
|
[/turbo sarj/, "motor"],
|
||||||
|
// "(tahrik) kayışı" = drive/accessory belt → triger (belt bucket), not motor.
|
||||||
|
[/tahrik kayis/, "triger-zincir-rulman"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function mapToCanonical(folded: string): CanonicalMatch {
|
||||||
|
for (const [re, slug] of OVERRIDES) {
|
||||||
|
if (re.test(folded)) return { slug, ambiguous: false, confidence: 0.95 };
|
||||||
|
}
|
||||||
|
const toks = tokens(folded);
|
||||||
|
let bestSlug: string | null = null;
|
||||||
|
let best = 0;
|
||||||
|
let second = 0;
|
||||||
|
for (const slug of PRIORITY) {
|
||||||
|
const b = LEXICON[slug];
|
||||||
|
const score = STRONG_WEIGHT * hitCount(toks, b.strong) + WEAK_WEIGHT * hitCount(toks, b.weak);
|
||||||
|
if (score > best) {
|
||||||
|
second = best;
|
||||||
|
best = score;
|
||||||
|
bestSlug = slug;
|
||||||
|
} else if (score > second) {
|
||||||
|
second = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best === 0) return { slug: null, ambiguous: false, confidence: 0 };
|
||||||
|
const margin = best - second;
|
||||||
|
if (second === 0) return { slug: bestSlug, ambiguous: false, confidence: 0.95 };
|
||||||
|
const ambiguous = margin < 2;
|
||||||
|
const confidence = margin >= 3 ? 0.9 : margin >= 2 ? 0.8 : margin >= 1 ? 0.65 : 0.5;
|
||||||
|
return { slug: bestSlug, ambiguous, confidence };
|
||||||
|
}
|
||||||
399
apps/api/src/jobs/canonical-sublexicon.ts
Normal file
399
apps/api/src/jobs/canonical-sublexicon.ts
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
// Second-level (sub-category) mapping — one step finer than the 20 top buckets.
|
||||||
|
// Target leaves are curated from the otoparcasan.com retail tree (564 leaves)
|
||||||
|
// down to the high-signal, sellable part types a B2C API consumer browses by.
|
||||||
|
//
|
||||||
|
// Design (proven by the 5-bucket pilot 2026-07-05):
|
||||||
|
// • A source category name is first mapped to a TOP bucket (canonical-lexicon).
|
||||||
|
// • Within that bucket we run mapToSubcategory: ordered substring match, first
|
||||||
|
// hit wins (most-specific sub-leaf listed first). Turkish spelling variants
|
||||||
|
// matter — "kaliper" ships as "kaliyer", "hidrolik" as "hidroliği" — so both
|
||||||
|
// forms are listed.
|
||||||
|
// • A shared "donanım" (fasteners: klips/cıvata/braket…) sub catches assembly
|
||||||
|
// hardware that has no retail leaf; genuinely unresolved names stay null and
|
||||||
|
// are completed manually in the super-panel (SP).
|
||||||
|
//
|
||||||
|
// slug is globally unique (canonical_categories.slug unique index) → every sub
|
||||||
|
// slug is prefixed with its bucket ("fren-balata"). Names arrive folded by
|
||||||
|
// foldName (Turkish→ASCII, lowercased) exactly like the top lexicon.
|
||||||
|
|
||||||
|
import { foldName } from "./canonical-lexicon";
|
||||||
|
|
||||||
|
export interface SubLeaf {
|
||||||
|
slug: string; // globally-unique, bucket-prefixed
|
||||||
|
name: string; // display
|
||||||
|
kw: string[]; // folded substrings; first matching leaf (in order) wins
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared hardware leaf appended to every mechanical bucket. Kept LAST so a real
|
||||||
|
// part keyword always wins ("fren balatasi klipsi" → balata, not donanım).
|
||||||
|
const HW_KW =
|
||||||
|
"klips civata vida somun saplama perno rondela kelepce baglanti civatasi montaj kiti tespit civatasi".split(
|
||||||
|
" ",
|
||||||
|
);
|
||||||
|
|
||||||
|
function hw(bucket: string): SubLeaf {
|
||||||
|
return { slug: `${bucket}-donanim`, name: "Bağlantı ve Donanım", kw: HW_KW };
|
||||||
|
}
|
||||||
|
|
||||||
|
// bucket slug → ordered sub-leaves (most specific first). Only buckets we can
|
||||||
|
// meaningfully sub-split are listed; others serve at top level only.
|
||||||
|
export const SUB_LEXICON: Record<string, SubLeaf[]> = {
|
||||||
|
fren: [
|
||||||
|
{ slug: "fren-abs", name: "ABS Sensörü / Ünitesi", kw: ["abs", "esp"] },
|
||||||
|
{ slug: "fren-balata", name: "Balata", kw: ["balata", "pabuc", "disk balata", "pabuclu"] },
|
||||||
|
{ slug: "fren-disk", name: "Fren Diski / Ayna", kw: ["disk", "ayna", "rotor"] },
|
||||||
|
{
|
||||||
|
slug: "fren-kaliper",
|
||||||
|
name: "Kaliper",
|
||||||
|
kw: ["kaliper", "kaliyer", "kundak", "kaliper kundagi"],
|
||||||
|
},
|
||||||
|
{ slug: "fren-kampana", name: "Kampana", kw: ["kampana"] },
|
||||||
|
{
|
||||||
|
slug: "fren-merkez",
|
||||||
|
name: "Ana Merkez / Silindir",
|
||||||
|
kw: ["ana merkez", "merkez", "silindir", "master"],
|
||||||
|
},
|
||||||
|
{ slug: "fren-guclendirici", name: "Fren Güçlendirici (Servo)", kw: ["guclendirici", "servo"] },
|
||||||
|
{
|
||||||
|
slug: "fren-hidrolik",
|
||||||
|
name: "Hortum / Boru / Hidrolik",
|
||||||
|
kw: ["hortum", "hidrol", "boru", "borusu"],
|
||||||
|
},
|
||||||
|
{ slug: "fren-elfren", name: "El Freni", kw: ["el fren", "el-fren", "park fren"] },
|
||||||
|
{
|
||||||
|
slug: "fren-musur",
|
||||||
|
name: "Fren Müşürü / İkaz",
|
||||||
|
kw: ["musur", "ikaz", "limitor", "fren lambasi anahtari"],
|
||||||
|
},
|
||||||
|
{ slug: "fren-vakum", name: "Vakum Pompası", kw: ["vakum"] },
|
||||||
|
{ slug: "fren-pedal", name: "Fren Pedalı", kw: ["pedal"] },
|
||||||
|
hw("fren"),
|
||||||
|
],
|
||||||
|
debriyaj: [
|
||||||
|
{ slug: "debriyaj-balata", name: "Debriyaj Balatası", kw: ["balata"] },
|
||||||
|
{ slug: "debriyaj-baski", name: "Baskı", kw: ["baski"] },
|
||||||
|
{ slug: "debriyaj-set", name: "Debriyaj Seti", kw: ["set", "seti", "kit"] },
|
||||||
|
{ slug: "debriyaj-rulman", name: "Debriyaj Rulmanı", kw: ["rulman", "bilya"] },
|
||||||
|
{
|
||||||
|
slug: "debriyaj-merkez",
|
||||||
|
name: "Debriyaj Merkezi / Hidrolik",
|
||||||
|
kw: ["merkez", "silindir", "serbest birakma", "hidrol"],
|
||||||
|
},
|
||||||
|
{ slug: "debriyaj-volan", name: "Volan", kw: ["volan"] },
|
||||||
|
{ slug: "debriyaj-catal", name: "Çatal / Bilya", kw: ["catal", "catali"] },
|
||||||
|
{ slug: "debriyaj-tel", name: "Debriyaj Teli / Pedalı", kw: ["tel", "teli", "pedal", "musur"] },
|
||||||
|
hw("debriyaj"),
|
||||||
|
],
|
||||||
|
motor: [
|
||||||
|
{
|
||||||
|
slug: "motor-silindirkapak",
|
||||||
|
name: "Silindir Kapağı",
|
||||||
|
kw: ["silindir kapak", "kapak contasi", "silindir kapagi"],
|
||||||
|
},
|
||||||
|
{ slug: "motor-piston", name: "Piston / Segman / Gömlek", kw: ["piston", "segman", "gomlek"] },
|
||||||
|
{
|
||||||
|
slug: "motor-krank",
|
||||||
|
name: "Krank / Biyel",
|
||||||
|
kw: ["krank", "biyel", "kol yatak", "ana yatak"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "motor-supap",
|
||||||
|
name: "Supap / Külbütör",
|
||||||
|
kw: ["supap", "subap", "kulbutor", "eksantrik", "tapet", "gayd"],
|
||||||
|
},
|
||||||
|
{ slug: "motor-conta", name: "Conta", kw: ["conta"] },
|
||||||
|
{ slug: "motor-kece", name: "Keçe", kw: ["kece"] },
|
||||||
|
{ slug: "motor-karter", name: "Karter", kw: ["karter"] },
|
||||||
|
{ slug: "motor-yagpompa", name: "Yağ Pompası", kw: ["yag pompa", "yaglama", "yag musur"] },
|
||||||
|
{ slug: "motor-takoz", name: "Motor Takozu", kw: ["takoz"] },
|
||||||
|
{ slug: "motor-turbo", name: "Turbo", kw: ["turbo"] },
|
||||||
|
{ slug: "motor-manifold", name: "Manifold", kw: ["manifold"] },
|
||||||
|
{
|
||||||
|
slug: "motor-komple",
|
||||||
|
name: "Motor Komple / Blok / Takım",
|
||||||
|
kw: ["komple", "blok", "motor bloku", "motor takimi"],
|
||||||
|
},
|
||||||
|
hw("motor"),
|
||||||
|
],
|
||||||
|
"yakit-sistemi": [
|
||||||
|
{ slug: "yakit-enjektor", name: "Enjektör", kw: ["enjektor", "enjeksiyon"] },
|
||||||
|
{ slug: "yakit-pompa", name: "Yakıt Pompası", kw: ["pompa", "pompasi", "otomatik"] },
|
||||||
|
{ slug: "yakit-kelebek", name: "Gaz Kelebeği", kw: ["gaz kelebegi", "kelebek"] },
|
||||||
|
{ slug: "yakit-depo", name: "Yakıt Deposu / Şamandıra", kw: ["depo", "deposu", "samandira"] },
|
||||||
|
{ slug: "yakit-boru", name: "Yakıt Borusu", kw: ["boru", "borusu", "rail"] },
|
||||||
|
{ slug: "yakit-egr", name: "EGR", kw: ["egr"] },
|
||||||
|
{ slug: "yakit-karburator", name: "Karbüratör", kw: ["karburator", "karbur"] },
|
||||||
|
{
|
||||||
|
slug: "yakit-egzoz",
|
||||||
|
name: "Egzoz / Lambda",
|
||||||
|
kw: ["egzoz", "susturucu", "katalitik", "konvertor", "lambda"],
|
||||||
|
},
|
||||||
|
hw("yakit-sistemi"),
|
||||||
|
],
|
||||||
|
"atesleme-sistemi": [
|
||||||
|
{ slug: "atesleme-buji", name: "Buji", kw: ["buji", "isitma buji", "kizdirma"] },
|
||||||
|
{ slug: "atesleme-bobin", name: "Ateşleme Bobini", kw: ["bobin"] },
|
||||||
|
{ slug: "atesleme-kablo", name: "Buji Kablosu", kw: ["kablo", "kablosu"] },
|
||||||
|
{ slug: "atesleme-distributor", name: "Distribütör", kw: ["distributor", "distribu"] },
|
||||||
|
],
|
||||||
|
"triger-zincir-rulman": [
|
||||||
|
{ slug: "triger-kayis", name: "Triger Kayışı / Seti", kw: ["kayis", "kayisi", "seti"] },
|
||||||
|
{ slug: "triger-zincir", name: "Triger Zinciri", kw: ["zincir", "zinciri"] },
|
||||||
|
{ slug: "triger-gergi", name: "Gergi / Rulman", kw: ["gergi", "rulman", "bilya"] },
|
||||||
|
],
|
||||||
|
sogutma: [
|
||||||
|
{
|
||||||
|
slug: "sogutma-radyator",
|
||||||
|
name: "Radyatör / Soğutucu",
|
||||||
|
kw: ["su radyator", "radyator", "radyatoru", "sogutucu"],
|
||||||
|
},
|
||||||
|
{ slug: "sogutma-termostat", name: "Termostat", kw: ["termostat"] },
|
||||||
|
{
|
||||||
|
slug: "sogutma-supompa",
|
||||||
|
name: "Devirdaim / Su Pompası",
|
||||||
|
kw: ["su pompa", "devirdaim", "devir daim"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "sogutma-klima",
|
||||||
|
name: "Klima (Kompresör/Radyatör)",
|
||||||
|
kw: ["klima", "kompresor", "kondansator", "evaporator", "buharlastirici", "kurutucu"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "sogutma-kalorifer",
|
||||||
|
name: "Kalorifer / Havalandırma / Isıtma",
|
||||||
|
kw: ["kalorifer", "isitici", "heater", "havalandirma", "isitma", "hava kanal", "esanjor"],
|
||||||
|
},
|
||||||
|
{ slug: "sogutma-fan", name: "Fan / Pervane", kw: ["fan", "pervane", "vantilator", "uflec"] },
|
||||||
|
{ slug: "sogutma-hortum", name: "Su Hortumu", kw: ["hortum", "hortumu"] },
|
||||||
|
{ slug: "sogutma-musur", name: "Hararet / Fan Müşürü", kw: ["musur", "hararet", "sicaklik"] },
|
||||||
|
{ slug: "sogutma-intercooler", name: "Intercooler / Turbo Radyatör", kw: ["intercooler"] },
|
||||||
|
hw("sogutma"),
|
||||||
|
],
|
||||||
|
filtreler: [
|
||||||
|
{ slug: "filtre-yag", name: "Yağ Filtresi", kw: ["yag filtre", "yag filtresi"] },
|
||||||
|
{ slug: "filtre-hava", name: "Hava Filtresi", kw: ["hava filtre", "hava filtresi"] },
|
||||||
|
{
|
||||||
|
slug: "filtre-yakit",
|
||||||
|
name: "Yakıt Filtresi",
|
||||||
|
kw: ["yakit filtre", "mazot filtre", "yakit filtresi"],
|
||||||
|
},
|
||||||
|
{ slug: "filtre-polen", name: "Polen / Kabin Filtresi", kw: ["polen", "kabin", "partikul"] },
|
||||||
|
{ slug: "filtre-diger", name: "Diğer Filtre", kw: ["filtre", "filter"] },
|
||||||
|
],
|
||||||
|
suspansiyon: [
|
||||||
|
{
|
||||||
|
slug: "susp-amortisor",
|
||||||
|
name: "Amortisör",
|
||||||
|
kw: ["amortisor", "amortisor takoz", "amortisor rulman", "koruk", "absorber"],
|
||||||
|
},
|
||||||
|
{ slug: "susp-salincak", name: "Salıncak", kw: ["salincak", "kontrol kol"] },
|
||||||
|
{ slug: "susp-rotil", name: "Rotil", kw: ["rotil"] },
|
||||||
|
{ slug: "susp-rot", name: "Rot / Rot Başı", kw: ["rot basi", "rot mili", "rot"] },
|
||||||
|
{
|
||||||
|
slug: "susp-viraj",
|
||||||
|
name: "Viraj Demiri / Z Rot / Burkma",
|
||||||
|
kw: ["viraj", "stabiliz", "z rot", "denge", "burkma"],
|
||||||
|
},
|
||||||
|
{ slug: "susp-helezon", name: "Helezon Yay", kw: ["helezon", "yay"] },
|
||||||
|
{ slug: "susp-rulman", name: "Aks / Porya Rulmanı", kw: ["rulman", "porya", "bilya"] },
|
||||||
|
{ slug: "susp-aks", name: "Aks / Aktarma", kw: ["aks", "koru", "kafasi", "mafsal", "dingil"] },
|
||||||
|
{ slug: "susp-burc", name: "Burç / Takoz / Pim", kw: ["burc", "takoz", "pim"] },
|
||||||
|
{ slug: "susp-bijon", name: "Bijon", kw: ["bijon"] },
|
||||||
|
hw("suspansiyon"),
|
||||||
|
],
|
||||||
|
direksiyon: [
|
||||||
|
{ slug: "direksiyon-simit", name: "Direksiyon Simidi", kw: ["simit", "simidi"] },
|
||||||
|
{
|
||||||
|
slug: "direksiyon-pompa",
|
||||||
|
name: "Direksiyon Pompası / Hidrolik",
|
||||||
|
kw: ["pompa", "pompasi", "hidrol"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "direksiyon-kutu",
|
||||||
|
name: "Direksiyon Kutusu / Dişli / Kremayer",
|
||||||
|
kw: ["kutu", "kremayer", "disli", "dislisi"],
|
||||||
|
},
|
||||||
|
{ slug: "direksiyon-airbag", name: "Airbag", kw: ["airbag", "hava yastigi", "yastigi"] },
|
||||||
|
{ slug: "direksiyon-koruk", name: "Direksiyon Körüğü", kw: ["koruk", "korugu"] },
|
||||||
|
{
|
||||||
|
slug: "direksiyon-elektronik",
|
||||||
|
name: "Kumanda / Sensör",
|
||||||
|
kw: ["kumanda", "sensor", "sensorleri", "unite"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "direksiyon-mil",
|
||||||
|
name: "Direksiyon Mili / Boğaz / Göbek",
|
||||||
|
kw: ["mil", "kol", "rot", "bogaz", "gobek", "dingil"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sanziman: [
|
||||||
|
{
|
||||||
|
slug: "sanziman-vites",
|
||||||
|
name: "Vites / İç Kontroller",
|
||||||
|
kw: ["vites", "kol korugu", "topuz", "ic kontrol"],
|
||||||
|
},
|
||||||
|
{ slug: "sanziman-takoz", name: "Şanzıman Takozu", kw: ["takoz"] },
|
||||||
|
{ slug: "sanziman-saft", name: "Şaft / Mafsal", kw: ["saft", "mafsal", "istavroz", "kaplin"] },
|
||||||
|
{ slug: "sanziman-diferansiyel", name: "Diferansiyel", kw: ["diferansiyel", "difransiyel"] },
|
||||||
|
{
|
||||||
|
slug: "sanziman-sensor",
|
||||||
|
name: "Hız / Km Sensörü",
|
||||||
|
kw: ["hiz sensor", "kilometre", "km", "geri vites musur"],
|
||||||
|
},
|
||||||
|
{ slug: "sanziman-karter", name: "Şanzıman Karteri / Yağ", kw: ["karter", "yag"] },
|
||||||
|
hw("sanziman"),
|
||||||
|
],
|
||||||
|
elektrik: [
|
||||||
|
{ slug: "elektrik-aku", name: "Akü", kw: ["aku", "akusu"] },
|
||||||
|
{ slug: "elektrik-alternator", name: "Alternatör", kw: ["alternator", "sarj dinamo"] },
|
||||||
|
{ slug: "elektrik-mars", name: "Marş Motoru", kw: ["mars", "mars dinamo", "mars otomat"] },
|
||||||
|
{ slug: "elektrik-sensor", name: "Sensör", kw: ["sensor", "verici", "sender", "pulse"] },
|
||||||
|
{ slug: "elektrik-sigorta", name: "Sigorta / Röle", kw: ["sigorta", "role"] },
|
||||||
|
{
|
||||||
|
slug: "elektrik-motor",
|
||||||
|
name: "Cam / Silecek Motoru",
|
||||||
|
kw: ["cam motor", "silecek motor", "kalorifer motor", "cam kolu", "cam kaldirma"],
|
||||||
|
},
|
||||||
|
{ slug: "elektrik-silecek", name: "Silecek", kw: ["silecek"] },
|
||||||
|
{ slug: "elektrik-korna", name: "Korna", kw: ["korna"] },
|
||||||
|
{ slug: "elektrik-kontak", name: "Kontak / Marş Anahtarı", kw: ["kontak", "anahtar", "dugme"] },
|
||||||
|
{
|
||||||
|
slug: "elektrik-modul",
|
||||||
|
name: "Modül / Gösterge / Elektronik",
|
||||||
|
kw: ["modul", "beyin", "ecu", "kontrol unite", "kumanda", "gosterge", "anten"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "elektrik-kablo",
|
||||||
|
name: "Kablo / Tesisat",
|
||||||
|
kw: ["kablo", "tesisat", "soket", "demeti"],
|
||||||
|
},
|
||||||
|
hw("elektrik"),
|
||||||
|
],
|
||||||
|
aydinlatma: [
|
||||||
|
{ slug: "ayd-far", name: "Far", kw: ["far", "gunduz far", "xenon", "led far"] },
|
||||||
|
{ slug: "ayd-stop", name: "Stop Lambası", kw: ["stop", "arka lamba", "geri vites lamba"] },
|
||||||
|
{ slug: "ayd-sinyal", name: "Sinyal", kw: ["sinyal", "flasor"] },
|
||||||
|
{ slug: "ayd-sis", name: "Sis Farı", kw: ["sis"] },
|
||||||
|
{ slug: "ayd-plaka", name: "Plaka Lambası", kw: ["plaka"] },
|
||||||
|
{ slug: "ayd-ic", name: "İç / Tavan Lambası", kw: ["tavan", "ic aydinlatma", "harita"] },
|
||||||
|
{ slug: "ayd-ampul", name: "Ampul", kw: ["ampul"] },
|
||||||
|
],
|
||||||
|
"dis-kaporta": [
|
||||||
|
{ slug: "disk-kapi", name: "Kapı / Menteşe / Kilit", kw: ["kapi", "mentese", "kilit"] },
|
||||||
|
{ slug: "disk-ayna", name: "Dikiz Aynası", kw: ["ayna", "dikiz"] },
|
||||||
|
{ slug: "disk-cam", name: "Cam / Kriko / Fitil", kw: ["cam", "kriko", "fitil"] },
|
||||||
|
{ slug: "disk-bagaj", name: "Bagaj Kapağı", kw: ["bagaj"] },
|
||||||
|
{ slug: "disk-kaput", name: "Motor Kaputu", kw: ["kaput", "kaputu"] },
|
||||||
|
{ slug: "disk-kol", name: "Kapı Kolu", kw: ["kol", "kolu"] },
|
||||||
|
],
|
||||||
|
"panjur-panel": [
|
||||||
|
{ slug: "panjur-camurluk", name: "Çamurluk / Kanat", kw: ["camurluk", "kanat"] },
|
||||||
|
{ slug: "panjur-kaput", name: "Kaput / Kaporta", kw: ["kaput", "kaporta"] },
|
||||||
|
{ slug: "panjur-tavan", name: "Tavan", kw: ["tavan"] },
|
||||||
|
{ slug: "panjur-sasi", name: "Şasi / Kiriş / Alt Panel", kw: ["sasi", "kiris", "alt panel"] },
|
||||||
|
{ slug: "panjur-cam", name: "Cam / Karo", kw: ["cam", "karo"] },
|
||||||
|
{ slug: "panjur-panjur", name: "Panjur / Ön Panel", kw: ["panjur", "panel", "izgara"] },
|
||||||
|
{ slug: "panjur-marspiyel", name: "Marşpiyel", kw: ["marspiyel"] },
|
||||||
|
{
|
||||||
|
slug: "panjur-farcerceve",
|
||||||
|
name: "Far Çerçevesi / Sacı",
|
||||||
|
kw: ["far cerceve", "far kasi", "far saci", "far yuva"],
|
||||||
|
},
|
||||||
|
{ slug: "panjur-travers", name: "Travers / Takviye", kw: ["travers", "takviye"] },
|
||||||
|
{
|
||||||
|
slug: "panjur-etek",
|
||||||
|
name: "Etek Sacı / Muhafaza",
|
||||||
|
kw: ["etek", "muhafaza", "pacalik", "davlumbaz"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tampon: [
|
||||||
|
{ slug: "tampon-tampon", name: "Tampon", kw: ["tampon"] },
|
||||||
|
{ slug: "tampon-demir", name: "Tampon Demiri / Travers", kw: ["demir", "demiri", "travers"] },
|
||||||
|
{ slug: "tampon-izgara", name: "Izgara / Panjur", kw: ["izgara", "panjur"] },
|
||||||
|
{ slug: "tampon-braket", name: "Braket / Sac", kw: ["braket", "sac", "muhafaza"] },
|
||||||
|
{ slug: "tampon-cita", name: "Çıta / Kaplama", kw: ["cita", "kaplama", "nikelaj"] },
|
||||||
|
],
|
||||||
|
"ic-doseme": [
|
||||||
|
{ slug: "ic-koltuk", name: "Koltuk / Kılıf", kw: ["koltuk", "kilif", "minder", "sirtlik"] },
|
||||||
|
{ slug: "ic-torpido", name: "Torpido / Konsol", kw: ["torpido", "konsol"] },
|
||||||
|
{ slug: "ic-tavan", name: "Tavan Döşeme / Güneşlik", kw: ["tavan", "guneslik"] },
|
||||||
|
{ slug: "ic-kemer", name: "Emniyet Kemeri", kw: ["kemer", "emniyet"] },
|
||||||
|
{ slug: "ic-kolcak", name: "Kolçak / Bardaklık", kw: ["kolcak", "bardaklik"] },
|
||||||
|
{ slug: "ic-paspas", name: "Paspas / Halı", kw: ["paspas", "hali"] },
|
||||||
|
],
|
||||||
|
"yaglar-sivilar": [
|
||||||
|
{ slug: "yag-motor", name: "Motor Yağı", kw: ["motor yag"] },
|
||||||
|
{
|
||||||
|
slug: "yag-sanziman",
|
||||||
|
name: "Şanzıman / Dif. Yağı",
|
||||||
|
kw: ["sanziman yag", "diferansiyel yag", "torpido yag"],
|
||||||
|
},
|
||||||
|
{ slug: "yag-fren", name: "Fren Hidroliği", kw: ["fren hidrolik", "hidrolik yag"] },
|
||||||
|
{ slug: "yag-direksiyon", name: "Direksiyon Yağı", kw: ["direksiyon yag"] },
|
||||||
|
{ slug: "yag-antifriz", name: "Antifriz", kw: ["antifriz"] },
|
||||||
|
{ slug: "yag-gres", name: "Gres", kw: ["gres"] },
|
||||||
|
{ slug: "yag-adblue", name: "AdBlue", kw: ["adblue", "ad blue"] },
|
||||||
|
{ slug: "yag-katki", name: "Katkı / Sprey", kw: ["katki", "sprey", "temizleyici", "cila"] },
|
||||||
|
// oil-SYSTEM hardware that lands in this bucket via "yağ" (pump/dipstick/
|
||||||
|
// line/lubrication) — no fluid leaf fits, so its own leaf.
|
||||||
|
{
|
||||||
|
slug: "yag-sistemi",
|
||||||
|
name: "Yağlama Sistemi (Pompa/Tava/Keçe)",
|
||||||
|
kw: [
|
||||||
|
"yaglama",
|
||||||
|
"yag pompa",
|
||||||
|
"yag cubugu",
|
||||||
|
"yag boru",
|
||||||
|
"yag karter",
|
||||||
|
"yag tava",
|
||||||
|
"yag kece",
|
||||||
|
"yag basinc",
|
||||||
|
"gonderici",
|
||||||
|
"pnomatik",
|
||||||
|
"hidrolik",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"jant-lastik": [
|
||||||
|
{ slug: "jant-jant", name: "Jant", kw: ["jant"] },
|
||||||
|
// slug "jant-tire" (not "jant-lastik") — the latter collides with the top
|
||||||
|
// bucket slug and the seed's ON CONFLICT would drop it.
|
||||||
|
{ slug: "jant-tire", name: "Lastik", kw: ["lastik", "tyre", "tire"] },
|
||||||
|
{ slug: "jant-kapak", name: "Jant Kapağı / Bijon", kw: ["kapak", "bijon", "logo"] },
|
||||||
|
{ slug: "jant-sibop", name: "Sibop", kw: ["sibop"] },
|
||||||
|
{ slug: "jant-rulman", name: "Teker Rulmanı / Göbek", kw: ["rulman", "gobek", "porya", "hub"] },
|
||||||
|
{ slug: "jant-zincir", name: "Kar Zinciri", kw: ["zincir", "kar corabi"] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const NORMALIZE = (s: string) => foldName(s);
|
||||||
|
|
||||||
|
// Map a folded category name to a sub-leaf within its top bucket. Returns the
|
||||||
|
// sub-leaf slug or null (→ served at top level, completed manually in SP).
|
||||||
|
export function mapToSubcategory(rawName: string, bucketSlug: string): string | null {
|
||||||
|
const leaves = SUB_LEXICON[bucketSlug];
|
||||||
|
if (!leaves) return null;
|
||||||
|
const n = NORMALIZE(rawName);
|
||||||
|
if (!n) return null;
|
||||||
|
for (const leaf of leaves) {
|
||||||
|
for (const k of leaf.kw) {
|
||||||
|
if (n.includes(k)) return leaf.slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All sub-leaves flattened, with their parent bucket slug — used by the seed
|
||||||
|
// migration builder and the backfill materializer.
|
||||||
|
export function allSubLeaves(): Array<{
|
||||||
|
bucket: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
sort: number;
|
||||||
|
}> {
|
||||||
|
const out: Array<{ bucket: string; slug: string; name: string; sort: number }> = [];
|
||||||
|
for (const [bucket, leaves] of Object.entries(SUB_LEXICON)) {
|
||||||
|
leaves.forEach((l, i) => out.push({ bucket, slug: l.slug, name: l.name, sort: i + 1 }));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -4,17 +4,28 @@ import { CategoriesModule } from "../categories/categories.module";
|
|||||||
import { isLifecycleEmailEnabled } from "./prefetch-utils";
|
import { isLifecycleEmailEnabled } from "./prefetch-utils";
|
||||||
import { PrefetchWorkerService } from "./prefetch-worker.service";
|
import { PrefetchWorkerService } from "./prefetch-worker.service";
|
||||||
import {
|
import {
|
||||||
|
CANONICAL_BACKFILL_QUEUE,
|
||||||
|
CanonicalBackfillQueueProvider,
|
||||||
|
} from "./queues/canonical-backfill.queue";
|
||||||
|
import {
|
||||||
|
CATALOG_PREFETCH_FAST_QUEUE,
|
||||||
CATALOG_PREFETCH_QUEUE,
|
CATALOG_PREFETCH_QUEUE,
|
||||||
|
CatalogPrefetchFastQueueProvider,
|
||||||
CatalogPrefetchQueueProvider,
|
CatalogPrefetchQueueProvider,
|
||||||
} from "./queues/catalog-prefetch.queue";
|
} from "./queues/catalog-prefetch.queue";
|
||||||
import { EMEX_SCRAPE_QUEUE, EmexScrapeQueueProvider } from "./queues/emex-scrape.queue";
|
import { EMEX_SCRAPE_QUEUE, EmexScrapeQueueProvider } from "./queues/emex-scrape.queue";
|
||||||
import { EXPERT_REWARDS_QUEUE, ExpertRewardsQueueProvider } from "./queues/expert-rewards.queue";
|
import { EXPERT_REWARDS_QUEUE, ExpertRewardsQueueProvider } from "./queues/expert-rewards.queue";
|
||||||
import { LIFECYCLE_EMAIL_QUEUE, LifecycleEmailQueueProvider } from "./queues/lifecycle-email.queue";
|
import { LIFECYCLE_EMAIL_QUEUE, LifecycleEmailQueueProvider } from "./queues/lifecycle-email.queue";
|
||||||
|
import {
|
||||||
|
PART_PRICE_REFRESH_QUEUE,
|
||||||
|
PartPriceRefreshQueueProvider,
|
||||||
|
} from "./queues/part-price-refresh.queue";
|
||||||
import { QUERY_CLEANUP_QUEUE, QueryCleanupQueueProvider } from "./queues/query-cleanup.queue";
|
import { QUERY_CLEANUP_QUEUE, QueryCleanupQueueProvider } from "./queues/query-cleanup.queue";
|
||||||
import {
|
import {
|
||||||
SUBSCRIPTION_EXPIRY_QUEUE,
|
SUBSCRIPTION_EXPIRY_QUEUE,
|
||||||
SubscriptionExpiryQueueProvider,
|
SubscriptionExpiryQueueProvider,
|
||||||
} from "./queues/subscription-expiry.queue";
|
} from "./queues/subscription-expiry.queue";
|
||||||
|
import { VINPIN_DECODE_QUEUE, VinpinDecodeQueueProvider } from "./queues/vinpin-decode.queue";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CategoriesModule],
|
imports: [CategoriesModule],
|
||||||
@@ -23,8 +34,12 @@ import {
|
|||||||
SubscriptionExpiryQueueProvider,
|
SubscriptionExpiryQueueProvider,
|
||||||
QueryCleanupQueueProvider,
|
QueryCleanupQueueProvider,
|
||||||
CatalogPrefetchQueueProvider,
|
CatalogPrefetchQueueProvider,
|
||||||
|
CatalogPrefetchFastQueueProvider,
|
||||||
LifecycleEmailQueueProvider,
|
LifecycleEmailQueueProvider,
|
||||||
ExpertRewardsQueueProvider,
|
ExpertRewardsQueueProvider,
|
||||||
|
PartPriceRefreshQueueProvider,
|
||||||
|
VinpinDecodeQueueProvider,
|
||||||
|
CanonicalBackfillQueueProvider,
|
||||||
PrefetchWorkerService,
|
PrefetchWorkerService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
@@ -32,8 +47,12 @@ import {
|
|||||||
SUBSCRIPTION_EXPIRY_QUEUE,
|
SUBSCRIPTION_EXPIRY_QUEUE,
|
||||||
QUERY_CLEANUP_QUEUE,
|
QUERY_CLEANUP_QUEUE,
|
||||||
CATALOG_PREFETCH_QUEUE,
|
CATALOG_PREFETCH_QUEUE,
|
||||||
|
CATALOG_PREFETCH_FAST_QUEUE,
|
||||||
LIFECYCLE_EMAIL_QUEUE,
|
LIFECYCLE_EMAIL_QUEUE,
|
||||||
EXPERT_REWARDS_QUEUE,
|
EXPERT_REWARDS_QUEUE,
|
||||||
|
PART_PRICE_REFRESH_QUEUE,
|
||||||
|
VINPIN_DECODE_QUEUE,
|
||||||
|
CANONICAL_BACKFILL_QUEUE,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
||||||
@@ -43,6 +62,8 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
|||||||
@Inject(CATALOG_PREFETCH_QUEUE) private catalogPrefetchQueue: Queue,
|
@Inject(CATALOG_PREFETCH_QUEUE) private catalogPrefetchQueue: Queue,
|
||||||
@Inject(LIFECYCLE_EMAIL_QUEUE) private lifecycleEmailQueue: Queue,
|
@Inject(LIFECYCLE_EMAIL_QUEUE) private lifecycleEmailQueue: Queue,
|
||||||
@Inject(EXPERT_REWARDS_QUEUE) private expertRewardsQueue: Queue,
|
@Inject(EXPERT_REWARDS_QUEUE) private expertRewardsQueue: Queue,
|
||||||
|
@Inject(PART_PRICE_REFRESH_QUEUE) private partPriceRefreshQueue: Queue,
|
||||||
|
@Inject(CANONICAL_BACKFILL_QUEUE) private canonicalBackfillQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
@@ -133,6 +154,50 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
console.log("[jobs] Registered expert-rewards cron: 0 0 1 * * (Europe/Istanbul)");
|
console.log("[jobs] Registered expert-rewards cron: 0 0 1 * * (Europe/Istanbul)");
|
||||||
|
|
||||||
|
// Tedarikçi fiyat tazeleme: her gün 19:30 İstanbul — takip MySQL'inin
|
||||||
|
// 18:00 CEST (19:00 İstanbul) sync'i ~5 dk'da bitiyor; sonrasında izlenen
|
||||||
|
// parça kodlarına bugünün p50/p95/p99 satırı yazılır. Dev'de de çalışır:
|
||||||
|
// pg yazımları kendi DB'sine, MySQL tarafı idempotent INSERT IGNORE.
|
||||||
|
// SUPPLIER_PRICE_DB_* env'i yoksa processor sessiz no-op.
|
||||||
|
await this.partPriceRefreshQueue.upsertJobScheduler(
|
||||||
|
"part-price-refresh-daily",
|
||||||
|
{ pattern: "30 19 * * *", tz: "Europe/Istanbul" },
|
||||||
|
{
|
||||||
|
name: "part-price-refresh-run",
|
||||||
|
data: {},
|
||||||
|
opts: {
|
||||||
|
removeOnComplete: { count: 30 },
|
||||||
|
removeOnFail: { count: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
console.log("[jobs] Registered part-price-refresh cron: 30 19 * * * (Europe/Istanbul)");
|
||||||
|
|
||||||
|
// Kanonik taksonomi tazeleme: her gün 05:00 İstanbul (gecelik trafik
|
||||||
|
// sonrası, decode edilen yeni araçların kategorileri materyalize edilsin).
|
||||||
|
// incremental=true → yalnız o günün YENİ kategori adları sınıflandırılır
|
||||||
|
// (tüm ~113k değil); materialize adımı differential, inheritance NULL-only,
|
||||||
|
// is_manual satırları korunur. Serving zaten anlık lexicon fallback yapıyor,
|
||||||
|
// bu cron materyalizasyonu güncel tutar (istek-başı hesap yükünü ve DB
|
||||||
|
// doğruluğunu iyileştirir). Dev + prod'da güvenle çalışır (harici yan-etki
|
||||||
|
// yok, kendi DB'sine yazar). Lexicon iyileştirmesi sonrası tam yeniden
|
||||||
|
// eşleme hâlâ ELLE `{ reset: true }` global run'la yapılır.
|
||||||
|
await this.canonicalBackfillQueue.upsertJobScheduler(
|
||||||
|
"canonical-backfill-daily",
|
||||||
|
{ pattern: "0 5 * * *", tz: "Europe/Istanbul" },
|
||||||
|
{
|
||||||
|
name: "canonical-backfill-run",
|
||||||
|
data: { incremental: true },
|
||||||
|
opts: {
|
||||||
|
removeOnComplete: { count: 14 },
|
||||||
|
removeOnFail: { count: 50 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"[jobs] Registered canonical-backfill cron: 0 5 * * * (Europe/Istanbul, incremental)",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
@@ -142,6 +207,8 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.catalogPrefetchQueue.close(),
|
this.catalogPrefetchQueue.close(),
|
||||||
this.lifecycleEmailQueue.close(),
|
this.lifecycleEmailQueue.close(),
|
||||||
this.expertRewardsQueue.close(),
|
this.expertRewardsQueue.close(),
|
||||||
|
this.partPriceRefreshQueue.close(),
|
||||||
|
this.canonicalBackfillQueue.close(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function isLifecycleEmailEnabled(): boolean {
|
|||||||
export class RateLimitError extends Error {
|
export class RateLimitError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly retryAfterMs: number,
|
public readonly retryAfterMs: number,
|
||||||
public readonly cause: "cooldown" | "time-window",
|
public readonly cause: "cooldown" | "time-window" | "source-rate",
|
||||||
) {
|
) {
|
||||||
super(`Rate limited (${cause}) — retry after ${retryAfterMs}ms`);
|
super(`Rate limited (${cause}) — retry after ${retryAfterMs}ms`);
|
||||||
this.name = "RateLimitError";
|
this.name = "RateLimitError";
|
||||||
@@ -86,21 +86,21 @@ function currentIstanbulHour(): number {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether `source` may be scraped right now.
|
* Whether `source` may be scraped right now.
|
||||||
* PL24 → 09:00–18:00 (Europe/Istanbul); upstream rate-limit is still
|
*
|
||||||
* tighter outside that window so user-facing requests benefit from the
|
* Time windows are DISABLED by default — PL24 (and everything else) may be
|
||||||
* guard. EMEX, parts-catalogs, and everything else have no window
|
* scraped 24/7. The window is still env-tunable: set PREFETCH_PL24_START /
|
||||||
* (always true). parts-catalogs used to be 09:00–19:00 too, but the
|
* PREFETCH_PL24_END (Europe/Istanbul hours) to re-narrow it (e.g.
|
||||||
* Redis-persisted warm JWT pool (commits aa4d055 + dcf7e06) now keeps
|
* PREFETCH_PL24_START=9 PREFETCH_PL24_END=18). EMEX and parts-catalogs never
|
||||||
* captures alive 24/7 so backfill can sweep through the night when the
|
* had a window — their Redis-persisted warm JWT pool keeps captures alive
|
||||||
* upstream is most idle.
|
* through the night.
|
||||||
*/
|
*/
|
||||||
/** PL24 scrape window (Europe/Istanbul hours), env-tunable so it can be widened
|
const PL24_WINDOW_START = Number(process.env.PREFETCH_PL24_START) || 0;
|
||||||
* without a redeploy (e.g. PREFETCH_PL24_START=8 PREFETCH_PL24_END=22). */
|
const PL24_WINDOW_END = Number(process.env.PREFETCH_PL24_END) || 24;
|
||||||
const PL24_WINDOW_START = Number(process.env.PREFETCH_PL24_START) || 9;
|
|
||||||
const PL24_WINDOW_END = Number(process.env.PREFETCH_PL24_END) || 18;
|
|
||||||
|
|
||||||
export function isWithinTimeWindow(source: string): boolean {
|
export function isWithinTimeWindow(source: string): boolean {
|
||||||
if (source !== "pl24") return true;
|
if (source !== "pl24") return true;
|
||||||
|
// Default (0–24) means the window is disabled → PL24 flows 24/7.
|
||||||
|
if (PL24_WINDOW_START <= 0 && PL24_WINDOW_END >= 24) return true;
|
||||||
const h = currentIstanbulHour();
|
const h = currentIstanbulHour();
|
||||||
return h >= PL24_WINDOW_START && h < PL24_WINDOW_END;
|
return h >= PL24_WINDOW_START && h < PL24_WINDOW_END;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,23 +24,32 @@ function makeDeps(opts: { waiting: number; limitResults: unknown[][] }) {
|
|||||||
getJobCounts: vi.fn(async () => ({ waiting: opts.waiting, delayed: 0, active: 0 })),
|
getJobCounts: vi.fn(async () => ({ waiting: opts.waiting, delayed: 0, active: 0 })),
|
||||||
};
|
};
|
||||||
const redis = {
|
const redis = {
|
||||||
exists: vi.fn(async () => false), // no source cooldown, no in-flight guard
|
// typed args (like queue.add) so mockImplementation((k)=>…) type-checks under
|
||||||
|
// the production nest build, which compiles spec files.
|
||||||
|
exists: vi.fn(async (..._args: unknown[]) => false), // no cooldown / guard / complete marker
|
||||||
get: vi.fn(async () => null), // no no-result residue
|
get: vi.fn(async () => null), // no no-result residue
|
||||||
set: vi.fn(async () => undefined),
|
set: vi.fn(async (..._args: unknown[]) => undefined),
|
||||||
|
del: vi.fn(async (..._args: unknown[]) => undefined),
|
||||||
|
incr: vi.fn(async (..._args: unknown[]) => 1), // per-source rate window counter
|
||||||
|
expire: vi.fn(async () => undefined),
|
||||||
|
ttl: vi.fn(async () => -2), // checkCooldown: no cooldown key
|
||||||
setNx: vi.fn(async () => true), // scan lock acquired by default
|
setNx: vi.fn(async () => true), // scan lock acquired by default
|
||||||
getJson: vi.fn(async () => null), // Phase-2 cursor empty
|
getJson: vi.fn(async (..._args: unknown[]): Promise<unknown> => null), // cursor / progress empty
|
||||||
setJson: vi.fn(async () => undefined),
|
setJson: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
const posthog = { payload: vi.fn(async () => ({})) }; // compiled-in defaults
|
const posthog = { payload: vi.fn(async () => ({})) }; // compiled-in defaults
|
||||||
const db = makeDb(opts.limitResults);
|
const db = makeDb(opts.limitResults);
|
||||||
|
const fastQueue = { add: vi.fn(async (..._args: unknown[]) => ({ id: "fastjob" })) };
|
||||||
const service = new PrefetchWorkerService(
|
const service = new PrefetchWorkerService(
|
||||||
queue as never,
|
queue as never,
|
||||||
|
fastQueue as never,
|
||||||
{} as never, // categoriesService — unused by the scan
|
{} as never, // categoriesService — unused by the scan
|
||||||
redis as never,
|
redis as never,
|
||||||
posthog as never,
|
posthog as never,
|
||||||
db as never,
|
db as never,
|
||||||
);
|
);
|
||||||
return { service, queue, redis, posthog, db };
|
|
||||||
|
return { service, queue, fastQueue, redis, posthog, db };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
|
describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
|
||||||
@@ -53,12 +62,12 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("enqueueInit", () => {
|
describe("enqueueInit", () => {
|
||||||
it("sets lifo + data.fast when fast", async () => {
|
it("sets lifo + data.fast when fast (fast queue)", async () => {
|
||||||
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
|
const { service, fastQueue } = makeDeps({ waiting: 0, limitResults: [] });
|
||||||
await (
|
await (
|
||||||
service as never as { enqueueInit: (a: string, b: string, c: boolean) => Promise<void> }
|
service as never as { enqueueInit: (a: string, b: string, c: boolean) => Promise<void> }
|
||||||
).enqueueInit("v1", "emex", true);
|
).enqueueInit("v1", "emex", true);
|
||||||
const [name, data, jobOpts] = queue.add.mock.calls[0];
|
const [name, data, jobOpts] = fastQueue.add.mock.calls[0];
|
||||||
expect(name).toBe("prefetch-init");
|
expect(name).toBe("prefetch-init");
|
||||||
expect(data).toMatchObject({ vehicleId: "v1", source: "emex", fast: true });
|
expect(data).toMatchObject({ vehicleId: "v1", source: "emex", fast: true });
|
||||||
expect(jobOpts).toMatchObject({ lifo: true });
|
expect(jobOpts).toMatchObject({ lifo: true });
|
||||||
@@ -76,8 +85,8 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("addJob (child jobs)", () => {
|
describe("addJob (child jobs)", () => {
|
||||||
it("sets lifo when data.fast is true", async () => {
|
it("sets lifo when data.fast is true (fast queue)", async () => {
|
||||||
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
|
const { service, fastQueue: queue } = makeDeps({ waiting: 0, limitResults: [] });
|
||||||
await (service as never as { addJob: (n: string, d: unknown) => Promise<void> }).addJob(
|
await (service as never as { addJob: (n: string, d: unknown) => Promise<void> }).addJob(
|
||||||
"prefetch-children",
|
"prefetch-children",
|
||||||
{
|
{
|
||||||
@@ -108,7 +117,11 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
|
|||||||
it("runs Phase-1 in the fast lane even when the backlog is far over the ceiling", async () => {
|
it("runs Phase-1 in the fast lane even when the backlog is far over the ceiling", async () => {
|
||||||
// waiting 50k ≫ default maxBacklog 1000 → Phase-2 must be suspended, but a
|
// waiting 50k ≫ default maxBacklog 1000 → Phase-2 must be suspended, but a
|
||||||
// zero-parts vehicle must still be onboarded with lifo.
|
// zero-parts vehicle must still be onboarded with lifo.
|
||||||
const { service, queue, redis } = makeDeps({
|
const {
|
||||||
|
service,
|
||||||
|
fastQueue: queue,
|
||||||
|
redis,
|
||||||
|
} = makeDeps({
|
||||||
waiting: 50_000,
|
waiting: 50_000,
|
||||||
limitResults: [[{ id: "empty1", source: "emex" }]], // Phase-1 query result
|
limitResults: [[{ id: "empty1", source: "emex" }]], // Phase-1 query result
|
||||||
});
|
});
|
||||||
@@ -147,6 +160,24 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
|
|||||||
expect(redis.setJson).toHaveBeenCalled();
|
expect(redis.setJson).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips a vehicle already marked complete (Phase-2 does not re-walk it)", async () => {
|
||||||
|
const { service, queue, redis } = makeDeps({
|
||||||
|
waiting: 10, // under ceiling → Phase-2 active
|
||||||
|
limitResults: [
|
||||||
|
[], // Phase-1: no zero-parts vehicles
|
||||||
|
[{ id: "done1", source: "emex", createdAt: new Date("2026-01-01T00:00:00Z") }], // Phase-2
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// scheduled guard absent (false), but the complete marker is present.
|
||||||
|
redis.exists.mockImplementation(async (...a: unknown[]) =>
|
||||||
|
String(a[0]).includes("prefetch:complete:"),
|
||||||
|
);
|
||||||
|
await (
|
||||||
|
service as never as { processBackfillScan: () => Promise<void> }
|
||||||
|
).processBackfillScan();
|
||||||
|
expect(queue.add).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("skips entirely when the backfill gate is off (not prod host)", async () => {
|
it("skips entirely when the backfill gate is off (not prod host)", async () => {
|
||||||
process.env.CATALOG_BACKFILL_ENABLED = "false";
|
process.env.CATALOG_BACKFILL_ENABLED = "false";
|
||||||
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
|
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
|
||||||
@@ -159,13 +190,17 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
|
|||||||
|
|
||||||
describe("runBackfillScan (in-process timer entry, NX-locked)", () => {
|
describe("runBackfillScan (in-process timer entry, NX-locked)", () => {
|
||||||
it("runs the scan when the lock is acquired", async () => {
|
it("runs the scan when the lock is acquired", async () => {
|
||||||
const { service, queue, redis } = makeDeps({
|
const {
|
||||||
|
service,
|
||||||
|
fastQueue: queue,
|
||||||
|
redis,
|
||||||
|
} = makeDeps({
|
||||||
waiting: 50_000,
|
waiting: 50_000,
|
||||||
limitResults: [[{ id: "empty1", source: "emex" }]],
|
limitResults: [[{ id: "empty1", source: "emex" }]],
|
||||||
});
|
});
|
||||||
await (service as never as { runBackfillScan: () => Promise<void> }).runBackfillScan();
|
await (service as never as { runBackfillScan: () => Promise<void> }).runBackfillScan();
|
||||||
expect(redis.setNx).toHaveBeenCalledWith("prefetch:backfill:lock", "1", expect.any(Number));
|
expect(redis.setNx).toHaveBeenCalledWith("prefetch:backfill:lock", "1", expect.any(Number));
|
||||||
expect(queue.add).toHaveBeenCalledTimes(1); // Phase-1 init enqueued
|
expect(queue.add).toHaveBeenCalledTimes(1); // Phase-1 init enqueued (fast lane)
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips the scan when the lock is already held", async () => {
|
it("skips the scan when the lock is already held", async () => {
|
||||||
@@ -175,4 +210,95 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
|
|||||||
expect(queue.add).not.toHaveBeenCalled();
|
expect(queue.add).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("checkSourceRate — per-source rate limit", () => {
|
||||||
|
type CSR = { checkSourceRate: (s: string) => Promise<void> };
|
||||||
|
|
||||||
|
it("passes when under the source ceiling", async () => {
|
||||||
|
const { service, redis } = makeDeps({ waiting: 0, limitResults: [] });
|
||||||
|
redis.incr.mockResolvedValueOnce(1);
|
||||||
|
await expect((service as never as CSR).checkSourceRate("pl24")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a source-rate RateLimitError once over the ceiling", async () => {
|
||||||
|
const { service, redis } = makeDeps({ waiting: 0, limitResults: [] });
|
||||||
|
redis.incr.mockResolvedValueOnce(21); // pl24 default ceiling is 20
|
||||||
|
await expect((service as never as CSR).checkSourceRate("pl24")).rejects.toMatchObject({
|
||||||
|
cause: "source-rate",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is unlimited (no counter) for a source without a configured ceiling", async () => {
|
||||||
|
const { service, redis } = makeDeps({ waiting: 0, limitResults: [] });
|
||||||
|
await (service as never as CSR).checkSourceRate("unknown-source");
|
||||||
|
expect(redis.incr).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("poison guard (Faz 6: category cap — brand-agnostic anti-explosion)", () => {
|
||||||
|
type PC = { processChildren: (j: unknown) => Promise<void> };
|
||||||
|
|
||||||
|
it("processChildren marks poison once progress.total exceeds CATEGORY_CAP", async () => {
|
||||||
|
const { service, queue, redis } = makeDeps({ waiting: 0, limitResults: [] });
|
||||||
|
redis.getJson.mockImplementation(async (...a: unknown[]) =>
|
||||||
|
String(a[0]).includes("progress") ? { total: 5000 } : null,
|
||||||
|
);
|
||||||
|
await (service as never as PC).processChildren({
|
||||||
|
data: { vehicleId: "op1", categoryId: "c1", source: "pl24", depth: 1 },
|
||||||
|
} as never);
|
||||||
|
const poisonSet = redis.set.mock.calls.some((c) =>
|
||||||
|
String(c[0]).includes("prefetch:poison:op1"),
|
||||||
|
);
|
||||||
|
expect(poisonSet).toBe(true);
|
||||||
|
expect(queue.add).not.toHaveBeenCalled(); // stopped drilling
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scan skips a poison-marked vehicle", async () => {
|
||||||
|
const { service, queue, redis } = makeDeps({
|
||||||
|
waiting: 10,
|
||||||
|
limitResults: [
|
||||||
|
[],
|
||||||
|
[{ id: "op1", source: "pl24", createdAt: new Date("2026-01-01T00:00:00Z") }],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
redis.exists.mockImplementation(async (...a: unknown[]) =>
|
||||||
|
String(a[0]).includes("prefetch:poison:"),
|
||||||
|
);
|
||||||
|
await (
|
||||||
|
service as never as { processBackfillScan: () => Promise<void> }
|
||||||
|
).processBackfillScan();
|
||||||
|
expect(queue.add).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("queueCategoryJob — depth cap (no-op guard)", () => {
|
||||||
|
// pl24 linkPath with no leaf marker (/bom/, /partinfo/ …) → treated as a
|
||||||
|
// non-leaf folder that would normally queue a prefetch-children job.
|
||||||
|
const nonLeaf = {
|
||||||
|
id: "c1",
|
||||||
|
linkPath: "/catalog/groups/x",
|
||||||
|
source: "pl24",
|
||||||
|
unavailable: false,
|
||||||
|
hasSubgroups: false,
|
||||||
|
};
|
||||||
|
type QCJ = {
|
||||||
|
queueCategoryJob: (c: unknown, v: string, s: string, d: number, f: boolean) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("does NOT enqueue a children job at depth >= MAX_DEPTH (avoids a no-op)", async () => {
|
||||||
|
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
|
||||||
|
// depth 12 == default MAX_DEPTH → processChildren would early-return, so the
|
||||||
|
// job must never be created.
|
||||||
|
await (service as never as QCJ).queueCategoryJob(nonLeaf, "v1", "pl24", 12, false);
|
||||||
|
expect(queue.add).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enqueues a children job for a non-leaf within the cap", async () => {
|
||||||
|
// childCheck query (.limit(1)) returns no rows → enqueue a children fetch.
|
||||||
|
const { service, queue } = makeDeps({ waiting: 0, limitResults: [[]] });
|
||||||
|
await (service as never as QCJ).queueCategoryJob(nonLeaf, "v1", "pl24", 1, false);
|
||||||
|
expect(queue.add).toHaveBeenCalledTimes(1);
|
||||||
|
expect(queue.add.mock.calls[0][0]).toBe("prefetch-children");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,9 +24,24 @@ import {
|
|||||||
updateProgress,
|
updateProgress,
|
||||||
} from "./prefetch-utils";
|
} from "./prefetch-utils";
|
||||||
import { PrefetchCategoryJobData, PrefetchInitJobData } from "./prefetch.types";
|
import { PrefetchCategoryJobData, PrefetchInitJobData } from "./prefetch.types";
|
||||||
import { CATALOG_PREFETCH_QUEUE } from "./queues/catalog-prefetch.queue";
|
import {
|
||||||
|
CATALOG_PREFETCH_FAST_QUEUE,
|
||||||
|
CATALOG_PREFETCH_QUEUE,
|
||||||
|
} from "./queues/catalog-prefetch.queue";
|
||||||
|
|
||||||
const MAX_DEPTH = 5;
|
/**
|
||||||
|
* Depth cap for the prefetch BFS — a safety ceiling, NOT the target depth.
|
||||||
|
*
|
||||||
|
* Goal is COMPLETE coverage: every decoded vehicle's full category tree + all
|
||||||
|
* parts in the DB so viewing is instant (DB read). The BFS terminates naturally
|
||||||
|
* at real leaves (isLeafLinkPath); this cap only guards against pathological /
|
||||||
|
* cyclic trees running away. Real PL24/EMEX/pcat trees are ~5-7 deep, so 12 is
|
||||||
|
* comfortably beyond every genuine leaf while still bounding a runaway.
|
||||||
|
* Efficiency is handled separately: queueCategoryJob never enqueues a children
|
||||||
|
* job past the cap (no early-return no-ops), and completed vehicles are skipped
|
||||||
|
* by the scan. Env-tunable (PREFETCH_MAX_DEPTH).
|
||||||
|
*/
|
||||||
|
const MAX_DEPTH = Number(process.env.PREFETCH_MAX_DEPTH) || 12;
|
||||||
|
|
||||||
// ── Backfill scan (hourly cron) tuning ──
|
// ── Backfill scan (hourly cron) tuning ──
|
||||||
/**
|
/**
|
||||||
@@ -59,14 +74,35 @@ const BACKFILL_SCAN_LOCK_TTL_S = 55 * 60;
|
|||||||
* lets a slow parts-catalogs job stop head-of-line-blocking emex. A user-active
|
* lets a slow parts-catalogs job stop head-of-line-blocking emex. A user-active
|
||||||
* cooldown still pauses the whole worker, so this only speeds up idle periods.
|
* cooldown still pauses the whole worker, so this only speeds up idle periods.
|
||||||
*/
|
*/
|
||||||
const WORKER_CONCURRENCY = Number(process.env.PREFETCH_CONCURRENCY) || 3;
|
const WORKER_CONCURRENCY = Number(process.env.PREFETCH_CONCURRENCY) || 8;
|
||||||
/** Global queue rate ceiling (jobs per minute). Was a hard 5/min. */
|
/**
|
||||||
const WORKER_RATE_MAX = Number(process.env.PREFETCH_RATE_MAX) || 20;
|
* Global queue rate ceiling (jobs/min) across ALL sources — now a SAFETY CAP,
|
||||||
|
* not the real throttle. Per-source limits (below) do the actual pacing so a
|
||||||
|
* slow/limited source can't starve the others. Defaults to the sum of the
|
||||||
|
* per-source ceilings + headroom. Was a hard 5/min, then a shared 20/min.
|
||||||
|
*/
|
||||||
|
const WORKER_RATE_MAX = Number(process.env.PREFETCH_RATE_MAX) || 180;
|
||||||
|
/**
|
||||||
|
* Per-source rate ceilings (jobs/min). Each source is throttled INDEPENDENTLY
|
||||||
|
* (Redis fixed-window) so PL24, EMEX and parts-catalogs run concurrently at
|
||||||
|
* their own safe rates instead of fighting over one global budget. 0 = unlimited
|
||||||
|
* for that source. PL24 stays conservative (upstream ban risk); raise it only
|
||||||
|
* with rotating residential proxies. pcat is already paced by PCAT_PACE_MS.
|
||||||
|
*/
|
||||||
|
const SOURCE_RATE_MAX: Record<string, number> = {
|
||||||
|
pl24: Number(process.env.PREFETCH_RATE_PL24) || 20,
|
||||||
|
emex: Number(process.env.PREFETCH_RATE_EMEX) || 20,
|
||||||
|
// pcat runs through a DataImpulse rotating gateway (74.81.81.81:10000-10999,
|
||||||
|
// ~1000 port-rotated exit IPs — verified 2026-06-25), so per-IP ban load is
|
||||||
|
// spread; safe to run well above the old shared cap. Ramp step 2 = 90/min
|
||||||
|
// (2026-07-08: 93k backlog %89 pcat idi — 20/min + 5s pace drenaja yetmiyordu).
|
||||||
|
"parts-catalogs": Number(process.env.PREFETCH_RATE_PCAT) || 90,
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* Per-job pacing for parts-catalogs only (its browser/JWT capture is heavy).
|
* Per-job pacing for parts-catalogs only (its browser/JWT capture is heavy).
|
||||||
* Set 0 to disable. Other sources are paced by the limiter + cooldown alone.
|
* Set 0 to disable. Other sources are paced by the limiter + cooldown alone.
|
||||||
*/
|
*/
|
||||||
const PCAT_PACE_MS = Number(process.env.PREFETCH_PCAT_DELAY_MS) || 15_000;
|
const PCAT_PACE_MS = Number(process.env.PREFETCH_PCAT_DELAY_MS) || 1_500;
|
||||||
|
|
||||||
// ── Phase-1 residue exclusion ──
|
// ── Phase-1 residue exclusion ──
|
||||||
/**
|
/**
|
||||||
@@ -78,16 +114,36 @@ const NORESULT_MAX_ATTEMPTS = Number(process.env.PREFETCH_NORESULT_MAX) || 2;
|
|||||||
/** TTL for the no-result counter — excluded vehicles retry after this, so a later
|
/** TTL for the no-result counter — excluded vehicles retry after this, so a later
|
||||||
* catalog fix eventually re-fills them (default 7 days). */
|
* catalog fix eventually re-fills them (default 7 days). */
|
||||||
const NORESULT_TTL_S = (Number(process.env.PREFETCH_NORESULT_TTL_DAYS) || 7) * 86_400;
|
const NORESULT_TTL_S = (Number(process.env.PREFETCH_NORESULT_TTL_DAYS) || 7) * 86_400;
|
||||||
|
/** TTL for the "fully fetched" marker. A vehicle whose whole prefetch chain
|
||||||
|
* finished WITH parts is marked complete and skipped by the Phase-2 rescan, so
|
||||||
|
* the scan stops re-walking finished trees (the old behaviour churned the queue
|
||||||
|
* forever). The TTL re-validates periodically so a later upstream catalog change
|
||||||
|
* is eventually picked up (default 21 days). Env-tunable. */
|
||||||
|
const COMPLETE_TTL_S = (Number(process.env.PREFETCH_COMPLETE_TTL_DAYS) || 21) * 86_400;
|
||||||
|
/**
|
||||||
|
* Per-vehicle category ceiling — anti-poison guard. A correctly-decoded PL24
|
||||||
|
* vehicle has ~100-800 categories (p95 744). A vehicle whose tree blows past
|
||||||
|
* this is almost always a decode that failed to resolve the model and landed on
|
||||||
|
* the generic ROOT catalog (the whole brand universe — e.g. 5 Opels with
|
||||||
|
* model="Opel" produced 420k/102k/21k categories and ZERO parts). Once a
|
||||||
|
* vehicle's discovered tree exceeds this, stop drilling it and mark it poison so
|
||||||
|
* it never gets re-picked. Env-tunable. */
|
||||||
|
const CATEGORY_CAP = Number(process.env.PREFETCH_CATEGORY_CAP) || 3000;
|
||||||
|
/** TTL for the poison marker (generic-model / over-cap vehicles). Long, because
|
||||||
|
* the underlying cause is a decode bug — re-validate monthly. */
|
||||||
|
const POISON_TTL_S = (Number(process.env.PREFETCH_POISON_TTL_DAYS) || 30) * 86_400;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(PrefetchWorkerService.name);
|
private readonly logger = new Logger(PrefetchWorkerService.name);
|
||||||
private worker: Worker | null = null;
|
private worker: Worker | null = null;
|
||||||
|
private fastWorker?: Worker;
|
||||||
private backfillKickoff?: ReturnType<typeof setTimeout>;
|
private backfillKickoff?: ReturnType<typeof setTimeout>;
|
||||||
private backfillInterval?: ReturnType<typeof setInterval>;
|
private backfillInterval?: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(CATALOG_PREFETCH_QUEUE) private queue: Queue,
|
@Inject(CATALOG_PREFETCH_QUEUE) private queue: Queue,
|
||||||
|
@Inject(CATALOG_PREFETCH_FAST_QUEUE) private fastQueue: Queue,
|
||||||
private categoriesService: CategoriesService,
|
private categoriesService: CategoriesService,
|
||||||
private redis: RedisService,
|
private redis: RedisService,
|
||||||
private posthog: PostHogService,
|
private posthog: PostHogService,
|
||||||
@@ -117,8 +173,30 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.logger.error(`[prefetch] Worker error: ${err.message}`);
|
this.logger.error(`[prefetch] Worker error: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FAST lane: own queue + worker so fresh decodes never compete with the
|
||||||
|
// deep-drill backlog (whose production>consumption regime starves lifo tails).
|
||||||
|
// Same process fn; the shared per-source redis rate gate still protects
|
||||||
|
// upstream across BOTH workers.
|
||||||
|
this.fastWorker = new Worker(
|
||||||
|
QUEUE_NAMES.CATALOG_PREFETCH_FAST,
|
||||||
|
(job, token) => backfillContext.run(true, () => this.process(job, token)),
|
||||||
|
{
|
||||||
|
connection: getBullConnection(),
|
||||||
|
concurrency: 4,
|
||||||
|
limiter: { max: 120, duration: 60_000 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.fastWorker.on("failed", (job, err) => {
|
||||||
|
this.logger.warn(`[prefetch:fast] Job ${job?.name} failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
this.fastWorker.on("error", (err) => {
|
||||||
|
this.logger.error(`[prefetch:fast] Worker error: ${err.message}`);
|
||||||
|
});
|
||||||
|
this.logger.log("[prefetch] Fast worker started (concurrency=4, 120/min)");
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[prefetch] Worker started (concurrency=${WORKER_CONCURRENCY}, ${WORKER_RATE_MAX} jobs/min, pcatPace=${PCAT_PACE_MS}ms)`,
|
`[prefetch] Worker started (concurrency=${WORKER_CONCURRENCY}, global ${WORKER_RATE_MAX}/min, ` +
|
||||||
|
`per-source ${JSON.stringify(SOURCE_RATE_MAX)}, pcatPace=${PCAT_PACE_MS}ms)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hourly backfill scan — run IN-PROCESS, not as a BullMQ cron job. A cron
|
// Hourly backfill scan — run IN-PROCESS, not as a BullMQ cron job. A cron
|
||||||
@@ -146,6 +224,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (this.backfillKickoff) clearTimeout(this.backfillKickoff);
|
if (this.backfillKickoff) clearTimeout(this.backfillKickoff);
|
||||||
if (this.backfillInterval) clearInterval(this.backfillInterval);
|
if (this.backfillInterval) clearInterval(this.backfillInterval);
|
||||||
if (this.worker) {
|
if (this.worker) {
|
||||||
|
await this.fastWorker?.close();
|
||||||
await this.worker.close();
|
await this.worker.close();
|
||||||
this.worker = null;
|
this.worker = null;
|
||||||
}
|
}
|
||||||
@@ -165,6 +244,19 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private async process(job: Job, token?: string): Promise<void> {
|
private async process(job: Job, token?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const data = job.data as { source?: string };
|
const data = job.data as { source?: string };
|
||||||
|
// Per-source rate gate FIRST (before the pcat pace) so we don't burn the 15s
|
||||||
|
// sleep on a job we're about to defer. Scan jobs are exempt.
|
||||||
|
if (
|
||||||
|
data.source &&
|
||||||
|
(job.name === "prefetch-init" ||
|
||||||
|
job.name === "prefetch-children" ||
|
||||||
|
job.name === "prefetch-parts")
|
||||||
|
) {
|
||||||
|
await this.checkSourceRate(
|
||||||
|
data.source,
|
||||||
|
(job.data as { fast?: boolean }).fast ? "fast" : "main",
|
||||||
|
);
|
||||||
|
}
|
||||||
if (data.source === "parts-catalogs" && PCAT_PACE_MS > 0) {
|
if (data.source === "parts-catalogs" && PCAT_PACE_MS > 0) {
|
||||||
await new Promise((r) => setTimeout(r, PCAT_PACE_MS));
|
await new Promise((r) => setTimeout(r, PCAT_PACE_MS));
|
||||||
}
|
}
|
||||||
@@ -192,14 +284,16 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// global wait is correct: when it ends and cooldown is still active,
|
// global wait is correct: when it ends and cooldown is still active,
|
||||||
// we'll pause again — at most ~1 cycle per retryAfterMs instead of
|
// we'll pause again — at most ~1 cycle per retryAfterMs instead of
|
||||||
// cycling every queued job through.
|
// cycling every queued job through.
|
||||||
await this.worker?.rateLimit(delayMs);
|
const lane = (job.data as { fast?: boolean }).fast ? this.fastWorker : this.worker;
|
||||||
|
await lane?.rateLimit(delayMs);
|
||||||
this.logger.debug(`[prefetch] Worker paused ${delayMs}ms (cooldown ${job.data.source})`);
|
this.logger.debug(`[prefetch] Worker paused ${delayMs}ms (cooldown ${job.data.source})`);
|
||||||
throw Worker.RateLimitError();
|
throw Worker.RateLimitError();
|
||||||
}
|
}
|
||||||
// Time-window deferral can be hours (msUntilNext9AM). Per-job defer so
|
// time-window (off-hours, up to hours) and source-rate (this source hit
|
||||||
// EMEX (no window) keeps flowing while PL24/pcat jobs sleep till 09:00.
|
// its per-minute ceiling, ~sub-minute) both defer PER-JOB so other sources
|
||||||
|
// keep flowing — only cooldown pauses the whole worker.
|
||||||
await job.moveToDelayed(Date.now() + delayMs, token);
|
await job.moveToDelayed(Date.now() + delayMs, token);
|
||||||
this.logger.debug(`[prefetch] Job ${job.name} deferred ${delayMs}ms (off-hours)`);
|
this.logger.debug(`[prefetch] Job ${job.name} deferred ${delayMs}ms (${err.cause})`);
|
||||||
throw new DelayedError();
|
throw new DelayedError();
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -216,6 +310,12 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
await checkCooldown(this.redis, source);
|
await checkCooldown(this.redis, source);
|
||||||
checkTimeWindow(source);
|
checkTimeWindow(source);
|
||||||
|
|
||||||
|
// Already flagged as poison (tree exceeded CATEGORY_CAP on a prior run) — skip.
|
||||||
|
if (await this.redis.exists(this.poisonKey(vehicleId))) {
|
||||||
|
this.logger.debug(`[prefetch] Skip poison vehicle=${vehicleId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Verify vehicle still exists
|
// Verify vehicle still exists
|
||||||
const [vehicle] = await this.db
|
const [vehicle] = await this.db
|
||||||
.select({ id: vehicles.id })
|
.select({ id: vehicles.id })
|
||||||
@@ -228,7 +328,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await initProgress(this.redis, vehicleId);
|
await initProgress(this.redis, vehicleId); // resets progress.total — the cap's tree-size proxy
|
||||||
|
|
||||||
// Get all top-level categories for this vehicle
|
// Get all top-level categories for this vehicle
|
||||||
let topCategories = await this.db
|
let topCategories = await this.db
|
||||||
@@ -342,6 +442,19 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anti-poison cap: progress.total tracks every sub-job queued for this vehicle
|
||||||
|
// (≈ its discovered tree size). Once it blows past the ceiling the vehicle is a
|
||||||
|
// generic-catalog explosion — stop drilling and mark it poison so the rest of
|
||||||
|
// its (part-less) tree is never fetched and it's never re-picked.
|
||||||
|
const prog = await this.redis.getJson<{ total: number }>(`prefetch:progress:${vehicleId}`);
|
||||||
|
if ((prog?.total ?? 0) >= CATEGORY_CAP) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[prefetch] Category cap ${CATEGORY_CAP} hit for vehicle=${vehicleId} (tree≈${prog?.total}) — marking poison, stop drilling`,
|
||||||
|
);
|
||||||
|
await this.markPoison(vehicleId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const children = await this.categoriesService.getChildren(categoryId);
|
const children = await this.categoriesService.getChildren(categoryId);
|
||||||
|
|
||||||
@@ -462,6 +575,13 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// parts (no catalog data). They'd otherwise be re-picked every wave forever.
|
// parts (no catalog data). They'd otherwise be re-picked every wave forever.
|
||||||
const noResult = await this.redis.get(this.noResultKey(v.id));
|
const noResult = await this.redis.get(this.noResultKey(v.id));
|
||||||
if (noResult && Number(noResult) >= NORESULT_MAX_ATTEMPTS) return;
|
if (noResult && Number(noResult) >= NORESULT_MAX_ATTEMPTS) return;
|
||||||
|
// Skip vehicles whose full tree is already fetched (marker set on chain
|
||||||
|
// completion with parts). Stops Phase-2 from re-walking finished vehicles
|
||||||
|
// every wave; the marker's TTL re-validates them periodically. Phase-1
|
||||||
|
// (zero-parts) vehicles never carry this marker, so this is a no-op there.
|
||||||
|
if (await this.redis.exists(this.completeKey(v.id))) return;
|
||||||
|
// Skip poison vehicles (generic-model / over-cap catalog explosions).
|
||||||
|
if (await this.redis.exists(this.poisonKey(v.id))) return;
|
||||||
seen.add(v.id);
|
seen.add(v.id);
|
||||||
picked.push({ id: v.id, source: v.source, fast });
|
picked.push({ id: v.id, source: v.source, fast });
|
||||||
};
|
};
|
||||||
@@ -531,7 +651,8 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
/** Queue a prefetch-init for a vehicle and set the in-flight guard. */
|
/** Queue a prefetch-init for a vehicle and set the in-flight guard. */
|
||||||
private async enqueueInit(vehicleId: string, source: string, fast = false): Promise<void> {
|
private async enqueueInit(vehicleId: string, source: string, fast = false): Promise<void> {
|
||||||
await this.queue.add(
|
const q = fast ? this.fastQueue : this.queue;
|
||||||
|
await q.add(
|
||||||
"prefetch-init",
|
"prefetch-init",
|
||||||
{ vehicleId, source: source as PrefetchInitJobData["source"], fast },
|
{ vehicleId, source: source as PrefetchInitJobData["source"], fast },
|
||||||
{
|
{
|
||||||
@@ -581,8 +702,11 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
fast,
|
fast,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (cat.linkPath) {
|
} else if (cat.linkPath && depth < MAX_DEPTH) {
|
||||||
// Check if children already exist
|
// Non-leaf within the depth cap — explore children. The `depth < MAX_DEPTH`
|
||||||
|
// gate mirrors processChildren's early-return: without it we'd enqueue a
|
||||||
|
// prefetch-children job that processChildren just drops, burning a rate-limit
|
||||||
|
// slot on a no-op (this was ~85% of the queue at MAX_DEPTH=2).
|
||||||
const [childCheck] = await this.db
|
const [childCheck] = await this.db
|
||||||
.select({ id: categories.id })
|
.select({ id: categories.id })
|
||||||
.from(categories)
|
.from(categories)
|
||||||
@@ -654,6 +778,10 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// OPPOSITE here and starve the job behind the backlog; lifo is correct.)
|
// OPPOSITE here and starve the job behind the backlog; lifo is correct.)
|
||||||
...(data.fast ? { lifo: true } : {}),
|
...(data.fast ? { lifo: true } : {}),
|
||||||
};
|
};
|
||||||
|
if (data.fast) {
|
||||||
|
await this.fastQueue.add(name, data, opts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// parts-catalogs pacing is handled per-job in process() (PCAT_PACE_MS) + the
|
// parts-catalogs pacing is handled per-job in process() (PCAT_PACE_MS) + the
|
||||||
// limiter. The old cumulative `index * 20s` delay was pathological (the Nth
|
// limiter. The old cumulative `index * 20s` delay was pathological (the Nth
|
||||||
@@ -687,7 +815,15 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
.from(parts)
|
.from(parts)
|
||||||
.where(eq(parts.vehicleId, vehicleId))
|
.where(eq(parts.vehicleId, vehicleId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!hasPart) await this.markNoResult(vehicleId);
|
if (hasPart) {
|
||||||
|
// Fully fetched with parts → mark complete so the Phase-2 rescan skips it
|
||||||
|
// (re-validates after the TTL). A chain with any failed job never reaches
|
||||||
|
// isFinished, so partially-fetched vehicles are never marked — they keep
|
||||||
|
// getting gap-filled.
|
||||||
|
await this.redis.set(this.completeKey(vehicleId), "1", COMPLETE_TTL_S);
|
||||||
|
} else {
|
||||||
|
await this.markNoResult(vehicleId);
|
||||||
|
}
|
||||||
// Clean up Redis keys — data is in PostgreSQL now
|
// Clean up Redis keys — data is in PostgreSQL now
|
||||||
await this.redis.del(`prefetch:scheduled:${vehicleId}`);
|
await this.redis.del(`prefetch:scheduled:${vehicleId}`);
|
||||||
await this.redis.del(`prefetch:progress:${vehicleId}`);
|
await this.redis.del(`prefetch:progress:${vehicleId}`);
|
||||||
@@ -706,6 +842,50 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return `prefetch:noresult:${vehicleId}`;
|
return `prefetch:noresult:${vehicleId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private completeKey(vehicleId: string): string {
|
||||||
|
return `prefetch:complete:${vehicleId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private poisonKey(vehicleId: string): string {
|
||||||
|
return `prefetch:poison:${vehicleId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flag a vehicle as poison (its tree blew past CATEGORY_CAP — a generic
|
||||||
|
* root-catalog explosion from a decode that didn't resolve the model) so
|
||||||
|
* init/scan skip it, and clear its in-flight + progress state. Brand-agnostic:
|
||||||
|
* only an actual explosion trips it, so small legit generic-model PSA vehicles
|
||||||
|
* (which stay ~200 categories and DO yield parts) are never affected. */
|
||||||
|
private async markPoison(vehicleId: string): Promise<void> {
|
||||||
|
await this.redis.set(this.poisonKey(vehicleId), "1", POISON_TTL_S);
|
||||||
|
await this.redis.del(`prefetch:scheduled:${vehicleId}`);
|
||||||
|
await this.redis.del(`prefetch:progress:${vehicleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-source fixed-window (60s) rate limit. Increments the source's window
|
||||||
|
* counter; once it exceeds the source's ceiling, throws RateLimitError
|
||||||
|
* ("source-rate") so process() defers THIS job to the window's end while other
|
||||||
|
* sources keep flowing. 0 / unknown source ceiling = unlimited.
|
||||||
|
*/
|
||||||
|
private async checkSourceRate(source: string, lane: "main" | "fast" = "main"): Promise<void> {
|
||||||
|
// Per-LANE budgets: the main worker's deep-drill backlog would otherwise eat
|
||||||
|
// the whole per-source window and starve the fast lane with 18s defers
|
||||||
|
// (observed: fresh BMW init couldn't get a single pcat slot). Totals per
|
||||||
|
// source stay the same as before, so upstream load is unchanged.
|
||||||
|
const total = SOURCE_RATE_MAX[source] ?? 0;
|
||||||
|
if (total <= 0) return;
|
||||||
|
const fastShare = Math.max(1, Math.floor(total / 3));
|
||||||
|
const max = lane === "fast" ? fastShare : total - fastShare;
|
||||||
|
const windowMs = 60_000;
|
||||||
|
const now = Date.now();
|
||||||
|
const key = `prefetch:rate:${lane}:${source}:${Math.floor(now / windowMs)}`;
|
||||||
|
const n = await this.redis.incr(key);
|
||||||
|
if (n === 1) await this.redis.expire(key, 61);
|
||||||
|
if (n > max) {
|
||||||
|
throw new RateLimitError(windowMs - (now % windowMs) + 50, "source-rate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Record that a backfill attempt finished with the vehicle still at zero parts. */
|
/** Record that a backfill attempt finished with the vehicle still at zero parts. */
|
||||||
private async markNoResult(vehicleId: string): Promise<void> {
|
private async markNoResult(vehicleId: string): Promise<void> {
|
||||||
const key = this.noResultKey(vehicleId);
|
const key = this.noResultKey(vehicleId);
|
||||||
|
|||||||
346
apps/api/src/jobs/processors/canonical-backfill.processor.ts
Normal file
346
apps/api/src/jobs/processors/canonical-backfill.processor.ts
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import { Job } from "bullmq";
|
||||||
|
import { inArray, sql } from "drizzle-orm";
|
||||||
|
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||||
|
import { NORMALIZE_SQL, classifyNode, mapToCanonical } from "../canonical-lexicon";
|
||||||
|
import { mapToSubcategory } from "../canonical-sublexicon";
|
||||||
|
|
||||||
|
type Database = PostgresJsDatabase<Record<string, unknown>>;
|
||||||
|
|
||||||
|
export interface CanonicalBackfillJobData {
|
||||||
|
// Limit input to these brands (matched via catalog_vehicles/vehicles). Empty
|
||||||
|
// = all brands. The map rows written are brand-agnostic (keyed by name), so
|
||||||
|
// scoping only limits WHICH names are considered this run — safe to widen later.
|
||||||
|
brands?: string[];
|
||||||
|
// Limit to these sources (pl24 | emex | parts-catalogs). Empty = all.
|
||||||
|
sources?: string[];
|
||||||
|
// Compute + log coverage but write nothing.
|
||||||
|
dryRun?: boolean;
|
||||||
|
// Full rebuild of the non-manual keyword layer: before re-mapping, NULL the
|
||||||
|
// materialized ids and DELETE stale non-manual map rows in scope, so names
|
||||||
|
// that no longer map (lexicon/classifier changed) don't leave dangling rows.
|
||||||
|
// Manual rows survive. ONLY honored for a GLOBAL run (no brands): the map is
|
||||||
|
// name-level/brand-agnostic, so a brand-scoped delete would wipe mappings
|
||||||
|
// shared with other brands. Use this for the periodic full re-run.
|
||||||
|
reset?: boolean;
|
||||||
|
// Keep-fresh mode for the nightly cron: only classify names that have NO map
|
||||||
|
// row yet (the day's newly-decoded categories), instead of re-classifying all
|
||||||
|
// ~113k. The materialize step is differential anyway, so a brand-new category
|
||||||
|
// whose name already maps still gets its id set. Does NOT re-apply lexicon
|
||||||
|
// tuning to already-mapped names — that's what a manual `reset` run is for.
|
||||||
|
// Ignored when `reset` is set (a reset intends a full re-map).
|
||||||
|
incremental?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanonicalBackfillStats {
|
||||||
|
distinctNames: number;
|
||||||
|
partCategories: number;
|
||||||
|
structural: number;
|
||||||
|
junk: number;
|
||||||
|
mapped: number;
|
||||||
|
subMapped: number;
|
||||||
|
ambiguous: number;
|
||||||
|
categoriesUpdated: number;
|
||||||
|
subcategoriesUpdated: number;
|
||||||
|
inherited: number;
|
||||||
|
mapRowsPruned: number;
|
||||||
|
dryRun: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bare `name` in NORMALIZE_SQL resolves to categories.name in every query
|
||||||
|
// below (catalog_vehicles/vehicles have no `name` column), so folding is
|
||||||
|
// identical on both the SELECT that builds the map and the UPDATE join.
|
||||||
|
const INSERT_CHUNK = 500;
|
||||||
|
|
||||||
|
export async function processCanonicalBackfill(
|
||||||
|
job: Job,
|
||||||
|
db: Database,
|
||||||
|
): Promise<CanonicalBackfillStats> {
|
||||||
|
const data = (job.data ?? {}) as CanonicalBackfillJobData;
|
||||||
|
const brands = data.brands?.length ? data.brands : null;
|
||||||
|
const sources = data.sources?.length ? data.sources : null;
|
||||||
|
const dryRun = data.dryRun === true;
|
||||||
|
const reset = data.reset === true && !brands;
|
||||||
|
const incremental = data.incremental === true && !reset;
|
||||||
|
if (data.reset === true && brands) {
|
||||||
|
console.warn(
|
||||||
|
"[canonical-backfill] reset ignored: only honored for a global run (map is name-level/brand-agnostic)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`[canonical-backfill] job ${job.id} start (brands=${brands ?? "ALL"}, sources=${sources ?? "ALL"}, dryRun=${dryRun}, reset=${reset})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. canonical slug → id (seeded by migration 0024)
|
||||||
|
const canon = await db.execute<{ id: string; slug: string }>(
|
||||||
|
sql`SELECT id, slug FROM canonical_categories`,
|
||||||
|
);
|
||||||
|
const slugToId = new Map<string, string>();
|
||||||
|
for (const r of canon) slugToId.set(r.slug, r.id);
|
||||||
|
if (slugToId.size === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"[canonical-backfill] canonical_categories is empty — migration 0024 seed missing",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// inArray expands to `expr IN ($1,$2,…)` — driver-safe across postgres-js,
|
||||||
|
// unlike `= ANY($1)` which needs the param bound as a Postgres array.
|
||||||
|
const sourceCond = sources ? sql`AND ${inArray(sql`c.source`, sources)}` : sql``;
|
||||||
|
// Brand scope via a correlated EXISTS on c.id — pl24 nodes hang off
|
||||||
|
// catalog_vehicles, emex/pcat off vehicles, so coalesce covers both. EXISTS
|
||||||
|
// (not a FROM-join) is used in the UPDATE because Postgres forbids referencing
|
||||||
|
// the UPDATE target inside its FROM-list join conditions.
|
||||||
|
const brandExists = brands
|
||||||
|
? sql`AND EXISTS (
|
||||||
|
SELECT 1 FROM categories c2
|
||||||
|
LEFT JOIN catalog_vehicles cv ON c2.catalog_vehicle_id = cv.id
|
||||||
|
LEFT JOIN vehicles v ON c2.vehicle_id = v.id
|
||||||
|
WHERE c2.id = c.id AND ${inArray(sql`coalesce(cv.brand_name, v.brand_name)`, brands)}
|
||||||
|
)`
|
||||||
|
: sql``;
|
||||||
|
|
||||||
|
// 2. distinct (folded name, source) in scope
|
||||||
|
const brandJoinFilter = brands
|
||||||
|
? sql`AND ${inArray(sql`coalesce(cv.brand_name, v.brand_name)`, brands)}`
|
||||||
|
: sql``;
|
||||||
|
// Incremental keep-fresh: skip names already in the map — only the day's new
|
||||||
|
// names are (re)classified. Materialize (step 5) still fixes any category
|
||||||
|
// whose name maps but whose own id is stale/null, so nothing new is missed.
|
||||||
|
const incrementalCond = incremental
|
||||||
|
? sql`AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM category_canonical_map m
|
||||||
|
WHERE m.source = c.source AND m.normalized_name = ${sql.raw(NORMALIZE_SQL)}
|
||||||
|
)`
|
||||||
|
: sql``;
|
||||||
|
const names = await db.execute<{ folded: string; source: string }>(sql`
|
||||||
|
SELECT DISTINCT ${sql.raw(NORMALIZE_SQL)} AS folded, c.source AS source
|
||||||
|
FROM categories c
|
||||||
|
LEFT JOIN catalog_vehicles cv ON c.catalog_vehicle_id = cv.id
|
||||||
|
LEFT JOIN vehicles v ON c.vehicle_id = v.id
|
||||||
|
WHERE true ${brandJoinFilter} ${sourceCond} ${incrementalCond}
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3. classify + keyword-map
|
||||||
|
const rows: {
|
||||||
|
source: string;
|
||||||
|
folded: string;
|
||||||
|
canonicalId: string;
|
||||||
|
subcategoryId: string | null;
|
||||||
|
ambiguous: boolean;
|
||||||
|
confidence: number;
|
||||||
|
}[] = [];
|
||||||
|
const stats: CanonicalBackfillStats = {
|
||||||
|
distinctNames: names.length,
|
||||||
|
partCategories: 0,
|
||||||
|
structural: 0,
|
||||||
|
junk: 0,
|
||||||
|
mapped: 0,
|
||||||
|
subMapped: 0,
|
||||||
|
ambiguous: 0,
|
||||||
|
categoriesUpdated: 0,
|
||||||
|
subcategoriesUpdated: 0,
|
||||||
|
inherited: 0,
|
||||||
|
mapRowsPruned: 0,
|
||||||
|
dryRun,
|
||||||
|
};
|
||||||
|
for (const { folded, source } of names) {
|
||||||
|
const kind = classifyNode(folded);
|
||||||
|
if (kind === "junk") {
|
||||||
|
stats.junk++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (kind === "structural") {
|
||||||
|
stats.structural++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stats.partCategories++;
|
||||||
|
const { slug, ambiguous, confidence } = mapToCanonical(folded);
|
||||||
|
if (!slug) continue;
|
||||||
|
const canonicalId = slugToId.get(slug);
|
||||||
|
if (!canonicalId) continue; // slug not seeded — skip defensively
|
||||||
|
stats.mapped++;
|
||||||
|
if (ambiguous) stats.ambiguous++;
|
||||||
|
// second level: name→bucket→sub is deterministic, so resolve it here.
|
||||||
|
const subSlug = mapToSubcategory(folded, slug);
|
||||||
|
const subcategoryId = subSlug ? (slugToId.get(subSlug) ?? null) : null;
|
||||||
|
if (subcategoryId) stats.subMapped++;
|
||||||
|
rows.push({ source, folded, canonicalId, subcategoryId, ambiguous, confidence });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[canonical-backfill] ${stats.distinctNames} names → part=${stats.partCategories} structural=${stats.structural} junk=${stats.junk} | mapped=${stats.mapped} (ambiguous=${stats.ambiguous})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log("[canonical-backfill] dryRun — no writes");
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3b. reset (global only): clear the materialized ids + prune non-manual map
|
||||||
|
// rows in scope so stale mappings from an older lexicon/classifier disappear.
|
||||||
|
if (reset) {
|
||||||
|
const mapSourceCond = sources ? sql`AND ${inArray(sql`source`, sources)}` : sql``;
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE categories c SET canonical_category_id = NULL, canonical_subcategory_id = NULL
|
||||||
|
WHERE (canonical_category_id IS NOT NULL OR canonical_subcategory_id IS NOT NULL) ${sourceCond}
|
||||||
|
`);
|
||||||
|
const pruned = await db.execute<{ n: number }>(sql`
|
||||||
|
WITH d AS (
|
||||||
|
DELETE FROM category_canonical_map
|
||||||
|
WHERE is_manual = false ${mapSourceCond}
|
||||||
|
RETURNING 1
|
||||||
|
)
|
||||||
|
SELECT count(*)::int AS n FROM d
|
||||||
|
`);
|
||||||
|
stats.mapRowsPruned = pruned[0]?.n ?? 0;
|
||||||
|
console.log(
|
||||||
|
`[canonical-backfill] reset — cleared materialized ids in scope, pruned ${stats.mapRowsPruned} non-manual map rows`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. upsert name-level map rows (chunked). is_manual rows are protected: the
|
||||||
|
// conflict target updates only when the existing row was not human-approved.
|
||||||
|
for (let i = 0; i < rows.length; i += INSERT_CHUNK) {
|
||||||
|
const chunk = rows.slice(i, i + INSERT_CHUNK);
|
||||||
|
const values = chunk.map(
|
||||||
|
(r) =>
|
||||||
|
sql`(${r.source}, ${r.folded}, ${r.canonicalId}, ${r.subcategoryId}, 'keyword', ${r.confidence}, ${r.ambiguous})`,
|
||||||
|
);
|
||||||
|
await db.execute(sql`
|
||||||
|
INSERT INTO category_canonical_map
|
||||||
|
(source, normalized_name, canonical_category_id, canonical_subcategory_id, method, confidence, ambiguous)
|
||||||
|
VALUES ${sql.join(values, sql`, `)}
|
||||||
|
ON CONFLICT (source, normalized_name) DO UPDATE SET
|
||||||
|
canonical_category_id = EXCLUDED.canonical_category_id,
|
||||||
|
canonical_subcategory_id = EXCLUDED.canonical_subcategory_id,
|
||||||
|
method = EXCLUDED.method,
|
||||||
|
confidence = EXCLUDED.confidence,
|
||||||
|
ambiguous = EXCLUDED.ambiguous,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE category_canonical_map.is_manual = false
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. materialize categories.canonical_category_id + _subcategory_id from the
|
||||||
|
// map (scoped join). Both levels come from the same name-keyed row.
|
||||||
|
const updated = await db.execute<{ n: number }>(sql`
|
||||||
|
WITH upd AS (
|
||||||
|
UPDATE categories c
|
||||||
|
SET canonical_category_id = m.canonical_category_id,
|
||||||
|
canonical_subcategory_id = m.canonical_subcategory_id
|
||||||
|
FROM category_canonical_map m
|
||||||
|
WHERE m.source = c.source
|
||||||
|
AND m.normalized_name = ${sql.raw(NORMALIZE_SQL)}
|
||||||
|
AND (c.canonical_category_id IS DISTINCT FROM m.canonical_category_id
|
||||||
|
OR c.canonical_subcategory_id IS DISTINCT FROM m.canonical_subcategory_id)
|
||||||
|
${sourceCond} ${brandExists}
|
||||||
|
RETURNING 1
|
||||||
|
)
|
||||||
|
SELECT count(*)::int AS n FROM upd
|
||||||
|
`);
|
||||||
|
stats.categoriesUpdated = updated[0]?.n ?? 0;
|
||||||
|
|
||||||
|
// 5b. parent-inheritance: a category whose own name didn't map inherits its
|
||||||
|
// parent's bucket. Handles code/plate-named illustration leaves under a
|
||||||
|
// descriptive parent (Renault P5 "Alternatör" → X1318_… ; any figure leaf).
|
||||||
|
// Cascade a few levels (deepest OEM trees are ~5).
|
||||||
|
//
|
||||||
|
// EXCEPT vehicle-selector nodes (year-range "… 2004 - 2014" / model-code
|
||||||
|
// "A04 ASTRA-H"): these are NOT parts, so inheriting a part bucket into them
|
||||||
|
// is wrong AND cascades that wrong bucket onto their generic children
|
||||||
|
// (Opel "BOYALAR"/"MÜTEFERRİK" under a model node were landing in Debriyaj).
|
||||||
|
// Blocking the selector node from receiving keeps it null, which breaks the
|
||||||
|
// whole downstream chain. Code/plate leaves (X1318_, 20-201A) don't match
|
||||||
|
// these patterns, so Renault/Hyundai inheritance is untouched.
|
||||||
|
for (let depth = 0; depth < 5; depth++) {
|
||||||
|
const inh = await db.execute<{ n: number }>(sql`
|
||||||
|
WITH upd AS (
|
||||||
|
UPDATE categories c
|
||||||
|
SET canonical_category_id = p.canonical_category_id,
|
||||||
|
canonical_subcategory_id = p.canonical_subcategory_id
|
||||||
|
FROM categories p
|
||||||
|
WHERE c.parent_id = p.id
|
||||||
|
AND c.canonical_category_id IS NULL
|
||||||
|
AND p.canonical_category_id IS NOT NULL
|
||||||
|
AND c.name !~ '(19|20)[0-9]{2} - (19|20)[0-9]{2}'
|
||||||
|
AND c.name !~* '^[a-z]{1,3}[0-9]{2} '
|
||||||
|
${sourceCond} ${brandExists}
|
||||||
|
RETURNING 1
|
||||||
|
)
|
||||||
|
SELECT count(*)::int AS n FROM upd
|
||||||
|
`);
|
||||||
|
const n = inh[0]?.n ?? 0;
|
||||||
|
stats.inherited += n;
|
||||||
|
if (n === 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// subcategory coverage: how many mapped categories in scope now carry a sub.
|
||||||
|
const subCount = await db.execute<{ n: number }>(sql`
|
||||||
|
SELECT count(*)::int AS n FROM categories c
|
||||||
|
WHERE c.canonical_subcategory_id IS NOT NULL ${sourceCond} ${brandExists}
|
||||||
|
`);
|
||||||
|
stats.subcategoriesUpdated = subCount[0]?.n ?? 0;
|
||||||
|
|
||||||
|
// 6. rebuild the per (source, brand) structure template — the shared canonical
|
||||||
|
// skeleton the serving overlays so a freshly-decoded vehicle shows the FULL
|
||||||
|
// structure instantly (before its own deep-drill). Derived from all mapped
|
||||||
|
// categories, so only on a GLOBAL run (brand-scoped runs see a partial world).
|
||||||
|
if (!brands && !dryRun) {
|
||||||
|
await db.execute(sql`DELETE FROM canonical_structure_template`);
|
||||||
|
const tpl = await db.execute<{ n: number }>(sql`
|
||||||
|
WITH ins AS (
|
||||||
|
INSERT INTO canonical_structure_template
|
||||||
|
(source, brand, canonical_category_id, canonical_subcategory_id, vehicle_count)
|
||||||
|
SELECT c.source,
|
||||||
|
coalesce(cv.brand_name, v.brand_name) AS brand,
|
||||||
|
c.canonical_category_id,
|
||||||
|
c.canonical_subcategory_id,
|
||||||
|
count(DISTINCT coalesce(c.catalog_vehicle_id, c.vehicle_id))::int
|
||||||
|
FROM categories c
|
||||||
|
LEFT JOIN catalog_vehicles cv ON c.catalog_vehicle_id = cv.id
|
||||||
|
LEFT JOIN vehicles v ON c.vehicle_id = v.id
|
||||||
|
WHERE c.canonical_category_id IS NOT NULL
|
||||||
|
AND coalesce(cv.brand_name, v.brand_name) IS NOT NULL
|
||||||
|
GROUP BY c.source, coalesce(cv.brand_name, v.brand_name),
|
||||||
|
c.canonical_category_id, c.canonical_subcategory_id
|
||||||
|
RETURNING 1
|
||||||
|
)
|
||||||
|
SELECT count(*)::int AS n FROM ins
|
||||||
|
`);
|
||||||
|
console.log(`[canonical-backfill] structure template rebuilt — ${tpl[0]?.n ?? 0} rows`);
|
||||||
|
|
||||||
|
// Entry-level template: the ENTRY names (bucket entry-point children — same
|
||||||
|
// level buildCanonicalInput serves) shared across the brand's vehicles.
|
||||||
|
// min 2 vehicles so one-off vehicle quirks don't become "the brand".
|
||||||
|
await db.execute(sql`DELETE FROM canonical_template_entries`);
|
||||||
|
const tent = await db.execute<{ n: number }>(sql`
|
||||||
|
WITH ins AS (
|
||||||
|
INSERT INTO canonical_template_entries
|
||||||
|
(source, brand, canonical_category_id, canonical_subcategory_id, name, vehicle_count)
|
||||||
|
SELECT c.source, coalesce(cv.brand_name, v.brand_name),
|
||||||
|
c.canonical_category_id, c.canonical_subcategory_id,
|
||||||
|
min(c.name),
|
||||||
|
count(DISTINCT coalesce(c.catalog_vehicle_id, c.vehicle_id))::int
|
||||||
|
FROM categories c
|
||||||
|
LEFT JOIN catalog_vehicles cv ON c.catalog_vehicle_id = cv.id
|
||||||
|
LEFT JOIN vehicles v ON c.vehicle_id = v.id
|
||||||
|
LEFT JOIN categories p ON p.id = c.parent_id
|
||||||
|
WHERE c.canonical_category_id IS NOT NULL
|
||||||
|
AND coalesce(cv.brand_name, v.brand_name) IS NOT NULL
|
||||||
|
AND (c.parent_id IS NULL OR p.canonical_category_id IS DISTINCT FROM c.canonical_category_id
|
||||||
|
OR (p.parent_id IS NULL AND p.canonical_category_id = c.canonical_category_id))
|
||||||
|
GROUP BY c.source, coalesce(cv.brand_name, v.brand_name),
|
||||||
|
c.canonical_category_id, c.canonical_subcategory_id,
|
||||||
|
btrim(regexp_replace(translate(lower(c.name),'çşğıöü','csgiou'),'\\s+',' ','g'))
|
||||||
|
HAVING count(DISTINCT coalesce(c.catalog_vehicle_id, c.vehicle_id)) >= 2
|
||||||
|
RETURNING 1
|
||||||
|
)
|
||||||
|
SELECT count(*)::int AS n FROM ins
|
||||||
|
`);
|
||||||
|
console.log(`[canonical-backfill] template entries rebuilt — ${tent[0]?.n ?? 0} rows`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[canonical-backfill] done — ${rows.length} map rows (${stats.subMapped} with sub, ${stats.mapRowsPruned} pruned), ${stats.categoriesUpdated} materialized, ${stats.inherited} inherited, ${stats.subcategoriesUpdated} sub-categorized`,
|
||||||
|
);
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
131
apps/api/src/jobs/processors/part-price-refresh.processor.ts
Normal file
131
apps/api/src/jobs/processors/part-price-refresh.processor.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { Job } from "bullmq";
|
||||||
|
import { asc, inArray, sql } from "drizzle-orm";
|
||||||
|
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||||
|
import Redis from "ioredis";
|
||||||
|
import { partPriceDaily, partPriceTracks } from "../../database/schema/core";
|
||||||
|
import {
|
||||||
|
computeStats,
|
||||||
|
filterOffersForBrand,
|
||||||
|
istanbulToday,
|
||||||
|
} from "../../part-prices/part-prices.logic";
|
||||||
|
import { createSupplierPriceSource } from "../../part-prices/supplier-price-source";
|
||||||
|
|
||||||
|
type Database = PostgresJsDatabase<Record<string, unknown>>;
|
||||||
|
|
||||||
|
const TRACKED_CAP = 50_000;
|
||||||
|
const BATCH = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Günlük tedarikçi fiyat tazeleme (19:30 Europe/Istanbul — takip sync'i
|
||||||
|
* ~19:05'te biter):
|
||||||
|
* 1. sku_map artımlı bakım (son 3 günde güncellenen ürünler, INSERT IGNORE).
|
||||||
|
* 2. İzlenen her (kod, marka) için bugünün p50/p95/p99'unu takip.products'tan
|
||||||
|
* hesapla (teklifler markaya süzülür — kısa kodlar markalar arası çakışır),
|
||||||
|
* part_price_daily'ye upsert et (teklif kalmadıysa null-fiyatlı satır —
|
||||||
|
* "stok yok" da seridir).
|
||||||
|
* 3. Tazelenen parçaların Redis cache'ini düşür.
|
||||||
|
* SUPPLIER_PRICE_DB_* yoksa sessiz no-op (dev/prod'da env'le açılır).
|
||||||
|
*/
|
||||||
|
export async function processPartPriceRefresh(
|
||||||
|
job: Job,
|
||||||
|
db: Database,
|
||||||
|
): Promise<{ tracked: number; refreshed: number; skipped: boolean }> {
|
||||||
|
const enabled = process.env.SUPPLIER_PRICE_DB_ENABLED === "true";
|
||||||
|
const url = process.env.SUPPLIER_PRICE_DB_URL;
|
||||||
|
if (!enabled || !url) {
|
||||||
|
console.log(`[part-price-refresh] disabled (enabled=${enabled}, urlSet=${Boolean(url)})`);
|
||||||
|
return { tracked: 0, refreshed: 0, skipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = createSupplierPriceSource(url);
|
||||||
|
const redis = new Redis({
|
||||||
|
host: process.env.REDIS_HOST || "localhost",
|
||||||
|
port: Number(process.env.REDIS_PORT) || 6379,
|
||||||
|
password: process.env.REDIS_PASSWORD || undefined,
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[part-price-refresh] job ${job.id} starting`);
|
||||||
|
await source.refreshSkuMap();
|
||||||
|
|
||||||
|
const tracked = await db
|
||||||
|
.select({ codeNorm: partPriceTracks.codeNorm, brandNorm: partPriceTracks.brandNorm })
|
||||||
|
.from(partPriceTracks)
|
||||||
|
.where(sql`${partPriceTracks.backfilledAt} IS NOT NULL`)
|
||||||
|
.orderBy(asc(partPriceTracks.codeNorm), asc(partPriceTracks.brandNorm))
|
||||||
|
.limit(TRACKED_CAP);
|
||||||
|
const today = istanbulToday();
|
||||||
|
let refreshed = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < tracked.length; i += BATCH) {
|
||||||
|
const chunk = tracked.slice(i, i + BATCH);
|
||||||
|
const codes = [...new Set(chunk.map((t) => t.codeNorm))];
|
||||||
|
const rows = await source.fetchCurrentOfferRows(codes);
|
||||||
|
const byCode = new Map<string, { brandNorm: string; price: number; stock: number }[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
let list = byCode.get(r.codeNorm);
|
||||||
|
if (!list) {
|
||||||
|
list = [];
|
||||||
|
byCode.set(r.codeNorm, list);
|
||||||
|
}
|
||||||
|
list.push({ brandNorm: r.brandNorm, price: r.price, stock: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = chunk.map((t) => {
|
||||||
|
const offers = filterOffersForBrand(byCode.get(t.codeNorm) ?? [], t.brandNorm, t.codeNorm);
|
||||||
|
const stats = computeStats(offers);
|
||||||
|
return {
|
||||||
|
codeNorm: t.codeNorm,
|
||||||
|
brandNorm: t.brandNorm,
|
||||||
|
source: "supplier" as const,
|
||||||
|
date: today,
|
||||||
|
p50: stats.p50 === null ? null : String(stats.p50),
|
||||||
|
p95: stats.p95 === null ? null : String(stats.p95),
|
||||||
|
p99: stats.p99 === null ? null : String(stats.p99),
|
||||||
|
offerCount: stats.offerCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(partPriceDaily)
|
||||||
|
.values(values)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
partPriceDaily.codeNorm,
|
||||||
|
partPriceDaily.brandNorm,
|
||||||
|
partPriceDaily.source,
|
||||||
|
partPriceDaily.date,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
p50: sql`excluded.p50`,
|
||||||
|
p95: sql`excluded.p95`,
|
||||||
|
p99: sql`excluded.p99`,
|
||||||
|
offerCount: sql`excluded.offer_count`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Bileşik anahtarla tek tek değil çift-kolon inArray ile güncellenemez;
|
||||||
|
// chunk küçük (≤300) — kod listesi yeterli (marka varyantları birlikte
|
||||||
|
// tazelenir, fazladan timestamp güncellemesi zararsız).
|
||||||
|
await db
|
||||||
|
.update(partPriceTracks)
|
||||||
|
.set({ lastRefreshedAt: new Date() })
|
||||||
|
.where(inArray(partPriceTracks.codeNorm, codes));
|
||||||
|
|
||||||
|
// Cache düşür — bir sonraki sayfa görüntülemesi taze pg satırını okur.
|
||||||
|
const keys = chunk.flatMap((t) => [
|
||||||
|
`partprice:series:v2:${t.codeNorm}::${t.brandNorm}`,
|
||||||
|
`partprice:cur:v2:${t.codeNorm}::${t.brandNorm}`,
|
||||||
|
]);
|
||||||
|
if (keys.length > 0) await redis.del(...keys);
|
||||||
|
|
||||||
|
refreshed += chunk.length;
|
||||||
|
console.log(`[part-price-refresh] ${refreshed}/${tracked.length} refreshed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tracked: tracked.length, refreshed, skipped: false };
|
||||||
|
} finally {
|
||||||
|
await source.end().catch(() => {});
|
||||||
|
redis.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
109
apps/api/src/jobs/processors/vinpin-decode.processor.ts
Normal file
109
apps/api/src/jobs/processors/vinpin-decode.processor.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { Job } from "bullmq";
|
||||||
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||||
|
import { catalogVehicles, vinpinDecodes } from "../../database/schema/core";
|
||||||
|
import { getVinpinDriver } from "../../integrations/vinpin/vinpin-driver.service";
|
||||||
|
import {
|
||||||
|
type CatalogCandidate,
|
||||||
|
pickBestCatalogMatch,
|
||||||
|
} from "../../integrations/vinpin/vinpin.matcher";
|
||||||
|
|
||||||
|
type Database = PostgresJsDatabase<Record<string, unknown>>;
|
||||||
|
|
||||||
|
export interface VinpinDecodeJobData {
|
||||||
|
vin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vinpin ePER decode-oracle processor (worker, concurrency 1). Drives Vinpin to
|
||||||
|
* identify a Fiat VIN the normal chain couldn't, matches it to an EXISTING PL24
|
||||||
|
* catalog_vehicle, and records the outcome in vinpin_decodes.
|
||||||
|
*
|
||||||
|
* Strict no-op when VINPIN_ENABLED is false (the feature flag) so it can sit in
|
||||||
|
* prod fully wired but inert until the paid seat is ready.
|
||||||
|
*/
|
||||||
|
export async function processVinpinDecode(
|
||||||
|
job: Job<VinpinDecodeJobData>,
|
||||||
|
db: Database,
|
||||||
|
): Promise<{ status: string; catalogVehicleId: string | null; skipped?: boolean }> {
|
||||||
|
const { vin } = job.data;
|
||||||
|
|
||||||
|
if (process.env.VINPIN_ENABLED !== "true") {
|
||||||
|
console.log(`[vinpin-decode] disabled (VINPIN_ENABLED!=true) — job ${job.id} no-op`);
|
||||||
|
return { status: "pending", catalogVehicleId: null, skipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[vinpin-decode] job ${job.id} decoding VIN ${vin}`);
|
||||||
|
|
||||||
|
// Bump attempts up-front so a hard crash still records the effort.
|
||||||
|
await db
|
||||||
|
.update(vinpinDecodes)
|
||||||
|
.set({ attempts: sql`${vinpinDecodes.attempts} + 1`, updatedAt: new Date() })
|
||||||
|
.where(eq(vinpinDecodes.vin, vin));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = await getVinpinDriver().decode(vin);
|
||||||
|
|
||||||
|
if (!decoded) {
|
||||||
|
await db
|
||||||
|
.update(vinpinDecodes)
|
||||||
|
.set({ status: "not_found", updatedAt: new Date() })
|
||||||
|
.where(eq(vinpinDecodes.vin, vin));
|
||||||
|
console.log(`[vinpin-decode] ${vin} → not_found`);
|
||||||
|
return { status: "not_found", catalogVehicleId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match the decoded model to an existing PL24 Fiat catalog_vehicle.
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: catalogVehicles.id,
|
||||||
|
model: catalogVehicles.model,
|
||||||
|
year: catalogVehicles.year,
|
||||||
|
})
|
||||||
|
.from(catalogVehicles)
|
||||||
|
.where(
|
||||||
|
and(sql`lower(${catalogVehicles.brandName}) = 'fiat'`, eq(catalogVehicles.source, "pl24")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const catalogVehicleId = pickBestCatalogMatch(
|
||||||
|
{ model: decoded.model, modelYear: decoded.modelYear },
|
||||||
|
rows as CatalogCandidate[],
|
||||||
|
);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(vinpinDecodes)
|
||||||
|
.set({
|
||||||
|
status: "decoded",
|
||||||
|
brandName: "Fiat",
|
||||||
|
model: decoded.model,
|
||||||
|
sincom: decoded.sincom,
|
||||||
|
trim: decoded.trim,
|
||||||
|
modelYear: decoded.modelYear,
|
||||||
|
engine: decoded.engine,
|
||||||
|
catalogVehicleId,
|
||||||
|
raw: decoded.raw,
|
||||||
|
decodedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(vinpinDecodes.vin, vin));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[vinpin-decode] ${vin} → decoded model="${decoded.model}" catalog_vehicle=${catalogVehicleId ?? "null"}`,
|
||||||
|
);
|
||||||
|
return { status: "decoded", catalogVehicleId };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`[vinpin-decode] job ${job.id} failed for ${vin}: ${message}`);
|
||||||
|
|
||||||
|
// Mark failed only once the BullMQ attempt budget is exhausted; otherwise let
|
||||||
|
// it retry (status stays 'pending').
|
||||||
|
const isLastAttempt = job.attemptsMade + 1 >= (job.opts.attempts ?? 1);
|
||||||
|
if (isLastAttempt) {
|
||||||
|
await db
|
||||||
|
.update(vinpinDecodes)
|
||||||
|
.set({ status: "failed", updatedAt: new Date() })
|
||||||
|
.where(eq(vinpinDecodes.vin, vin));
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/api/src/jobs/queues/canonical-backfill.queue.ts
Normal file
24
apps/api/src/jobs/queues/canonical-backfill.queue.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Provider } from "@nestjs/common";
|
||||||
|
import { Queue } from "bullmq";
|
||||||
|
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
|
||||||
|
|
||||||
|
export const CANONICAL_BACKFILL_QUEUE = "CANONICAL_BACKFILL_QUEUE";
|
||||||
|
|
||||||
|
// On-demand backfill (no cron): enqueue a job to (re)map raw category names to
|
||||||
|
// the canonical taxonomy. Trigger manually with data { brands?, sources?, dryRun? }.
|
||||||
|
export const CanonicalBackfillQueueProvider: Provider = {
|
||||||
|
provide: CANONICAL_BACKFILL_QUEUE,
|
||||||
|
useFactory: () => {
|
||||||
|
const telemetry = getBullTelemetry();
|
||||||
|
return new Queue(QUEUE_NAMES.CANONICAL_BACKFILL, {
|
||||||
|
connection: getBullConnection(),
|
||||||
|
...(telemetry ? { telemetry } : {}),
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 2,
|
||||||
|
backoff: { type: "fixed", delay: 30000 },
|
||||||
|
removeOnComplete: { count: 50 },
|
||||||
|
removeOnFail: { count: 100 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { Queue } from "bullmq";
|
|||||||
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
|
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
|
||||||
|
|
||||||
export const CATALOG_PREFETCH_QUEUE = "CATALOG_PREFETCH_QUEUE";
|
export const CATALOG_PREFETCH_QUEUE = "CATALOG_PREFETCH_QUEUE";
|
||||||
|
export const CATALOG_PREFETCH_FAST_QUEUE = "CATALOG_PREFETCH_FAST_QUEUE";
|
||||||
|
|
||||||
export const CatalogPrefetchQueueProvider: Provider = {
|
export const CatalogPrefetchQueueProvider: Provider = {
|
||||||
provide: CATALOG_PREFETCH_QUEUE,
|
provide: CATALOG_PREFETCH_QUEUE,
|
||||||
@@ -23,3 +24,20 @@ export const CatalogPrefetchQueueProvider: Provider = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CatalogPrefetchFastQueueProvider: Provider = {
|
||||||
|
provide: CATALOG_PREFETCH_FAST_QUEUE,
|
||||||
|
useFactory: () => {
|
||||||
|
const telemetry = getBullTelemetry();
|
||||||
|
return new Queue(QUEUE_NAMES.CATALOG_PREFETCH_FAST, {
|
||||||
|
connection: getBullConnection(),
|
||||||
|
...(telemetry ? { telemetry } : {}),
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: { type: "exponential", delay: 30000 },
|
||||||
|
removeOnComplete: { count: 1000 },
|
||||||
|
removeOnFail: { count: 5000 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
25
apps/api/src/jobs/queues/part-price-refresh.queue.ts
Normal file
25
apps/api/src/jobs/queues/part-price-refresh.queue.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Provider } from "@nestjs/common";
|
||||||
|
import { Queue } from "bullmq";
|
||||||
|
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
|
||||||
|
|
||||||
|
export const PART_PRICE_REFRESH_QUEUE = "PART_PRICE_REFRESH_QUEUE";
|
||||||
|
|
||||||
|
export const PartPriceRefreshQueueProvider: Provider = {
|
||||||
|
provide: PART_PRICE_REFRESH_QUEUE,
|
||||||
|
useFactory: () => {
|
||||||
|
const telemetry = getBullTelemetry();
|
||||||
|
return new Queue(QUEUE_NAMES.PART_PRICE_REFRESH, {
|
||||||
|
connection: getBullConnection(),
|
||||||
|
...(telemetry ? { telemetry } : {}),
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: "exponential",
|
||||||
|
delay: 60000,
|
||||||
|
},
|
||||||
|
removeOnComplete: { count: 30 },
|
||||||
|
removeOnFail: { count: 100 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
26
apps/api/src/jobs/queues/vinpin-decode.queue.ts
Normal file
26
apps/api/src/jobs/queues/vinpin-decode.queue.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Provider } from "@nestjs/common";
|
||||||
|
import { Queue } from "bullmq";
|
||||||
|
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
|
||||||
|
|
||||||
|
export const VINPIN_DECODE_QUEUE = "VINPIN_DECODE_QUEUE";
|
||||||
|
|
||||||
|
/** Vinpin ePER decode-oracle queue. Single Vinpin seat → the worker consumes
|
||||||
|
* this with concurrency 1. Jobs carry just `{ vin }`. */
|
||||||
|
export const VinpinDecodeQueueProvider: Provider = {
|
||||||
|
provide: VINPIN_DECODE_QUEUE,
|
||||||
|
useFactory: () => {
|
||||||
|
const telemetry = getBullTelemetry();
|
||||||
|
return new Queue(QUEUE_NAMES.VINPIN_DECODE, {
|
||||||
|
connection: getBullConnection(),
|
||||||
|
...(telemetry ? { telemetry } : {}),
|
||||||
|
defaultJobOptions: {
|
||||||
|
// The driver itself retries (VINPIN_DECODE_MAX_ATTEMPTS) before reporting
|
||||||
|
// not_found, so the queue keeps a small attempt budget for hard crashes.
|
||||||
|
attempts: 2,
|
||||||
|
backoff: { type: "exponential", delay: 30_000 },
|
||||||
|
removeOnComplete: { count: 50 },
|
||||||
|
removeOnFail: { count: 100 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -4,10 +4,12 @@ import "./telemetry/tracing"; // MUST be early — instruments modules before th
|
|||||||
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
|
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
import { fileUploadValidation } from "./common/middleware/file-upload-validation.middleware";
|
import { fileUploadValidation } from "./common/middleware/file-upload-validation.middleware";
|
||||||
|
import { PublicApiModule } from "./public-api/public-api.module";
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||||
@@ -18,6 +20,32 @@ async function bootstrap() {
|
|||||||
|
|
||||||
app.setGlobalPrefix("api", { exclude: ["/collect/(.*)"] });
|
app.setGlobalPrefix("api", { exclude: ["/collect/(.*)"] });
|
||||||
|
|
||||||
|
// api.sase.tr kısa yolları: müşteriye dokümante edilen taban
|
||||||
|
// https://api.sase.tr/v1/... — global prefix'i host bazında yeniden yazarak
|
||||||
|
// karşılarız (/v1/* → /api/v1/*, /docs* → /api/v1/docs*). Uzun form
|
||||||
|
// (/api/v1/...) her host'ta çalışmaya devam eder.
|
||||||
|
app.use((req: Request, _res: Response, next: NextFunction) => {
|
||||||
|
const host = (req.headers.host ?? "").toLowerCase();
|
||||||
|
if (host === "api.sase.tr" || host.startsWith("api.")) {
|
||||||
|
if (req.url === "/v1" || req.url.startsWith("/v1/")) {
|
||||||
|
req.url = `/api${req.url}`;
|
||||||
|
} else if (
|
||||||
|
req.url === "/widget" ||
|
||||||
|
req.url.startsWith("/widget?") ||
|
||||||
|
req.url.startsWith("/widget/")
|
||||||
|
) {
|
||||||
|
req.url = `/api${req.url}`;
|
||||||
|
} else if (
|
||||||
|
req.url === "/docs" ||
|
||||||
|
req.url.startsWith("/docs/") ||
|
||||||
|
req.url.startsWith("/docs-json")
|
||||||
|
) {
|
||||||
|
req.url = `/api/v1${req.url}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Security headers
|
// Security headers
|
||||||
app.use(
|
app.use(
|
||||||
helmet({
|
helmet({
|
||||||
@@ -101,6 +129,43 @@ async function bootstrap() {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Public API dokümantasyonu — SADECE PublicApiModule controller'larını
|
||||||
|
// kapsar; dashboard'ın iç API yüzeyi spec'e asla girmez. parts-catalogs
|
||||||
|
// modeli: spec herkese açık (https://api.sase.tr/docs + /docs-json).
|
||||||
|
const swaggerConfig = new DocumentBuilder()
|
||||||
|
.setTitle("sase.tr Public API")
|
||||||
|
.setDescription(
|
||||||
|
[
|
||||||
|
"Şase (VIN) çözümleme ve OEM çapraz-referans API'si.",
|
||||||
|
"",
|
||||||
|
"**Kimlik doğrulama:** Anahtarınızı `Authorization: Bearer sase_live_...` header'ında gönderin.",
|
||||||
|
"**Kota:** Faturalama birimi başarılı araç tanımlamasıdır (decode). Aynı VIN'in aynı gün",
|
||||||
|
"tekrar çözülmesi, aday listesi dönmesi ve OEM sorguları kotadan düşmez. Güncel durum her",
|
||||||
|
"decode cevabındaki `X-Decode-Quota-Limit/Used/Remaining` header'larında raporlanır;",
|
||||||
|
"kota dolduğunda `429` döner ve Türkiye saatiyle gece yarısı yenilenir.",
|
||||||
|
"",
|
||||||
|
"Cevaplar `{ success, data }` zarfıyla sarılıdır.",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
.setVersion("1.0")
|
||||||
|
.addBearerAuth(
|
||||||
|
{
|
||||||
|
type: "http",
|
||||||
|
scheme: "bearer",
|
||||||
|
description: "API anahtarınız (sase_live_... / sase_test_...)",
|
||||||
|
},
|
||||||
|
"apiKey",
|
||||||
|
)
|
||||||
|
.addServer("https://api.sase.tr", "Canlı")
|
||||||
|
.build();
|
||||||
|
const swaggerDoc = SwaggerModule.createDocument(app, swaggerConfig, {
|
||||||
|
include: [PublicApiModule],
|
||||||
|
});
|
||||||
|
SwaggerModule.setup("api/v1/docs", app, swaggerDoc, {
|
||||||
|
customSiteTitle: "sase.tr Public API",
|
||||||
|
swaggerOptions: { persistAuthorization: true },
|
||||||
|
});
|
||||||
|
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
console.log(`API running on http://localhost:${port}`);
|
console.log(`API running on http://localhost:${port}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,9 +159,7 @@ export class EmailPreferencesService {
|
|||||||
async getCategoryState(userId: string, key: NotificationCategoryKey): Promise<boolean> {
|
async getCategoryState(userId: string, key: NotificationCategoryKey): Promise<boolean> {
|
||||||
const cat = NOTIFICATION_CATEGORIES.find((c) => c.key === key);
|
const cat = NOTIFICATION_CATEGORIES.find((c) => c.key === key);
|
||||||
if (!cat) return false;
|
if (!cat) return false;
|
||||||
const states = await Promise.all(
|
const states = await Promise.all(cat.workflows.map((w) => this.isOptedOut(userId, w)));
|
||||||
cat.workflows.map((w) => this.isOptedOut(userId, w)),
|
|
||||||
);
|
|
||||||
return states.length > 0 && states.every(Boolean);
|
return states.length > 0 && states.every(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,11 +155,7 @@ export class NovuService {
|
|||||||
amount: formatTryAmount(opts.amountKurus),
|
amount: formatTryAmount(opts.amountKurus),
|
||||||
...(opts.plan ? { plan: opts.plan } : {}),
|
...(opts.plan ? { plan: opts.plan } : {}),
|
||||||
...(opts.nextBillingDate ? { nextBillingDate: formatTrDate(opts.nextBillingDate) } : {}),
|
...(opts.nextBillingDate ? { nextBillingDate: formatTrDate(opts.nextBillingDate) } : {}),
|
||||||
ctaUrl: buildTrackedUrl(
|
ctaUrl: buildTrackedUrl("payment-success", user.email, webUrl("/dashboard/subscription")),
|
||||||
"payment-success",
|
|
||||||
user.email,
|
|
||||||
webUrl("/dashboard/subscription"),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,11 +167,7 @@ export class NovuService {
|
|||||||
await this.trigger("payment-failed", user, {
|
await this.trigger("payment-failed", user, {
|
||||||
...(opts.amountKurus !== undefined ? { amount: formatTryAmount(opts.amountKurus) } : {}),
|
...(opts.amountKurus !== undefined ? { amount: formatTryAmount(opts.amountKurus) } : {}),
|
||||||
...(opts.retryDate ? { retryDate: formatTrDate(opts.retryDate) } : {}),
|
...(opts.retryDate ? { retryDate: formatTrDate(opts.retryDate) } : {}),
|
||||||
ctaUrl: buildTrackedUrl(
|
ctaUrl: buildTrackedUrl("payment-failed", user.email, webUrl("/dashboard/subscription")),
|
||||||
"payment-failed",
|
|
||||||
user.email,
|
|
||||||
webUrl("/dashboard/subscription"),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,10 +62,7 @@ const NO_UNSUBSCRIBE_WORKFLOWS = new Set<string>([
|
|||||||
"payment-failed",
|
"payment-failed",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function buildUnsubscribeHeaders(
|
function buildUnsubscribeHeaders(workflow: string, subscriberId: string): Record<string, string> {
|
||||||
workflow: string,
|
|
||||||
subscriberId: string,
|
|
||||||
): Record<string, string> {
|
|
||||||
if (NO_UNSUBSCRIBE_WORKFLOWS.has(workflow)) return {};
|
if (NO_UNSUBSCRIBE_WORKFLOWS.has(workflow)) return {};
|
||||||
const targets: string[] = [];
|
const targets: string[] = [];
|
||||||
if (UNSUBSCRIBE_URL_BASE && UNSUBSCRIBE_SECRET) {
|
if (UNSUBSCRIBE_URL_BASE && UNSUBSCRIBE_SECRET) {
|
||||||
@@ -192,9 +189,7 @@ export async function triggerNovu(
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errBody = await res.text().catch(() => "");
|
const errBody = await res.text().catch(() => "");
|
||||||
logger.error(
|
logger.error(`[novu] trigger "${name}" failed: HTTP ${res.status} ${errBody.slice(0, 300)}`);
|
||||||
`[novu] trigger "${name}" failed: HTTP ${res.status} ${errBody.slice(0, 300)}`,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logger.log(`[novu] triggered "${name}" → ${to.email}`);
|
logger.log(`[novu] triggered "${name}" → ${to.email}`);
|
||||||
|
|||||||
@@ -35,12 +35,9 @@ export class UnsubscribeController {
|
|||||||
private readonly preferences: EmailPreferencesService,
|
private readonly preferences: EmailPreferencesService,
|
||||||
config: ConfigService,
|
config: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.secret =
|
this.secret = config.get<string>("UNSUBSCRIBE_SECRET") || process.env.UNSUBSCRIBE_SECRET || "";
|
||||||
config.get<string>("UNSUBSCRIBE_SECRET") || process.env.UNSUBSCRIBE_SECRET || "";
|
|
||||||
if (!this.secret) {
|
if (!this.secret) {
|
||||||
this.logger.warn(
|
this.logger.warn("UNSUBSCRIBE_SECRET is unset — all one-click requests will be rejected");
|
||||||
"UNSUBSCRIBE_SECRET is unset — all one-click requests will be rejected",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +73,10 @@ export class UnsubscribeController {
|
|||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ok = await this.applyOptOut(userId, workflow, token, "manual_link");
|
const ok = await this.applyOptOut(userId, workflow, token, "manual_link");
|
||||||
res.status(ok ? 200 : 400).type("html").send(renderPage(ok, workflow));
|
res
|
||||||
|
.status(ok ? 200 : 400)
|
||||||
|
.type("html")
|
||||||
|
.send(renderPage(ok, workflow));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared validation + DB update. Returns false on bad token / bad workflow. */
|
/** Shared validation + DB update. Returns false on bad token / bad workflow. */
|
||||||
|
|||||||
30
apps/api/src/part-prices/part-prices.controller.ts
Normal file
30
apps/api/src/part-prices/part-prices.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
|
||||||
|
import { PartPricesService } from "./part-prices.service";
|
||||||
|
|
||||||
|
@Controller("part-prices")
|
||||||
|
export class PartPricesController {
|
||||||
|
constructor(private readonly partPrices: PartPricesService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bir parçanın (kod + opsiyonel marka) tedarikçi fiyat serisi (p50/p95/p99,
|
||||||
|
* günlük değişim noktaları). `GET /part-prices/series?code=27155&brand=FEBI
|
||||||
|
* BILSTEIN` → her durumda 200; eşleşmeme/kapalı kaynak `matched: false`.
|
||||||
|
* Marka verilmezse yalnızca uzun/benzersiz kodlar eşleşir (kısa sayısal
|
||||||
|
* kodlar markalar arası çakışır). İlk istek lazy-backfill yapar.
|
||||||
|
*/
|
||||||
|
@Get("series")
|
||||||
|
async series(@Query("code") code: string, @Query("brand") brand?: string) {
|
||||||
|
return this.partPrices.getSeries(code ?? "", brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sayfada görünen (kod, marka) çiftlerinin güncel istatistikleri.
|
||||||
|
* `POST /part-prices/current-batch { parts: [{ code, brand? }] }` →
|
||||||
|
* `{ prices: { "CODENORM::BRANDNORM": { p50, p95, p99, offerCount } } }` —
|
||||||
|
* eşleşmeyenler haritaya girmez, UI o satıra fiyat çizmez.
|
||||||
|
*/
|
||||||
|
@Post("current-batch")
|
||||||
|
async currentBatch(@Body("parts") parts: Array<{ code?: unknown; brand?: unknown }>) {
|
||||||
|
return this.partPrices.getCurrentBatch(Array.isArray(parts) ? parts : []);
|
||||||
|
}
|
||||||
|
}
|
||||||
196
apps/api/src/part-prices/part-prices.logic.spec.ts
Normal file
196
apps/api/src/part-prices/part-prices.logic.spec.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
type HistoryEvent,
|
||||||
|
type SupplierOffer,
|
||||||
|
allowBrandless,
|
||||||
|
brandCompatible,
|
||||||
|
computeStats,
|
||||||
|
filterOffersForBrand,
|
||||||
|
istanbulToday,
|
||||||
|
normPartCode,
|
||||||
|
percentile,
|
||||||
|
reconstructDailySeries,
|
||||||
|
} from "./part-prices.logic";
|
||||||
|
|
||||||
|
describe("normPartCode", () => {
|
||||||
|
it("uppercases and strips non-alphanumerics (web normCode ile aynı kural)", () => {
|
||||||
|
expect(normPartCode("1j0 973-702")).toBe("1J0973702");
|
||||||
|
expect(normPartCode("62 92 9423")).toBe("62929423");
|
||||||
|
expect(normPartCode(" ")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("percentile", () => {
|
||||||
|
it("interpolates linearly (PERCENTILE_CONT)", () => {
|
||||||
|
expect(percentile([10], 99)).toBe(10);
|
||||||
|
expect(percentile([10, 20], 50)).toBe(15);
|
||||||
|
expect(percentile([1, 2, 3, 4, 5], 50)).toBe(3);
|
||||||
|
expect(percentile([1, 2, 3, 4, 5], 95)).toBeCloseTo(4.8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeStats", () => {
|
||||||
|
it("filters out-of-stock and zero-priced offers", () => {
|
||||||
|
const stats = computeStats([
|
||||||
|
{ price: 100, stock: 2 },
|
||||||
|
{ price: 0, stock: 5 },
|
||||||
|
{ price: 200, stock: 0 },
|
||||||
|
{ price: 300, stock: 1 },
|
||||||
|
]);
|
||||||
|
expect(stats).toEqual({ p50: 200, p95: 290, p99: 298, offerCount: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null prices when nothing is in stock", () => {
|
||||||
|
expect(computeStats([{ price: 100, stock: 0 }])).toEqual({
|
||||||
|
p50: null,
|
||||||
|
p95: null,
|
||||||
|
p99: null,
|
||||||
|
offerCount: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reconstructDailySeries", () => {
|
||||||
|
const W = "2026-01-29";
|
||||||
|
const TODAY = "2026-06-12";
|
||||||
|
|
||||||
|
it("hiç event yoksa güncel teklifleri pencere başından beri sabit sayar", () => {
|
||||||
|
const current: SupplierOffer[] = [
|
||||||
|
{ productId: 1, price: 100, stock: 3 },
|
||||||
|
{ productId: 2, price: 140, stock: 1 },
|
||||||
|
];
|
||||||
|
const series = reconstructDailySeries([], current, W, TODAY);
|
||||||
|
expect(series).toHaveLength(1);
|
||||||
|
expect(series[0]).toMatchObject({ date: W, p50: 120, offerCount: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ilk event'in prev değerleri baseline olur, takibe yeni girenler olmaz", () => {
|
||||||
|
const events: HistoryEvent[] = [
|
||||||
|
// 1 numara fiyat değiştirdi: 100 → 150
|
||||||
|
{ productId: 1, date: "2026-03-01", price: 150, stock: 2, prevPrice: 100, prevStock: 2 },
|
||||||
|
// 2 numara o gün takibe girdi (prev'ler 0) → baseline'da yok
|
||||||
|
{ productId: 2, date: "2026-03-01", price: 200, stock: 1, prevPrice: 0, prevStock: 0 },
|
||||||
|
];
|
||||||
|
const current: SupplierOffer[] = [
|
||||||
|
{ productId: 1, price: 150, stock: 2 },
|
||||||
|
{ productId: 2, price: 200, stock: 1 },
|
||||||
|
];
|
||||||
|
const series = reconstructDailySeries(events, current, W, TODAY);
|
||||||
|
expect(series[0]).toMatchObject({ date: W, p50: 100, offerCount: 1 });
|
||||||
|
expect(series[1]).toMatchObject({ date: "2026-03-01", p50: 175, offerCount: 2 });
|
||||||
|
// current son durumla aynı → bugün için fazladan nokta yok
|
||||||
|
expect(series).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stok bitişini null-fiyatlı nokta olarak işler ve aynı istatistiği tekrarlamaz", () => {
|
||||||
|
const events: HistoryEvent[] = [
|
||||||
|
{ productId: 1, date: "2026-02-10", price: 100, stock: 0, prevPrice: 100, prevStock: 5 },
|
||||||
|
// istatistiği değiştirmeyen event (fiyat aynı, hâlâ stok dışı)
|
||||||
|
{ productId: 1, date: "2026-02-20", price: 110, stock: 0, prevPrice: 100, prevStock: 0 },
|
||||||
|
];
|
||||||
|
const current: SupplierOffer[] = [{ productId: 1, price: 110, stock: 0 }];
|
||||||
|
const series = reconstructDailySeries(events, current, W, TODAY);
|
||||||
|
expect(series[0]).toMatchObject({ date: W, p50: 100, offerCount: 1 });
|
||||||
|
expect(series[1]).toMatchObject({ date: "2026-02-10", p50: null, offerCount: 0 });
|
||||||
|
expect(series).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("current ile history çelişirse bugünü current'a göre düzeltir", () => {
|
||||||
|
const events: HistoryEvent[] = [
|
||||||
|
{ productId: 1, date: "2026-04-01", price: 90, stock: 1, prevPrice: 80, prevStock: 1 },
|
||||||
|
];
|
||||||
|
// history'nin bilmediği daha taze gerçek
|
||||||
|
const current: SupplierOffer[] = [{ productId: 1, price: 95, stock: 2 }];
|
||||||
|
const series = reconstructDailySeries(events, current, W, TODAY);
|
||||||
|
const last = series[series.length - 1];
|
||||||
|
expect(last).toMatchObject({ date: TODAY, p50: 95, offerCount: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aynı güne birden çok event tek nokta üretir", () => {
|
||||||
|
const events: HistoryEvent[] = [
|
||||||
|
{ productId: 1, date: "2026-05-05", price: 100, stock: 1, prevPrice: 0, prevStock: 0 },
|
||||||
|
{ productId: 2, date: "2026-05-05", price: 300, stock: 1, prevPrice: 0, prevStock: 0 },
|
||||||
|
];
|
||||||
|
const current: SupplierOffer[] = [
|
||||||
|
{ productId: 1, price: 100, stock: 1 },
|
||||||
|
{ productId: 2, price: 300, stock: 1 },
|
||||||
|
];
|
||||||
|
const series = reconstructDailySeries(events, current, W, TODAY);
|
||||||
|
expect(series).toHaveLength(1);
|
||||||
|
expect(series[0]).toMatchObject({ date: "2026-05-05", p50: 200, offerCount: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("brandCompatible", () => {
|
||||||
|
it("önek ve kısaltmaları tanır", () => {
|
||||||
|
expect(brandCompatible("BOSCH", "BOSCH")).toBe(true);
|
||||||
|
expect(brandCompatible("BCH", "BOSCH")).toBe(true); // sıralı altdizi
|
||||||
|
expect(brandCompatible("B", "BOSCH")).toBe(true); // tek harf önek
|
||||||
|
expect(brandCompatible("BLP", "BLUEPRINT")).toBe(true);
|
||||||
|
expect(brandCompatible("BLUEPRNT", "BLUEPRINT")).toBe(true); // Türkçe ı düşmüş
|
||||||
|
expect(brandCompatible("FEBI", "FEBIBILSTEIN")).toBe(true);
|
||||||
|
expect(brandCompatible("MANN", "MANNFILTER")).toBe(true);
|
||||||
|
expect(brandCompatible("BRA", "IBRAS")).toBe(true); // İBRAŞ → BRA
|
||||||
|
});
|
||||||
|
|
||||||
|
it("alakasız markaları reddeder", () => {
|
||||||
|
expect(brandCompatible("GROS", "FEBIBILSTEIN")).toBe(false);
|
||||||
|
expect(brandCompatible("NIFEA", "FEBIBILSTEIN")).toBe(false);
|
||||||
|
expect(brandCompatible("MAIS", "RENAULT")).toBe(false); // dağıtıcı — uzun-kod fallback'i halleder
|
||||||
|
expect(brandCompatible("", "BOSCH")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("allowBrandless", () => {
|
||||||
|
it("uzun kodlar ve harf+rakam karışımı serbest, kısa salt-sayısal yasak", () => {
|
||||||
|
expect(allowBrandless("8200768913")).toBe(true); // 10 hane OE
|
||||||
|
expect(allowBrandless("46805832")).toBe(true); // 8 hane OE
|
||||||
|
expect(allowBrandless("W7008")).toBe(true); // harf+rakam
|
||||||
|
expect(allowBrandless("0249C6")).toBe(true); // PSA kısa OE, harf içerir
|
||||||
|
expect(allowBrandless("27155")).toBe(false); // kısa salt-sayısal — çakışma sınıfı
|
||||||
|
expect(allowBrandless("615014")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filterOffersForBrand", () => {
|
||||||
|
// 2026-06-12 vakası: 27155 kodu FEBI/GROS/İBRAŞ/NIFEA'da FARKLI parçalar.
|
||||||
|
const offers = [
|
||||||
|
{ brandNorm: "FEBI", price: 160 },
|
||||||
|
{ brandNorm: "GROS", price: 224 },
|
||||||
|
{ brandNorm: "IBR", price: 909 },
|
||||||
|
{ brandNorm: "NIFEA", price: 1229 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("kısa kodda istenen markanın tekliflerine süzer", () => {
|
||||||
|
const out = filterOffersForBrand(offers, "FEBIBILSTEIN", "27155");
|
||||||
|
expect(out).toEqual([{ brandNorm: "FEBI", price: 160 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kısa kod + uyumsuz marka → boş (yanlış veri göstermez)", () => {
|
||||||
|
expect(filterOffersForBrand(offers, "TRW", "27155")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kısa kod + markasız istek → boş", () => {
|
||||||
|
expect(filterOffersForBrand(offers, "", "27155")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uzun kodda markasız istek tüm teklifleri kullanır (OE vakası)", () => {
|
||||||
|
const oe = [
|
||||||
|
{ brandNorm: "MAIS", price: 195 },
|
||||||
|
{ brandNorm: "RENAULT", price: 247 },
|
||||||
|
];
|
||||||
|
expect(filterOffersForBrand(oe, "", "8200768913")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uzun kod + uyumsuz marka etiketi → dağıtıcı fallback'i (tümü)", () => {
|
||||||
|
const oe = [{ brandNorm: "MAIS", price: 195 }];
|
||||||
|
expect(filterOffersForBrand(oe, "RENAULT", "8200768913")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("istanbulToday", () => {
|
||||||
|
it("UTC gece yarısı civarında İstanbul gününü döner", () => {
|
||||||
|
// 2026-06-11 22:30 UTC = 2026-06-12 01:30 İstanbul
|
||||||
|
expect(istanbulToday(new Date("2026-06-11T22:30:00Z"))).toBe("2026-06-12");
|
||||||
|
});
|
||||||
|
});
|
||||||
230
apps/api/src/part-prices/part-prices.logic.ts
Normal file
230
apps/api/src/part-prices/part-prices.logic.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* Tedarikçi fiyat serisi saf mantığı — DB'siz, yan etkisiz.
|
||||||
|
*
|
||||||
|
* Kaynak model (takip MySQL):
|
||||||
|
* - `products` : güncel durum (ürün başına son fiyat + stok; her gün 19:00
|
||||||
|
* İstanbul'da sync biter).
|
||||||
|
* - `product_history`: DEĞİŞİKLİK logu — bir ürünün satırı yalnızca fiyatı
|
||||||
|
* veya stoğu değiştiği gün yazılır (prev_* bir önceki değer;
|
||||||
|
* ürün takibe o gün girdiyse prev'ler 0). Tam-gün snapshot
|
||||||
|
* YOKTUR; günlük durum carry-forward ile kurulur.
|
||||||
|
*
|
||||||
|
* Bir parça kodunun günlük istatistiği: o gün stokta (stock > 0) ve geçerli
|
||||||
|
* fiyatlı (price > 0) tedarikçi tekliflerinin p50/p95/p99'u + teklif sayısı.
|
||||||
|
* Teklif yoksa fiyatlar null, offerCount 0 — "stok yok" da seriye işlenir.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PART_CODE_MIN_NORM_LEN = 5;
|
||||||
|
|
||||||
|
/** Web'in normCode'u ve P servisinin norm'uyla birebir aynı kural:
|
||||||
|
* `1J0 973 702` / `1j0-973-702` → `1J0973702`. Markalar için de kullanılır
|
||||||
|
* ("FEBI BILSTEIN" → FEBIBILSTEIN; Türkçe harfler düşer: "İBRAŞ" → BRA). */
|
||||||
|
export function normPartCode(code: string): string {
|
||||||
|
return (code ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Marka eşleme ──────────────────────────────────────────────────────────
|
||||||
|
// Kısa sayısal kodlar (FEBI 27155 / GROS 27155 / İBRAŞ 27155) markalar arası
|
||||||
|
// çakışır — bunlar FARKLI fiziksel parçalardır; tek havuzda percentile almak
|
||||||
|
// fiyatı anlamsızlaştırır (2026-06-12 vakası). Bu yüzden teklifler taşıdıkları
|
||||||
|
// marka etiketiyle (takip p10 markası ya da sku öneki) istenen markaya
|
||||||
|
// süzülür. Tedarikçiler markayı kısaltarak yazar (BCH/B→Bosch, BLP→Blue
|
||||||
|
// Print, IBR→İbraş) — eşleşme önek VEYA sıralı-altdizi ile yapılır.
|
||||||
|
|
||||||
|
/** a'nın tüm karakterleri b içinde aynı sırayla geçiyor mu (BCH ⊂ BOSCH). */
|
||||||
|
export function inOrderSubsequence(a: string, b: string): boolean {
|
||||||
|
let i = 0;
|
||||||
|
for (const ch of b) {
|
||||||
|
if (ch === a[i]) i++;
|
||||||
|
if (i === a.length) return true;
|
||||||
|
}
|
||||||
|
return i === a.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Teklifin marka etiketi istenen markayla uyumlu mu (ikisi de normalize). */
|
||||||
|
export function brandCompatible(offerBrand: string, requestedBrand: string): boolean {
|
||||||
|
if (!offerBrand || !requestedBrand) return false;
|
||||||
|
if (offerBrand.startsWith(requestedBrand) || requestedBrand.startsWith(offerBrand)) return true;
|
||||||
|
// Kısaltma: en az 2 karakter, istenen markanın sıralı altdizisi (BLP→BLUEPRINT).
|
||||||
|
return offerBrand.length >= 2 && inOrderSubsequence(offerBrand, requestedBrand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marka süzgeci olmadan tüm teklifleri kullanmak güvenli mi? Uzun kodlar
|
||||||
|
* (OE numaraları) pratikte benzersizdir; kısa SALT-SAYISAL kodlar markalar
|
||||||
|
* arası çakışmanın ta kendisidir → markasız gösterilmez. */
|
||||||
|
export function allowBrandless(codeNorm: string): boolean {
|
||||||
|
if (codeNorm.length >= 8) return true;
|
||||||
|
return (
|
||||||
|
codeNorm.length >= PART_CODE_MIN_NORM_LEN && /[A-Z]/.test(codeNorm) && /[0-9]/.test(codeNorm)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teklifleri istenen markaya süz:
|
||||||
|
* - marka istendi → uyumlular; hiçbiri uymuyorsa ve kod markasız-güvenliyse
|
||||||
|
* hepsi (OE/dağıtıcı etiketi vakası: RENAULT istenir, teklifler MAIS taşır);
|
||||||
|
* kısa kodda boş (yanlış veri göstermekten iyidir),
|
||||||
|
* - marka istenmedi → kod markasız-güvenliyse hepsi, değilse boş.
|
||||||
|
*/
|
||||||
|
export function filterOffersForBrand<T extends { brandNorm: string }>(
|
||||||
|
offers: T[],
|
||||||
|
requestedBrandNorm: string,
|
||||||
|
codeNorm: string,
|
||||||
|
): T[] {
|
||||||
|
if (requestedBrandNorm) {
|
||||||
|
const compat = offers.filter((o) => brandCompatible(o.brandNorm, requestedBrandNorm));
|
||||||
|
if (compat.length > 0) return compat;
|
||||||
|
return allowBrandless(codeNorm) ? offers : [];
|
||||||
|
}
|
||||||
|
return allowBrandless(codeNorm) ? offers : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplierOffer {
|
||||||
|
productId: number;
|
||||||
|
price: number;
|
||||||
|
stock: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEvent {
|
||||||
|
productId: number;
|
||||||
|
/** YYYY-MM-DD */
|
||||||
|
date: string;
|
||||||
|
price: number;
|
||||||
|
stock: number;
|
||||||
|
prevPrice: number;
|
||||||
|
prevStock: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyPoint {
|
||||||
|
/** YYYY-MM-DD */
|
||||||
|
date: string;
|
||||||
|
p50: number | null;
|
||||||
|
p95: number | null;
|
||||||
|
p99: number | null;
|
||||||
|
offerCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PERCENTILE_CONT (lineer interpolasyon). `sorted` artan sıralı ve boş değil. */
|
||||||
|
export function percentile(sorted: number[], p: number): number {
|
||||||
|
const n = sorted.length;
|
||||||
|
if (n === 1) return sorted[0];
|
||||||
|
const rank = (p / 100) * (n - 1);
|
||||||
|
const lo = Math.floor(rank);
|
||||||
|
const hi = Math.ceil(rank);
|
||||||
|
if (lo === hi) return sorted[lo];
|
||||||
|
return sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||||
|
|
||||||
|
/** Geçerli (price>0, stock>0) tekliflerden günlük istatistik. Teklif yoksa
|
||||||
|
* null-fiyatlı sıfır satırı döner (seriye "stok yok" olarak işlenir). */
|
||||||
|
export function computeStats(offers: Iterable<{ price: number; stock: number }>): {
|
||||||
|
p50: number | null;
|
||||||
|
p95: number | null;
|
||||||
|
p99: number | null;
|
||||||
|
offerCount: number;
|
||||||
|
} {
|
||||||
|
const prices: number[] = [];
|
||||||
|
for (const o of offers) {
|
||||||
|
if (o.stock > 0 && o.price > 0) prices.push(o.price);
|
||||||
|
}
|
||||||
|
if (prices.length === 0) return { p50: null, p95: null, p99: null, offerCount: 0 };
|
||||||
|
prices.sort((a, b) => a - b);
|
||||||
|
return {
|
||||||
|
p50: round2(percentile(prices, 50)),
|
||||||
|
p95: round2(percentile(prices, 95)),
|
||||||
|
p99: round2(percentile(prices, 99)),
|
||||||
|
offerCount: prices.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameStats = (a: DailyPoint, b: DailyPoint) =>
|
||||||
|
a.p50 === b.p50 && a.p95 === b.p95 && a.p99 === b.p99 && a.offerCount === b.offerCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change-log'dan sıkıştırılmış günlük seri kurar: yalnızca istatistiğin
|
||||||
|
* değiştiği günler için nokta üretir (grafik step-after çizer, ara günler
|
||||||
|
* carry-forward'dur).
|
||||||
|
*
|
||||||
|
* - Bir ürünün ilk event'inden ÖNCEKİ durumu = (prevPrice, prevStock); ürün
|
||||||
|
* takibe o gün girdiyse (prev'ler 0) öncesinde piyasada yok sayılır.
|
||||||
|
* - Hiç event'i olmayan ürünler tracking başından beri değişmemiştir →
|
||||||
|
* güncel değerleri tüm pencere boyunca sabittir.
|
||||||
|
* - Son nokta her zaman `current` (products tablosu = bugünün gerçeği) ile
|
||||||
|
* tutarlı hale getirilir; fark varsa `todayIso` tarihli nokta eklenir.
|
||||||
|
*
|
||||||
|
* `windowStart` = takip'in veri başlangıcı; baseline (event-öncesi) durumun
|
||||||
|
* tarihi olarak kullanılır. Event'ler tarih artan sırada gelmeli (SQL ORDER BY).
|
||||||
|
*/
|
||||||
|
export function reconstructDailySeries(
|
||||||
|
events: HistoryEvent[],
|
||||||
|
current: SupplierOffer[],
|
||||||
|
windowStart: string,
|
||||||
|
todayIso: string,
|
||||||
|
): DailyPoint[] {
|
||||||
|
// Ürün başına güncel durum — event'i olmayan ürünlerin sabit değeri ve
|
||||||
|
// baseline'da "var mı yok mu" kararı için.
|
||||||
|
const state = new Map<number, { price: number; stock: number }>();
|
||||||
|
const hasEvents = new Set<number>();
|
||||||
|
for (const e of events) hasEvents.add(e.productId);
|
||||||
|
for (const c of current) {
|
||||||
|
if (!hasEvents.has(c.productId)) state.set(c.productId, { price: c.price, stock: c.stock });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baseline: event'li ürünler ilk event'lerinin prev değerleriyle başlar
|
||||||
|
// (prev'leri 0 olanlar — takibe sonradan girenler — baseline'da yok).
|
||||||
|
const firstSeen = new Set<number>();
|
||||||
|
for (const e of events) {
|
||||||
|
if (firstSeen.has(e.productId)) continue;
|
||||||
|
firstSeen.add(e.productId);
|
||||||
|
if (e.prevPrice > 0 || e.prevStock > 0) {
|
||||||
|
state.set(e.productId, { price: e.prevPrice, stock: e.prevStock });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const series: DailyPoint[] = [];
|
||||||
|
const emit = (date: string) => {
|
||||||
|
const point: DailyPoint = { date, ...computeStats(state.values()) };
|
||||||
|
const last = series[series.length - 1];
|
||||||
|
if (last && sameStats(last, point)) return;
|
||||||
|
if (last && last.date === date) {
|
||||||
|
series[series.length - 1] = point;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
series.push(point);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Baseline'da en az bir geçerli teklif varsa pencere başına nokta koy.
|
||||||
|
const firstEventDate = events[0]?.date;
|
||||||
|
if (firstEventDate !== windowStart && computeStats(state.values()).offerCount > 0) {
|
||||||
|
emit(windowStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor: string | null = null;
|
||||||
|
for (const e of events) {
|
||||||
|
if (cursor !== null && e.date !== cursor) emit(cursor);
|
||||||
|
cursor = e.date;
|
||||||
|
state.set(e.productId, { price: e.price, stock: e.stock });
|
||||||
|
}
|
||||||
|
if (cursor !== null) emit(cursor);
|
||||||
|
|
||||||
|
// Bugünün gerçeği: products tablosu. History'den türeyen son durumla
|
||||||
|
// çelişiyorsa (ör. history LIMIT'e takıldı ya da sync arası) bugünü düzelt.
|
||||||
|
state.clear();
|
||||||
|
for (const c of current) state.set(c.productId, { price: c.price, stock: c.stock });
|
||||||
|
const todayPoint: DailyPoint = { date: todayIso, ...computeStats(state.values()) };
|
||||||
|
const last = series[series.length - 1];
|
||||||
|
if (!last || !sameStats(last, todayPoint)) {
|
||||||
|
if (last && last.date === todayIso) series[series.length - 1] = todayPoint;
|
||||||
|
else if (todayPoint.offerCount > 0 || last) series.push(todayPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
return series;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bugünün YYYY-MM-DD'si, İstanbul takvimine göre (takip sync'i 19:00 İstanbul
|
||||||
|
* civarı biter; gece yarısı kaymalarında gün etiketi TR gününe sabitlenir). */
|
||||||
|
export function istanbulToday(now: Date = new Date()): string {
|
||||||
|
return now.toLocaleDateString("en-CA", { timeZone: "Europe/Istanbul" });
|
||||||
|
}
|
||||||
16
apps/api/src/part-prices/part-prices.module.ts
Normal file
16
apps/api/src/part-prices/part-prices.module.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PartPricesController } from "./part-prices.controller";
|
||||||
|
import { PartPricesService } from "./part-prices.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parça kodu bazlı tedarikçi fiyat görünümleri (takip MySQL → pg fiyat
|
||||||
|
* geçmişi). SUPPLIER_PRICE_DB_* env'i yokken kendini devre dışı bırakır;
|
||||||
|
* uçlar boş görünüm döner, OEM detay sayfası fiyat bölümünü çizmez.
|
||||||
|
* (Database + Redis modülleri @Global — import gerekmez.)
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [PartPricesController],
|
||||||
|
providers: [PartPricesService],
|
||||||
|
exports: [PartPricesService],
|
||||||
|
})
|
||||||
|
export class PartPricesModule {}
|
||||||
325
apps/api/src/part-prices/part-prices.service.ts
Normal file
325
apps/api/src/part-prices/part-prices.service.ts
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
type OnModuleDestroy,
|
||||||
|
type OnModuleInit,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
|
import { partPriceDaily, partPriceTracks } from "../database/schema/core";
|
||||||
|
import { RedisService } from "../redis/redis.service";
|
||||||
|
import {
|
||||||
|
type DailyPoint,
|
||||||
|
PART_CODE_MIN_NORM_LEN,
|
||||||
|
computeStats,
|
||||||
|
filterOffersForBrand,
|
||||||
|
istanbulToday,
|
||||||
|
normPartCode,
|
||||||
|
reconstructDailySeries,
|
||||||
|
} from "./part-prices.logic";
|
||||||
|
import { type SupplierPriceSource, createSupplierPriceSource } from "./supplier-price-source";
|
||||||
|
|
||||||
|
/** takip tracking'inin veri başlangıcı — baseline noktasının tarihi. */
|
||||||
|
export const SUPPLIER_PRICE_EPOCH = "2026-01-29";
|
||||||
|
|
||||||
|
export interface PartPriceSeriesView {
|
||||||
|
matched: boolean;
|
||||||
|
codeNorm: string;
|
||||||
|
brandNorm: string;
|
||||||
|
currency: "TRY";
|
||||||
|
source: "supplier";
|
||||||
|
series: DailyPoint[];
|
||||||
|
/** Serinin son noktası (güncel alan bunu gösterir). */
|
||||||
|
latest: DailyPoint | null;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartPriceCurrent {
|
||||||
|
p50: number;
|
||||||
|
p95: number;
|
||||||
|
p99: number;
|
||||||
|
offerCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartPriceBatchView {
|
||||||
|
/** `${codeNorm}::${brandNorm}` → güncel istatistik (eşleşmeyenler yok). */
|
||||||
|
prices: Record<string, PartPriceCurrent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERIES_CACHE_TTL = 3600; // 1h — cron 19:30'da tazeler, anahtarları siler
|
||||||
|
const SERIES_MISS_TTL = 1800;
|
||||||
|
const BATCH_CACHE_TTL = 1800;
|
||||||
|
const MAX_BATCH_PARTS = 400;
|
||||||
|
|
||||||
|
export const partPriceKey = (codeNorm: string, brandNorm: string) => `${codeNorm}::${brandNorm}`;
|
||||||
|
const seriesKey = (codeNorm: string, brandNorm: string) =>
|
||||||
|
`partprice:series:v2:${codeNorm}::${brandNorm}`;
|
||||||
|
const currentKey = (codeNorm: string, brandNorm: string) =>
|
||||||
|
`partprice:cur:v2:${codeNorm}::${brandNorm}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parça (kod + marka) bazlı tedarikçi fiyat görünümleri. P-servisi sözleşmesi:
|
||||||
|
* asla throw etmez — kapalı kaynak, kısa kod, bağlantı hatası ve eşleşmeme
|
||||||
|
* hepsi boş görünüme düşer (UI tek empty-state yolu görür).
|
||||||
|
*
|
||||||
|
* Kimlik (kod, marka): kısa sayısal kodlar markalar arası çakışır (FEBI 27155
|
||||||
|
* ≠ GROS 27155 ≠ İBRAŞ 27155 — farklı fiziksel parçalar). Teklifler sku_map'in
|
||||||
|
* marka etiketiyle istenen markaya süzülür (filterOffersForBrand); markasız
|
||||||
|
* sorgu yalnızca uzun/benzersiz kodlarda tüm teklifleri kullanır.
|
||||||
|
*
|
||||||
|
* - Seri: parça ilk kez istendiğinde takip history'sinden lazy-backfill edilir
|
||||||
|
* ve pg'ye (part_price_tracks + part_price_daily) kalıcı yazılır; sonraki
|
||||||
|
* istekler salt-pg okur. Günlük cron izlenen parçalara bugünü ekler.
|
||||||
|
* - Batch (sayfadaki satırlar): canlı MySQL'den tek sorgu + Redis cache;
|
||||||
|
* pg'ye iz BIRAKMAZ (tracking yalnızca seri isteğiyle başlar).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PartPricesService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(PartPricesService.name);
|
||||||
|
private source: SupplierPriceSource | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
@Inject(DATABASE) private readonly db: Database,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
const enabled = this.config.get<boolean>("supplierPrices.enabled");
|
||||||
|
const url = this.config.get<string>("supplierPrices.url");
|
||||||
|
if (!enabled || !url) {
|
||||||
|
this.logger.log(`[part-prices] disabled (enabled=${enabled}, urlSet=${Boolean(url)})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.source = createSupplierPriceSource(url);
|
||||||
|
this.logger.log("[part-prices] supplier price source connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
if (this.source) {
|
||||||
|
await this.source.end().catch(() => {});
|
||||||
|
this.source = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private miss(codeNorm: string, brandNorm: string): PartPriceSeriesView {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
codeNorm,
|
||||||
|
brandNorm,
|
||||||
|
currency: "TRY",
|
||||||
|
source: "supplier",
|
||||||
|
series: [],
|
||||||
|
latest: null,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSeries(rawCode: string, rawBrand?: string): Promise<PartPriceSeriesView> {
|
||||||
|
const codeNorm = normPartCode(rawCode);
|
||||||
|
const brandNorm = normPartCode(rawBrand ?? "");
|
||||||
|
if (codeNorm.length < PART_CODE_MIN_NORM_LEN) return this.miss(codeNorm, brandNorm);
|
||||||
|
|
||||||
|
const cached = await this.redis.getJson<PartPriceSeriesView>(seriesKey(codeNorm, brandNorm));
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// İzlenen parça → pg'den oku (hızlı yol; cron güncel tutuyor).
|
||||||
|
const [track] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(partPriceTracks)
|
||||||
|
.where(
|
||||||
|
and(eq(partPriceTracks.codeNorm, codeNorm), eq(partPriceTracks.brandNorm, brandNorm)),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let view: PartPriceSeriesView;
|
||||||
|
if (track?.backfilledAt) {
|
||||||
|
const rows = await this.db
|
||||||
|
.select()
|
||||||
|
.from(partPriceDaily)
|
||||||
|
.where(
|
||||||
|
and(eq(partPriceDaily.codeNorm, codeNorm), eq(partPriceDaily.brandNorm, brandNorm)),
|
||||||
|
)
|
||||||
|
.orderBy(asc(partPriceDaily.date));
|
||||||
|
const series: DailyPoint[] = rows
|
||||||
|
.filter((r) => r.source === "supplier")
|
||||||
|
.map((r) => ({
|
||||||
|
date: r.date,
|
||||||
|
p50: r.p50 === null ? null : Number(r.p50),
|
||||||
|
p95: r.p95 === null ? null : Number(r.p95),
|
||||||
|
p99: r.p99 === null ? null : Number(r.p99),
|
||||||
|
offerCount: r.offerCount,
|
||||||
|
}));
|
||||||
|
view = {
|
||||||
|
matched: series.length > 0,
|
||||||
|
codeNorm,
|
||||||
|
brandNorm,
|
||||||
|
currency: "TRY",
|
||||||
|
source: "supplier",
|
||||||
|
series,
|
||||||
|
latest: series[series.length - 1] ?? null,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
view = await this.backfill(codeNorm, brandNorm);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redis.setJson(
|
||||||
|
seriesKey(codeNorm, brandNorm),
|
||||||
|
view,
|
||||||
|
view.matched ? SERIES_CACHE_TTL : SERIES_MISS_TTL,
|
||||||
|
);
|
||||||
|
return view;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[part-prices] series failed (code=${codeNorm}): ${(err as Error).message}`);
|
||||||
|
return this.miss(codeNorm, brandNorm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** İlk görüntülenme: takip history'sinden seriyi kur, pg'ye kalıcı yaz. */
|
||||||
|
private async backfill(codeNorm: string, brandNorm: string): Promise<PartPriceSeriesView> {
|
||||||
|
if (!this.source) return this.miss(codeNorm, brandNorm);
|
||||||
|
|
||||||
|
const { rows: mapped, truncated: idsTruncated } =
|
||||||
|
await this.source.fetchMappedProducts(codeNorm);
|
||||||
|
const selected = filterOffersForBrand(mapped, brandNorm, codeNorm);
|
||||||
|
if (selected.length === 0) return this.miss(codeNorm, brandNorm);
|
||||||
|
const ids = selected.map((m) => m.productId);
|
||||||
|
|
||||||
|
const [{ events, truncated: histTruncated }, current] = await Promise.all([
|
||||||
|
this.source.fetchHistory(ids),
|
||||||
|
this.source.fetchCurrentOffers(ids),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const series = reconstructDailySeries(events, current, SUPPLIER_PRICE_EPOCH, istanbulToday());
|
||||||
|
if (series.length === 0) return this.miss(codeNorm, brandNorm);
|
||||||
|
|
||||||
|
// Kalıcılaştır — yarış durumunda (iki istek aynı anda backfill eder)
|
||||||
|
// ON CONFLICT'ler ikinci yazımı sessizce yutar.
|
||||||
|
await this.db
|
||||||
|
.insert(partPriceTracks)
|
||||||
|
.values({ codeNorm, brandNorm, backfilledAt: new Date() })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [partPriceTracks.codeNorm, partPriceTracks.brandNorm],
|
||||||
|
set: { backfilledAt: new Date() },
|
||||||
|
});
|
||||||
|
const rows = series.map((pt) => ({
|
||||||
|
codeNorm,
|
||||||
|
brandNorm,
|
||||||
|
source: "supplier" as const,
|
||||||
|
date: pt.date,
|
||||||
|
p50: pt.p50 === null ? null : String(pt.p50),
|
||||||
|
p95: pt.p95 === null ? null : String(pt.p95),
|
||||||
|
p99: pt.p99 === null ? null : String(pt.p99),
|
||||||
|
offerCount: pt.offerCount,
|
||||||
|
}));
|
||||||
|
for (let i = 0; i < rows.length; i += 500) {
|
||||||
|
await this.db
|
||||||
|
.insert(partPriceDaily)
|
||||||
|
.values(rows.slice(i, i + 500))
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
codeNorm,
|
||||||
|
brandNorm,
|
||||||
|
currency: "TRY",
|
||||||
|
source: "supplier",
|
||||||
|
series,
|
||||||
|
latest: series[series.length - 1] ?? null,
|
||||||
|
truncated: idsTruncated || histTruncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sayfadaki (kod, marka) çiftleri için güncel istatistik. Canlı MySQL +
|
||||||
|
* Redis; pg'ye dokunmaz. Kaynak kapalı/ulaşılamaz → boş harita (fail-open).
|
||||||
|
*/
|
||||||
|
async getCurrentBatch(
|
||||||
|
rawParts: Array<{ code?: unknown; brand?: unknown }>,
|
||||||
|
): Promise<PartPriceBatchView> {
|
||||||
|
const parts = new Map<string, { codeNorm: string; brandNorm: string }>();
|
||||||
|
for (const p of Array.isArray(rawParts) ? rawParts : []) {
|
||||||
|
if (typeof p?.code !== "string") continue;
|
||||||
|
const codeNorm = normPartCode(p.code);
|
||||||
|
const brandNorm = typeof p.brand === "string" ? normPartCode(p.brand) : "";
|
||||||
|
if (codeNorm.length < PART_CODE_MIN_NORM_LEN || codeNorm.length > 64) continue;
|
||||||
|
const key = partPriceKey(codeNorm, brandNorm);
|
||||||
|
if (!parts.has(key)) parts.set(key, { codeNorm, brandNorm });
|
||||||
|
if (parts.size >= MAX_BATCH_PARTS) break;
|
||||||
|
}
|
||||||
|
if (parts.size === 0) return { prices: {} };
|
||||||
|
|
||||||
|
const prices: Record<string, PartPriceCurrent> = {};
|
||||||
|
const pending: Array<{ key: string; codeNorm: string; brandNorm: string }> = [];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
[...parts.entries()].map(async ([key, p]) => {
|
||||||
|
const hit = await this.redis.getJson<PartPriceCurrent | { miss: true }>(
|
||||||
|
currentKey(p.codeNorm, p.brandNorm),
|
||||||
|
);
|
||||||
|
if (hit === null) {
|
||||||
|
pending.push({ key, ...p });
|
||||||
|
} else if (!("miss" in hit)) {
|
||||||
|
prices[key] = hit;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pending.length > 0 && this.source) {
|
||||||
|
try {
|
||||||
|
const codes = [...new Set(pending.map((p) => p.codeNorm))];
|
||||||
|
const rows = await this.source.fetchCurrentOfferRows(codes);
|
||||||
|
const byCode = new Map<string, { brandNorm: string; price: number; stock: number }[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
let list = byCode.get(r.codeNorm);
|
||||||
|
if (!list) {
|
||||||
|
list = [];
|
||||||
|
byCode.set(r.codeNorm, list);
|
||||||
|
}
|
||||||
|
list.push({ brandNorm: r.brandNorm, price: r.price, stock: 1 });
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
pending.map(async (p) => {
|
||||||
|
const offers = filterOffersForBrand(
|
||||||
|
byCode.get(p.codeNorm) ?? [],
|
||||||
|
p.brandNorm,
|
||||||
|
p.codeNorm,
|
||||||
|
);
|
||||||
|
const stats = computeStats(offers);
|
||||||
|
if (stats.p50 !== null) {
|
||||||
|
const current: PartPriceCurrent = {
|
||||||
|
p50: stats.p50,
|
||||||
|
p95: stats.p95 as number,
|
||||||
|
p99: stats.p99 as number,
|
||||||
|
offerCount: stats.offerCount,
|
||||||
|
};
|
||||||
|
prices[p.key] = current;
|
||||||
|
await this.redis.setJson(
|
||||||
|
currentKey(p.codeNorm, p.brandNorm),
|
||||||
|
current,
|
||||||
|
BATCH_CACHE_TTL,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.redis.setJson(
|
||||||
|
currentKey(p.codeNorm, p.brandNorm),
|
||||||
|
{ miss: true },
|
||||||
|
BATCH_CACHE_TTL,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[part-prices] batch failed (${pending.length} parts): ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { prices };
|
||||||
|
}
|
||||||
|
}
|
||||||
153
apps/api/src/part-prices/supplier-price-source.ts
Normal file
153
apps/api/src/part-prices/supplier-price-source.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import mysql, { type Pool, type RowDataPacket } from "mysql2/promise";
|
||||||
|
import type { HistoryEvent, SupplierOffer } from "./part-prices.logic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* takip MySQL'ine (Tailscale, read-mostly) framework-bağımsız erişim — Nest
|
||||||
|
* servisi de worker processor'ı da bunu kullanır (Novu istemcisi kalıbı).
|
||||||
|
*
|
||||||
|
* Sorgu yolu: `sku_map(code_norm → product_id, brand_norm)` köprü tablosu (vmi
|
||||||
|
* üzerinde kurulu; tedarikçi SKU'larının "tam / ilk-boşluk-sonrası /
|
||||||
|
* ilk-tire-sonrası" normalize adaylarını, markayı takip p10 kolonundan —
|
||||||
|
* yoksa sku önekinden — alarak indeksler) → `products` (güncel fiyat+stok) ve
|
||||||
|
* `product_history` (değişiklik logu). Tek yazma işi `refreshSkuMap` —
|
||||||
|
* idempotent INSERT IGNORE, son 3 günde güncellenen ürünleri haritaya ekler.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Bir kod çok genel olduğunda (kampanya/jenerik SKU çakışmaları) sorguyu
|
||||||
|
// sınırlamak için tavanlar — aşılırsa seri "truncated" işaretlenir.
|
||||||
|
const MAX_PRODUCTS_PER_CODE = 2000;
|
||||||
|
const MAX_HISTORY_ROWS = 150_000;
|
||||||
|
const MAX_BATCH_OFFER_ROWS = 60_000;
|
||||||
|
|
||||||
|
/** sku_map satırı: ürünün marka etiketiyle birlikte. */
|
||||||
|
export interface MappedProduct {
|
||||||
|
productId: number;
|
||||||
|
brandNorm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfferRow {
|
||||||
|
codeNorm: string;
|
||||||
|
brandNorm: string;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplierPriceSource {
|
||||||
|
fetchMappedProducts(codeNorm: string): Promise<{ rows: MappedProduct[]; truncated: boolean }>;
|
||||||
|
fetchCurrentOffers(ids: number[]): Promise<SupplierOffer[]>;
|
||||||
|
fetchHistory(ids: number[]): Promise<{ events: HistoryEvent[]; truncated: boolean }>;
|
||||||
|
/** Batch: stoktaki tekliflerin (code_norm, brand_norm, price) satırları —
|
||||||
|
* marka süzgeci ve istatistik JS'te (part-prices.logic). */
|
||||||
|
fetchCurrentOfferRows(codeNorms: string[]): Promise<OfferRow[]>;
|
||||||
|
/** sku_map artımlı bakım: son 3 günde güncellenen ürünleri haritaya ekler. */
|
||||||
|
refreshSkuMap(): Promise<void>;
|
||||||
|
end(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSupplierPriceSource(url: string): SupplierPriceSource {
|
||||||
|
const pool: Pool = mysql.createPool({
|
||||||
|
uri: url,
|
||||||
|
connectionLimit: 5,
|
||||||
|
connectTimeout: 10_000,
|
||||||
|
waitForConnections: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
async fetchMappedProducts(codeNorm) {
|
||||||
|
const [rows] = await pool.query<RowDataPacket[]>(
|
||||||
|
"SELECT product_id, brand_norm FROM sku_map WHERE code_norm = ? LIMIT ?",
|
||||||
|
[codeNorm, MAX_PRODUCTS_PER_CODE + 1],
|
||||||
|
);
|
||||||
|
const truncated = rows.length > MAX_PRODUCTS_PER_CODE;
|
||||||
|
return {
|
||||||
|
rows: rows.slice(0, MAX_PRODUCTS_PER_CODE).map((r) => ({
|
||||||
|
productId: Number(r.product_id),
|
||||||
|
brandNorm: String(r.brand_norm ?? ""),
|
||||||
|
})),
|
||||||
|
truncated,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchCurrentOffers(ids) {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const [rows] = await pool.query<RowDataPacket[]>(
|
||||||
|
"SELECT id, price, stock FROM products WHERE id IN (?)",
|
||||||
|
[ids],
|
||||||
|
);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
productId: Number(r.id),
|
||||||
|
price: Number(r.price ?? 0),
|
||||||
|
stock: Number(r.stock ?? 0),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchHistory(ids) {
|
||||||
|
if (ids.length === 0) return { events: [], truncated: false };
|
||||||
|
const [rows] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT product_id, DATE_FORMAT(date, '%Y-%m-%d') AS d, price, stock, prev_price, prev_stock
|
||||||
|
FROM product_history WHERE product_id IN (?) ORDER BY date ASC LIMIT ?`,
|
||||||
|
[ids, MAX_HISTORY_ROWS + 1],
|
||||||
|
);
|
||||||
|
const truncated = rows.length > MAX_HISTORY_ROWS;
|
||||||
|
const events = rows.slice(0, MAX_HISTORY_ROWS).map((r) => ({
|
||||||
|
productId: Number(r.product_id),
|
||||||
|
date: String(r.d),
|
||||||
|
price: Number(r.price ?? 0),
|
||||||
|
stock: Number(r.stock ?? 0),
|
||||||
|
prevPrice: Number(r.prev_price ?? 0),
|
||||||
|
prevStock: Number(r.prev_stock ?? 0),
|
||||||
|
}));
|
||||||
|
return { events, truncated };
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchCurrentOfferRows(codeNorms) {
|
||||||
|
if (codeNorms.length === 0) return [];
|
||||||
|
const [rows] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT m.code_norm, m.brand_norm, p.price
|
||||||
|
FROM sku_map m JOIN products p ON p.id = m.product_id
|
||||||
|
WHERE m.code_norm IN (?) AND p.stock > 0 AND p.price > 0
|
||||||
|
LIMIT ?`,
|
||||||
|
[codeNorms, MAX_BATCH_OFFER_ROWS],
|
||||||
|
);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
codeNorm: String(r.code_norm),
|
||||||
|
brandNorm: String(r.brand_norm ?? ""),
|
||||||
|
price: Number(r.price),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshSkuMap() {
|
||||||
|
// sku → code_norm adayları: tam, ilk boşluk sonrası, ilk tire sonrası;
|
||||||
|
// marka = p10'un son-token-öncesi kısmı, yoksa sku öneki. İlk kurulumla
|
||||||
|
// (vmi /root/build_sku_map.sql) birebir aynı çıkarım kuralı.
|
||||||
|
await pool.query(
|
||||||
|
`INSERT IGNORE INTO sku_map (code_norm, product_id, brand_norm)
|
||||||
|
SELECT c.code_norm, c.pid, c.brand_norm FROM (
|
||||||
|
SELECT UPPER(REGEXP_REPLACE(sku, '[^A-Za-z0-9]', '')) AS code_norm, id AS pid,
|
||||||
|
UPPER(REGEXP_REPLACE(CASE
|
||||||
|
WHEN p10 IS NOT NULL AND p10 LIKE '% %' THEN LEFT(p10, CHAR_LENGTH(p10) - CHAR_LENGTH(SUBSTRING_INDEX(p10, ' ', -1)) - 1)
|
||||||
|
WHEN sku LIKE '% %' THEN SUBSTRING_INDEX(sku, ' ', 1)
|
||||||
|
WHEN sku LIKE '%-%' THEN SUBSTRING_INDEX(sku, '-', 1)
|
||||||
|
ELSE '' END, '[^A-Za-z0-9]', '')) AS brand_norm
|
||||||
|
FROM products WHERE updated_at >= CURDATE() - INTERVAL 3 DAY
|
||||||
|
UNION ALL
|
||||||
|
SELECT UPPER(REGEXP_REPLACE(SUBSTRING(sku, LOCATE(' ', sku) + 1), '[^A-Za-z0-9]', '')), id,
|
||||||
|
UPPER(REGEXP_REPLACE(CASE
|
||||||
|
WHEN p10 IS NOT NULL AND p10 LIKE '% %' THEN LEFT(p10, CHAR_LENGTH(p10) - CHAR_LENGTH(SUBSTRING_INDEX(p10, ' ', -1)) - 1)
|
||||||
|
ELSE SUBSTRING_INDEX(sku, ' ', 1) END, '[^A-Za-z0-9]', ''))
|
||||||
|
FROM products WHERE updated_at >= CURDATE() - INTERVAL 3 DAY AND sku LIKE '% %'
|
||||||
|
UNION ALL
|
||||||
|
SELECT UPPER(REGEXP_REPLACE(SUBSTRING(sku, LOCATE('-', sku) + 1), '[^A-Za-z0-9]', '')), id,
|
||||||
|
UPPER(REGEXP_REPLACE(CASE
|
||||||
|
WHEN p10 IS NOT NULL AND p10 LIKE '% %' THEN LEFT(p10, CHAR_LENGTH(p10) - CHAR_LENGTH(SUBSTRING_INDEX(p10, ' ', -1)) - 1)
|
||||||
|
ELSE SUBSTRING_INDEX(sku, '-', 1) END, '[^A-Za-z0-9]', ''))
|
||||||
|
FROM products WHERE updated_at >= CURDATE() - INTERVAL 3 DAY AND sku LIKE '%-%'
|
||||||
|
) c
|
||||||
|
WHERE CHAR_LENGTH(c.code_norm) BETWEEN 5 AND 64`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async end() {
|
||||||
|
await pool.end();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -24,7 +24,13 @@ export class StripeController {
|
|||||||
@Post("checkout")
|
@Post("checkout")
|
||||||
async createCheckout(
|
async createCheckout(
|
||||||
@CurrentUser() user: AuthedUser,
|
@CurrentUser() user: AuthedUser,
|
||||||
@Body() body: { planKey: string; billingPeriod: "monthly" | "yearly"; brandIds: string[] },
|
@Body()
|
||||||
|
body: {
|
||||||
|
planKey: string;
|
||||||
|
billingPeriod: "monthly" | "yearly";
|
||||||
|
brandIds: string[];
|
||||||
|
discountCode?: string;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
if (!body?.planKey || !body?.billingPeriod) {
|
if (!body?.planKey || !body?.billingPeriod) {
|
||||||
throw new BadRequestException("planKey ve billingPeriod zorunlu");
|
throw new BadRequestException("planKey ve billingPeriod zorunlu");
|
||||||
@@ -35,6 +41,7 @@ export class StripeController {
|
|||||||
body.planKey,
|
body.planKey,
|
||||||
body.billingPeriod,
|
body.billingPeriod,
|
||||||
body.brandIds ?? [],
|
body.brandIds ?? [],
|
||||||
|
body.discountCode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module, forwardRef } from "@nestjs/common";
|
||||||
import { SubscriptionsModule } from "../../subscriptions/subscriptions.module";
|
import { SubscriptionsModule } from "../../subscriptions/subscriptions.module";
|
||||||
import { StripeController } from "./stripe.controller";
|
import { StripeController } from "./stripe.controller";
|
||||||
import { StripeService } from "./stripe.service";
|
import { StripeService } from "./stripe.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SubscriptionsModule],
|
// forwardRef: SubscriptionsModule imports us back (cancel/resume sync
|
||||||
|
// auto-renewal to Stripe; the webhook here activates subscriptions).
|
||||||
|
imports: [forwardRef(() => SubscriptionsModule)],
|
||||||
controllers: [StripeController],
|
controllers: [StripeController],
|
||||||
providers: [StripeService],
|
providers: [StripeService],
|
||||||
exports: [StripeService],
|
exports: [StripeService],
|
||||||
|
|||||||
275
apps/api/src/payments/stripe/stripe.service.spec.ts
Normal file
275
apps/api/src/payments/stripe/stripe.service.spec.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { StripeService } from "./stripe.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the recurring-billing webhook handlers (invoice.paid /
|
||||||
|
* invoice.payment_failed). The service is constructed WITHOUT a Stripe key
|
||||||
|
* (client = null), which the handlers tolerate: the only Stripe API call on
|
||||||
|
* these paths (payment-intent backfill) is fail-open.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Sequenced select mock: call N resolves results[N] (last repeats). */
|
||||||
|
function sequencedSelect(results: unknown[][]) {
|
||||||
|
let i = 0;
|
||||||
|
return vi.fn().mockImplementation(() => {
|
||||||
|
const rows = results[Math.min(i, results.length - 1)];
|
||||||
|
i++;
|
||||||
|
return {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
innerJoin: vi.fn().mockReturnThis(),
|
||||||
|
orderBy: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue(rows),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMocks(selectResults: unknown[][]) {
|
||||||
|
const updateChains: Array<{ set: ReturnType<typeof vi.fn> }> = [];
|
||||||
|
const db = {
|
||||||
|
select: sequencedSelect(selectResults),
|
||||||
|
update: vi.fn().mockImplementation(() => {
|
||||||
|
const chain = {
|
||||||
|
set: vi.fn(),
|
||||||
|
where: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
chain.set.mockReturnValue(chain);
|
||||||
|
updateChains.push(chain);
|
||||||
|
return chain;
|
||||||
|
}),
|
||||||
|
insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }),
|
||||||
|
delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
|
||||||
|
};
|
||||||
|
const posthog = {
|
||||||
|
captureForUser: vi.fn(),
|
||||||
|
capture: vi.fn(),
|
||||||
|
flush: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const novu = {
|
||||||
|
paymentSuccess: vi.fn().mockResolvedValue(undefined),
|
||||||
|
paymentFailed: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const config = { get: vi.fn().mockReturnValue(undefined) }; // no Stripe key → client null
|
||||||
|
const service = new StripeService(
|
||||||
|
db as any,
|
||||||
|
config as any,
|
||||||
|
{} as any, // SubscriptionsService — unused on invoice paths
|
||||||
|
posthog as any,
|
||||||
|
novu as any,
|
||||||
|
);
|
||||||
|
return { service: service as any, db, updateChains, posthog, novu };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERIOD_END_SEC = 1784278800; // 2026-07-17T01:00:00Z
|
||||||
|
const RETRY_AT_SEC = 1781700000;
|
||||||
|
|
||||||
|
function invoiceFixture(over: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "in_1",
|
||||||
|
billing_reason: "subscription_cycle",
|
||||||
|
amount_paid: 5000,
|
||||||
|
amount_due: 5000,
|
||||||
|
next_payment_attempt: RETRY_AT_SEC,
|
||||||
|
parent: {
|
||||||
|
type: "subscription_details",
|
||||||
|
subscription_details: {
|
||||||
|
subscription: "sub_stripe1",
|
||||||
|
metadata: { subscription_id: "our-sub-1", user_id: "user-1" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lines: { data: [{ period: { end: PERIOD_END_SEC } }] },
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSub = {
|
||||||
|
id: "our-sub-1",
|
||||||
|
userId: "user-1",
|
||||||
|
planId: "plan-1",
|
||||||
|
status: "active",
|
||||||
|
billingPeriod: "monthly",
|
||||||
|
stripeSubscriptionId: "sub_stripe1",
|
||||||
|
cancelledAt: null,
|
||||||
|
};
|
||||||
|
const fullPlan = { id: "plan-1", name: "Full Paket", brandCount: 0, priceMonthly: 5000 };
|
||||||
|
const user = { id: "user-1", email: "u@example.com", name: "U" };
|
||||||
|
|
||||||
|
describe("StripeService recurring webhooks", () => {
|
||||||
|
describe("handleInvoicePaid (renewal)", () => {
|
||||||
|
it("extends the paid-through date, records the payment once, counts revenue, mails the receipt", async () => {
|
||||||
|
// selects: resolve sub → dedupe (none) → plan → user (for the mail)
|
||||||
|
const { service, db, updateChains, posthog, novu } = createMocks([
|
||||||
|
[activeSub],
|
||||||
|
[],
|
||||||
|
[fullPlan],
|
||||||
|
[user],
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.handleInvoicePaid(invoiceFixture());
|
||||||
|
|
||||||
|
// end_date extended to Stripe's billing-line period end
|
||||||
|
expect(db.update).toHaveBeenCalledTimes(1);
|
||||||
|
const setArg = updateChains[0].set.mock.calls[0][0] as { endDate?: Date };
|
||||||
|
expect(setArg.endDate?.getTime()).toBe(PERIOD_END_SEC * 1000);
|
||||||
|
|
||||||
|
// exactly one completed payment row, keyed to the invoice
|
||||||
|
expect(db.insert).toHaveBeenCalledTimes(1);
|
||||||
|
const valuesArg = (db.insert.mock.results[0].value as { values: ReturnType<typeof vi.fn> })
|
||||||
|
.values.mock.calls[0][0];
|
||||||
|
expect(valuesArg).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "completed",
|
||||||
|
amount: 5000,
|
||||||
|
stripeInvoiceId: "in_1",
|
||||||
|
userId: "user-1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// renewal revenue + receipt mail (success mail YES, reminder mails never)
|
||||||
|
expect(posthog.captureForUser).toHaveBeenCalledWith(
|
||||||
|
"user-1",
|
||||||
|
"subscription_renewed",
|
||||||
|
expect.objectContaining({ $revenue: 50, amount_kurus: 5000 }),
|
||||||
|
);
|
||||||
|
expect(novu.paymentSuccess).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a webhook retry for an already-recorded invoice", async () => {
|
||||||
|
// selects: resolve sub → dedupe finds the prior payment row
|
||||||
|
const { service, db, posthog, novu } = createMocks([[activeSub], [{ id: "pay-1" }]]);
|
||||||
|
|
||||||
|
await service.handleInvoicePaid(invoiceFixture());
|
||||||
|
|
||||||
|
expect(db.insert).not.toHaveBeenCalled();
|
||||||
|
expect(db.update).not.toHaveBeenCalled();
|
||||||
|
expect(posthog.captureForUser).not.toHaveBeenCalled();
|
||||||
|
expect(novu.paymentSuccess).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscription_create invoices only enrich the checkout's payment row (no double revenue)", async () => {
|
||||||
|
const { service, db, updateChains, posthog, novu } = createMocks([[activeSub]]);
|
||||||
|
|
||||||
|
await service.handleInvoicePaid(invoiceFixture({ billing_reason: "subscription_create" }));
|
||||||
|
|
||||||
|
// single update: invoice id (+ intent when available) onto the payment row
|
||||||
|
expect(db.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(updateChains[0].set).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ stripeInvoiceId: "in_1" }),
|
||||||
|
);
|
||||||
|
expect(db.insert).not.toHaveBeenCalled();
|
||||||
|
expect(posthog.captureForUser).not.toHaveBeenCalled();
|
||||||
|
expect(novu.paymentSuccess).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleInvoiceFailed", () => {
|
||||||
|
it("mails dunning with Stripe's retry date and leaves the paid-through date alone", async () => {
|
||||||
|
// selects: resolve sub → user
|
||||||
|
const { service, db, posthog, novu } = createMocks([[activeSub], [user]]);
|
||||||
|
|
||||||
|
await service.handleInvoiceFailed(invoiceFixture());
|
||||||
|
|
||||||
|
expect(db.update).not.toHaveBeenCalled(); // access keeps running to end_date
|
||||||
|
expect(novu.paymentFailed).toHaveBeenCalledTimes(1);
|
||||||
|
const opts = novu.paymentFailed.mock.calls[0][1] as { retryDate?: Date };
|
||||||
|
expect(opts.retryDate?.getTime()).toBe(RETRY_AT_SEC * 1000);
|
||||||
|
expect(posthog.captureForUser).toHaveBeenCalledWith(
|
||||||
|
"user-1",
|
||||||
|
"payment_failed",
|
||||||
|
expect.objectContaining({ reason: "renewal_charge_failed" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores first-charge failures (checkout flow owns those)", async () => {
|
||||||
|
const { service, posthog, novu } = createMocks([[activeSub]]);
|
||||||
|
|
||||||
|
await service.handleInvoiceFailed(invoiceFixture({ billing_reason: "subscription_create" }));
|
||||||
|
|
||||||
|
expect(novu.paymentFailed).not.toHaveBeenCalled();
|
||||||
|
expect(posthog.captureForUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("StripeService.previewDiscount", () => {
|
||||||
|
const discountRow = (over: Record<string, unknown> = {}) => ({
|
||||||
|
id: "dc-1",
|
||||||
|
code: "SAVE20",
|
||||||
|
stripeCouponId: "co_1",
|
||||||
|
stripePromotionCodeId: "promo_1",
|
||||||
|
discountType: "percentage",
|
||||||
|
percentOff: 20,
|
||||||
|
amountOff: null,
|
||||||
|
duration: "once",
|
||||||
|
durationInMonths: null,
|
||||||
|
maxRedemptions: null,
|
||||||
|
timesRedeemed: 0,
|
||||||
|
expiresAt: null,
|
||||||
|
isActive: true,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
// resolvePlanAmount looks plans up by brandCount; the mocked select ignores the
|
||||||
|
// predicate and returns this row regardless.
|
||||||
|
const planRow = {
|
||||||
|
id: "plan-1",
|
||||||
|
name: "Full",
|
||||||
|
brandCount: 0,
|
||||||
|
priceMonthly: 99900,
|
||||||
|
priceYearly: 999000,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("prices a percentage code against the chosen plan (case-insensitive)", async () => {
|
||||||
|
const { service } = createMocks([[discountRow()], [planRow]]);
|
||||||
|
const r = await service.previewDiscount("save20", "full", "monthly");
|
||||||
|
expect(r).toMatchObject({
|
||||||
|
valid: true,
|
||||||
|
code: "SAVE20",
|
||||||
|
originalAmount: 99900,
|
||||||
|
discountAmount: 19980,
|
||||||
|
finalAmount: 79920,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the yearly price for yearly billing", async () => {
|
||||||
|
const { service } = createMocks([[discountRow()], [planRow]]);
|
||||||
|
const r = await service.previewDiscount("SAVE20", "full", "yearly");
|
||||||
|
expect(r).toMatchObject({
|
||||||
|
originalAmount: 999000,
|
||||||
|
discountAmount: 199800,
|
||||||
|
finalAmount: 799200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps a fixed-amount discount at the plan price (never below zero)", async () => {
|
||||||
|
const { service } = createMocks([
|
||||||
|
[discountRow({ discountType: "fixed", percentOff: null, amountOff: 150000 })],
|
||||||
|
[planRow],
|
||||||
|
]);
|
||||||
|
const r = await service.previewDiscount("BIG", "full", "monthly");
|
||||||
|
expect(r).toMatchObject({ valid: true, discountAmount: 99900, finalAmount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown code", async () => {
|
||||||
|
const { service } = createMocks([[]]);
|
||||||
|
const r = await service.previewDiscount("NOPE", "full", "monthly");
|
||||||
|
expect(r).toEqual({ valid: false, reason: "not_found", message: expect.any(String) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an inactive code", async () => {
|
||||||
|
const { service } = createMocks([[discountRow({ isActive: false })]]);
|
||||||
|
const r = await service.previewDiscount("SAVE20", "full", "monthly");
|
||||||
|
expect(r).toMatchObject({ valid: false, reason: "inactive" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an expired code", async () => {
|
||||||
|
const { service } = createMocks([[discountRow({ expiresAt: new Date(Date.now() - 1000) })]]);
|
||||||
|
const r = await service.previewDiscount("SAVE20", "full", "monthly");
|
||||||
|
expect(r).toMatchObject({ valid: false, reason: "expired" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a code at its redemption cap", async () => {
|
||||||
|
const { service } = createMocks([[discountRow({ maxRedemptions: 5, timesRedeemed: 5 })]]);
|
||||||
|
const r = await service.previewDiscount("SAVE20", "full", "monthly");
|
||||||
|
expect(r).toMatchObject({ valid: false, reason: "exhausted" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,9 +5,10 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
|
forwardRef,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { DATABASE, type Database } from "../../database/database.provider";
|
import { DATABASE, type Database } from "../../database/database.provider";
|
||||||
|
|
||||||
@@ -17,7 +18,19 @@ import { DATABASE, type Database } from "../../database/database.provider";
|
|||||||
type StripeNs = import("stripe/cjs/stripe.core").Stripe;
|
type StripeNs = import("stripe/cjs/stripe.core").Stripe;
|
||||||
type StripeEvent = import("stripe/cjs/stripe.core").Stripe.Event;
|
type StripeEvent = import("stripe/cjs/stripe.core").Stripe.Event;
|
||||||
type CheckoutSession = import("stripe/cjs/stripe.core").Stripe.Checkout.Session;
|
type CheckoutSession = import("stripe/cjs/stripe.core").Stripe.Checkout.Session;
|
||||||
import { payments, plans, userSubscriptions, users } from "../../database/schema/core";
|
type SessionCreateParams = import("stripe/cjs/stripe.core").Stripe.Checkout.SessionCreateParams;
|
||||||
|
type StripeInvoice = import("stripe/cjs/stripe.core").Stripe.Invoice;
|
||||||
|
type StripeSubscriptionObj = import("stripe/cjs/stripe.core").Stripe.Subscription;
|
||||||
|
type CouponCreateParams = import("stripe/cjs/stripe.core").Stripe.CouponCreateParams;
|
||||||
|
import {
|
||||||
|
brands,
|
||||||
|
discountCodes,
|
||||||
|
payments,
|
||||||
|
plans,
|
||||||
|
userBrands,
|
||||||
|
userSubscriptions,
|
||||||
|
users,
|
||||||
|
} from "../../database/schema/core";
|
||||||
import { NovuService } from "../../notifications/novu.service";
|
import { NovuService } from "../../notifications/novu.service";
|
||||||
import { PostHogService } from "../../posthog/posthog.service";
|
import { PostHogService } from "../../posthog/posthog.service";
|
||||||
import { SubscriptionsService } from "../../subscriptions/subscriptions.service";
|
import { SubscriptionsService } from "../../subscriptions/subscriptions.service";
|
||||||
@@ -40,6 +53,9 @@ export class StripeService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(DATABASE) private db: Database,
|
@Inject(DATABASE) private db: Database,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
|
// forwardRef: SubscriptionsService also injects StripeService (cancel/resume
|
||||||
|
// must sync auto-renewal to Stripe), so the two providers are circular.
|
||||||
|
@Inject(forwardRef(() => SubscriptionsService))
|
||||||
private subscriptionsService: SubscriptionsService,
|
private subscriptionsService: SubscriptionsService,
|
||||||
private posthog: PostHogService,
|
private posthog: PostHogService,
|
||||||
private novu: NovuService,
|
private novu: NovuService,
|
||||||
@@ -98,23 +114,13 @@ export class StripeService {
|
|||||||
planKey: string,
|
planKey: string,
|
||||||
billingPeriod: "monthly" | "yearly",
|
billingPeriod: "monthly" | "yearly",
|
||||||
brandIds: string[],
|
brandIds: string[],
|
||||||
|
discountCode?: string,
|
||||||
) {
|
) {
|
||||||
if (!this.stripe) {
|
if (!this.stripe) {
|
||||||
throw new ServiceUnavailableException("Stripe ödeme şu an kullanılamıyor");
|
throw new ServiceUnavailableException("Stripe ödeme şu an kullanılamıyor");
|
||||||
}
|
}
|
||||||
|
|
||||||
const brandCount = PLAN_KEY_TO_BRAND_COUNT[planKey];
|
const { plan, amount } = await this.resolvePlanAmount(planKey, billingPeriod);
|
||||||
if (brandCount === undefined) {
|
|
||||||
throw new BadRequestException(`Geçersiz plan anahtarı: ${planKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [plan] = await this.db
|
|
||||||
.select()
|
|
||||||
.from(plans)
|
|
||||||
.where(and(eq(plans.brandCount, brandCount), eq(plans.isActive, true)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!plan) throw new NotFoundException("Plan bulunamadı");
|
|
||||||
|
|
||||||
const subscription = await this.subscriptionsService.create(userId, {
|
const subscription = await this.subscriptionsService.create(userId, {
|
||||||
planId: plan.id,
|
planId: plan.id,
|
||||||
@@ -126,7 +132,6 @@ export class StripeService {
|
|||||||
await this.subscriptionsService.addBrandsToSubscription(subscription.id, userId, brandIds);
|
await this.subscriptionsService.addBrandsToSubscription(subscription.id, userId, brandIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
const amount = billingPeriod === "yearly" ? plan.priceYearly : plan.priceMonthly;
|
|
||||||
const productName = `${plan.name} (${billingPeriod === "yearly" ? "Yıllık" : "Aylık"})`;
|
const productName = `${plan.name} (${billingPeriod === "yearly" ? "Yıllık" : "Aylık"})`;
|
||||||
|
|
||||||
const [payment] = await this.db
|
const [payment] = await this.db
|
||||||
@@ -141,24 +146,73 @@ export class StripeService {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const session = await this.stripe.checkout.sessions.create({
|
// Reuse the Stripe customer from earlier purchases so the saved card and
|
||||||
mode: "payment",
|
// invoice history stay on one record.
|
||||||
|
const [buyer] = await this.db
|
||||||
|
.select({ stripeCustomerId: users.stripeCustomerId })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// Optional discount code: validate against our mirror (Stripe re-checks the
|
||||||
|
// cap/expiry at redemption). We pass the Stripe promotion code id so the
|
||||||
|
// hosted page shows the discount, and stamp the human code into metadata so
|
||||||
|
// the completed webhook can bump times_redeemed.
|
||||||
|
let discount: { promotionCodeId: string; code: string } | null = null;
|
||||||
|
if (discountCode?.trim()) {
|
||||||
|
const code = discountCode.trim().toUpperCase();
|
||||||
|
const [row] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(discountCodes)
|
||||||
|
.where(eq(discountCodes.code, code))
|
||||||
|
.limit(1);
|
||||||
|
if (!row || !row.isActive || !row.stripePromotionCodeId) {
|
||||||
|
throw new BadRequestException("İndirim kodu geçersiz");
|
||||||
|
}
|
||||||
|
if (row.expiresAt && row.expiresAt.getTime() < Date.now()) {
|
||||||
|
throw new BadRequestException("İndirim kodunun süresi dolmuş");
|
||||||
|
}
|
||||||
|
if (row.maxRedemptions != null && row.timesRedeemed >= row.maxRedemptions) {
|
||||||
|
throw new BadRequestException("İndirim kodu kullanım limitine ulaştı");
|
||||||
|
}
|
||||||
|
discount = { promotionCodeId: row.stripePromotionCodeId, code };
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildParams = (customer: string | null): SessionCreateParams => ({
|
||||||
|
// Recurring billing: Stripe owns the renewal schedule and auto-charges the
|
||||||
|
// saved card each period. No pre-charge reminder mails (product decision) —
|
||||||
|
// only a receipt on success and dunning on failure, both webhook-driven.
|
||||||
|
// Our row's end_date is extended on every paid invoice (handleInvoicePaid);
|
||||||
|
// access keeps gating on end_date, so a failed renewal naturally lapses.
|
||||||
|
mode: "subscription",
|
||||||
// Render Stripe's hosted page in Turkish. The audience is Turkish B2B; a
|
// Render Stripe's hosted page in Turkish. The audience is Turkish B2B; a
|
||||||
// foreign-language checkout is a known abandonment driver (~60% of sessions
|
// foreign-language checkout is a known abandonment driver (~60% of sessions
|
||||||
// reached the page but never started a payment intent).
|
// reached the page but never started a payment intent).
|
||||||
locale: "tr",
|
locale: "tr",
|
||||||
payment_method_types: ["card"],
|
payment_method_types: ["card"],
|
||||||
customer_email: userEmail,
|
...(customer ? { customer } : { customer_email: userEmail }),
|
||||||
|
...(discount ? { discounts: [{ promotion_code: discount.promotionCodeId }] } : {}),
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price_data: {
|
price_data: {
|
||||||
currency: "try",
|
currency: "try",
|
||||||
product_data: { name: productName },
|
product_data: { name: productName },
|
||||||
unit_amount: amount, // already in kuruş (smallest unit)
|
unit_amount: amount, // already in kuruş (smallest unit)
|
||||||
|
recurring: { interval: billingPeriod === "yearly" ? "year" : "month" },
|
||||||
},
|
},
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
subscription_data: {
|
||||||
|
// Mirrored onto every invoice (parent.subscription_details.metadata), so
|
||||||
|
// renewal webhooks can resolve our rows even before/without the
|
||||||
|
// stripe_subscription_id column link.
|
||||||
|
metadata: {
|
||||||
|
subscription_id: subscription.id,
|
||||||
|
user_id: userId,
|
||||||
|
plan_key: planKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
success_url: `${this.successUrl}&session_id={CHECKOUT_SESSION_ID}`,
|
success_url: `${this.successUrl}&session_id={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: this.cancelUrl,
|
cancel_url: this.cancelUrl,
|
||||||
client_reference_id: payment.id,
|
client_reference_id: payment.id,
|
||||||
@@ -168,9 +222,32 @@ export class StripeService {
|
|||||||
user_id: userId,
|
user_id: userId,
|
||||||
plan_key: planKey,
|
plan_key: planKey,
|
||||||
billing_period: billingPeriod,
|
billing_period: billingPeriod,
|
||||||
|
...(discount ? { discount_code: discount.code } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let session: CheckoutSession;
|
||||||
|
try {
|
||||||
|
session = await this.stripe.checkout.sessions.create(
|
||||||
|
buildParams(buyer?.stripeCustomerId ?? null),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// A stored customer deleted on Stripe's side must not brick the user's
|
||||||
|
// checkout forever: clear the stale id and retry with a fresh customer.
|
||||||
|
if (buyer?.stripeCustomerId && String(err).includes("No such customer")) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Stored Stripe customer ${buyer.stripeCustomerId} is gone — clearing and retrying (user=${userId})`,
|
||||||
|
);
|
||||||
|
await this.db
|
||||||
|
.update(users)
|
||||||
|
.set({ stripeCustomerId: null, updatedAt: new Date() })
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
session = await this.stripe.checkout.sessions.create(buildParams(null));
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.db
|
await this.db
|
||||||
.update(payments)
|
.update(payments)
|
||||||
.set({ stripeSessionId: session.id, updatedAt: new Date() })
|
.set({ stripeSessionId: session.id, updatedAt: new Date() })
|
||||||
@@ -222,6 +299,18 @@ export class StripeService {
|
|||||||
await this.handleCheckoutFailed(session, event.type);
|
await this.handleCheckoutFailed(session, event.type);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "invoice.paid": {
|
||||||
|
await this.handleInvoicePaid(event.data.object as StripeInvoice);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "invoice.payment_failed": {
|
||||||
|
await this.handleInvoiceFailed(event.data.object as StripeInvoice);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "customer.subscription.deleted": {
|
||||||
|
await this.handleSubscriptionDeleted(event.data.object as StripeSubscriptionObj);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
this.logger.debug(`Unhandled Stripe event type: ${event.type}`);
|
this.logger.debug(`Unhandled Stripe event type: ${event.type}`);
|
||||||
}
|
}
|
||||||
@@ -258,6 +347,31 @@ export class StripeService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the Stripe linkage this checkout created BEFORE activating, so a
|
||||||
|
// failed activation (webhook retry) never loses it: the recurring
|
||||||
|
// subscription id (renewal invoices resolve through it) and the customer
|
||||||
|
// id (the next checkout reuses the same Stripe customer).
|
||||||
|
const stripeSubId =
|
||||||
|
typeof session.subscription === "string"
|
||||||
|
? session.subscription
|
||||||
|
: (session.subscription?.id ?? null);
|
||||||
|
if (stripeSubId) {
|
||||||
|
await this.db
|
||||||
|
.update(userSubscriptions)
|
||||||
|
.set({ stripeSubscriptionId: stripeSubId, updatedAt: new Date() })
|
||||||
|
.where(eq(userSubscriptions.id, payment.subscriptionId));
|
||||||
|
}
|
||||||
|
const stripeCustomerId =
|
||||||
|
typeof session.customer === "string" ? session.customer : (session.customer?.id ?? null);
|
||||||
|
if (stripeCustomerId) {
|
||||||
|
await this.db
|
||||||
|
.update(users)
|
||||||
|
.set({ stripeCustomerId, updatedAt: new Date() })
|
||||||
|
.where(eq(users.id, payment.userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// In subscription mode the charge lives on the first invoice, so the
|
||||||
|
// session itself carries no payment_intent — handleInvoicePaid backfills it.
|
||||||
const paymentIntentId =
|
const paymentIntentId =
|
||||||
typeof session.payment_intent === "string" ? session.payment_intent : null;
|
typeof session.payment_intent === "string" ? session.payment_intent : null;
|
||||||
|
|
||||||
@@ -269,25 +383,42 @@ export class StripeService {
|
|||||||
// successful activate but failed mark-completed won't double-count revenue.
|
// successful activate but failed mark-completed won't double-count revenue.
|
||||||
const activated = await this.subscriptionsService.activateSubscription(payment.subscriptionId);
|
const activated = await this.subscriptionsService.activateSubscription(payment.subscriptionId);
|
||||||
|
|
||||||
|
// The real charged amount (after any discount) is the session total; persist
|
||||||
|
// it so the payment row, receipt and revenue reflect what was actually paid
|
||||||
|
// rather than the pre-discount price we wrote when creating the session.
|
||||||
|
const chargedAmount = session.amount_total ?? Number(payment.amount);
|
||||||
|
|
||||||
await this.db
|
await this.db
|
||||||
.update(payments)
|
.update(payments)
|
||||||
.set({
|
.set({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
|
amount: chargedAmount,
|
||||||
stripePaymentIntentId: paymentIntentId,
|
stripePaymentIntentId: paymentIntentId,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(payments.id, paymentId));
|
.where(eq(payments.id, paymentId));
|
||||||
|
|
||||||
|
// Bump the mirrored redemption counter for the admin panel. Stripe holds the
|
||||||
|
// authoritative count; this is a cheap mirror, at-most-once per checkout
|
||||||
|
// (the completed-guard above makes this handler idempotent).
|
||||||
|
const usedCode = session.metadata?.discount_code;
|
||||||
|
if (usedCode) {
|
||||||
|
await this.db
|
||||||
|
.update(discountCodes)
|
||||||
|
.set({ timesRedeemed: sql`${discountCodes.timesRedeemed} + 1`, updatedAt: new Date() })
|
||||||
|
.where(eq(discountCodes.code, usedCode));
|
||||||
|
}
|
||||||
|
|
||||||
this.posthog.captureForUser(payment.userId, "payment_success", {
|
this.posthog.captureForUser(payment.userId, "payment_success", {
|
||||||
method: "stripe",
|
method: "stripe",
|
||||||
payment_id: paymentId,
|
payment_id: paymentId,
|
||||||
subscription_id: payment.subscriptionId,
|
subscription_id: payment.subscriptionId,
|
||||||
amount: Number(payment.amount),
|
amount: chargedAmount,
|
||||||
stripe_session_id: session.id,
|
stripe_session_id: session.id,
|
||||||
stripe_payment_intent_id: paymentIntentId,
|
stripe_payment_intent_id: paymentIntentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.notifyPaymentSuccess(payment.userId, Number(payment.amount), activated);
|
await this.notifyPaymentSuccess(payment.userId, chargedAmount, activated);
|
||||||
|
|
||||||
this.logger.log(`Subscription ${payment.subscriptionId} activated via Stripe ${session.id}`);
|
this.logger.log(`Subscription ${payment.subscriptionId} activated via Stripe ${session.id}`);
|
||||||
}
|
}
|
||||||
@@ -382,6 +513,308 @@ export class StripeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve our user_subscriptions row for a subscription invoice: primarily
|
||||||
|
* via the stripe_subscription_id column, falling back to the subscription
|
||||||
|
* metadata we stamp at checkout (covers invoice events that arrive before
|
||||||
|
* checkout.session.completed has linked the column).
|
||||||
|
*/
|
||||||
|
private async resolveOurSubscription(stripeSubId: string | null, ourSubId: string | null) {
|
||||||
|
if (stripeSubId) {
|
||||||
|
const [byColumn] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(userSubscriptions)
|
||||||
|
.where(eq(userSubscriptions.stripeSubscriptionId, stripeSubId))
|
||||||
|
.limit(1);
|
||||||
|
if (byColumn) return byColumn;
|
||||||
|
}
|
||||||
|
if (ourSubId) {
|
||||||
|
const [byMetadata] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(userSubscriptions)
|
||||||
|
.where(eq(userSubscriptions.id, ourSubId))
|
||||||
|
.limit(1);
|
||||||
|
if (byMetadata) return byMetadata;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the PaymentIntent id behind an invoice's charge (webhook payloads
|
||||||
|
* don't embed it). Receipts and panel refunds key on the intent. Best-effort.
|
||||||
|
*/
|
||||||
|
private async fetchInvoicePaymentIntentId(invoiceId: string): Promise<string | null> {
|
||||||
|
if (!this.stripe) return null;
|
||||||
|
try {
|
||||||
|
const inv = await this.stripe.invoices.retrieve(invoiceId, {
|
||||||
|
expand: ["payments.data.payment.payment_intent"],
|
||||||
|
});
|
||||||
|
const paid = inv.payments?.data?.find((p) => p.status === "paid") ?? inv.payments?.data?.[0];
|
||||||
|
const pi = paid?.payment?.payment_intent;
|
||||||
|
return typeof pi === "string" ? pi : (pi?.id ?? null);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`invoice PI fetch failed (${invoiceId}): ${String(err)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* invoice.paid — the heartbeat of recurring billing.
|
||||||
|
*
|
||||||
|
* billing_reason=subscription_create (first charge): activation, revenue and
|
||||||
|
* the receipt mail are owned by checkout.session.completed; here we only
|
||||||
|
* enrich the original payment row (intent + invoice id) and backfill the
|
||||||
|
* subscription link in case events arrived out of order.
|
||||||
|
*
|
||||||
|
* Any other billing_reason (subscription_cycle renewals, updates): extend the
|
||||||
|
* paid-through date, record a completed payment (deduped on invoice id),
|
||||||
|
* count renewal revenue, and mail the receipt.
|
||||||
|
*/
|
||||||
|
private async handleInvoicePaid(invoice: StripeInvoice) {
|
||||||
|
const details = invoice.parent?.subscription_details ?? null;
|
||||||
|
if (!details) return; // not a subscription invoice
|
||||||
|
|
||||||
|
const subRef = details.subscription;
|
||||||
|
const stripeSubId = typeof subRef === "string" ? subRef : (subRef?.id ?? null);
|
||||||
|
const metaSubId = details.metadata?.subscription_id ?? null;
|
||||||
|
|
||||||
|
const sub = await this.resolveOurSubscription(stripeSubId, metaSubId);
|
||||||
|
if (!sub) {
|
||||||
|
this.logger.warn(`invoice.paid ${invoice.id}: no matching subscription (${stripeSubId})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backfill the column link (events can beat checkout.session.completed).
|
||||||
|
if (!sub.stripeSubscriptionId && stripeSubId) {
|
||||||
|
await this.db
|
||||||
|
.update(userSubscriptions)
|
||||||
|
.set({ stripeSubscriptionId: stripeSubId, updatedAt: new Date() })
|
||||||
|
.where(eq(userSubscriptions.id, sub.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.billing_reason === "subscription_create") {
|
||||||
|
const paymentIntentId = await this.fetchInvoicePaymentIntentId(invoice.id);
|
||||||
|
await this.db
|
||||||
|
.update(payments)
|
||||||
|
.set({
|
||||||
|
...(paymentIntentId ? { stripePaymentIntentId: paymentIntentId } : {}),
|
||||||
|
stripeInvoiceId: invoice.id,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(payments.subscriptionId, sub.id), isNull(payments.stripeInvoiceId)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Renewal ──
|
||||||
|
const [already] = await this.db
|
||||||
|
.select({ id: payments.id })
|
||||||
|
.from(payments)
|
||||||
|
.where(eq(payments.stripeInvoiceId, invoice.id))
|
||||||
|
.limit(1);
|
||||||
|
if (already) {
|
||||||
|
this.logger.debug(`invoice.paid ${invoice.id} already recorded — skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
// Paid-through date comes from the invoice line period (Stripe's truth for
|
||||||
|
// the billing window); fall back to now+period if the payload lacks lines.
|
||||||
|
let newEndDate: Date;
|
||||||
|
const periodEndSec = invoice.lines?.data?.[0]?.period?.end;
|
||||||
|
if (periodEndSec) {
|
||||||
|
newEndDate = new Date(periodEndSec * 1000);
|
||||||
|
} else {
|
||||||
|
newEndDate = new Date(now);
|
||||||
|
if (sub.billingPeriod === "yearly") newEndDate.setFullYear(newEndDate.getFullYear() + 1);
|
||||||
|
else newEndDate.setMonth(newEndDate.getMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [plan] = await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1);
|
||||||
|
const recovered = sub.status !== "active" && sub.status !== "cancelled";
|
||||||
|
|
||||||
|
if (!recovered) {
|
||||||
|
// Normal renewal — extend. A 'cancelled' row that still got billed keeps
|
||||||
|
// its status; access is governed by end_date either way.
|
||||||
|
await this.db
|
||||||
|
.update(userSubscriptions)
|
||||||
|
.set({ endDate: newEndDate, updatedAt: now })
|
||||||
|
.where(eq(userSubscriptions.id, sub.id));
|
||||||
|
} else {
|
||||||
|
// Late dunning recovery: the nightly cron already expired the row and
|
||||||
|
// purged its brand grants. Restore access for the freshly paid period.
|
||||||
|
await this.db
|
||||||
|
.update(userSubscriptions)
|
||||||
|
.set({ status: "active", endDate: newEndDate, updatedAt: now })
|
||||||
|
.where(eq(userSubscriptions.id, sub.id));
|
||||||
|
if (plan?.brandCount === 0) {
|
||||||
|
await this.db.delete(userBrands).where(eq(userBrands.subscriptionId, sub.id));
|
||||||
|
const allBrands = await this.db.select().from(brands).where(eq(brands.isActive, true));
|
||||||
|
if (allBrands.length > 0) {
|
||||||
|
await this.db
|
||||||
|
.insert(userBrands)
|
||||||
|
.values(
|
||||||
|
allBrands.map((b) => ({ userId: sub.userId, subscriptionId: sub.id, brandId: b.id })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.error(
|
||||||
|
`invoice.paid ${invoice.id}: sub ${sub.id} recovered from '${sub.status}' but its ` +
|
||||||
|
"brand-plan grants were purged at expiry — re-grant brands manually",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentIntentId = await this.fetchInvoicePaymentIntentId(invoice.id);
|
||||||
|
const amountKurus = invoice.amount_paid ?? 0;
|
||||||
|
await this.db.insert(payments).values({
|
||||||
|
userId: sub.userId,
|
||||||
|
subscriptionId: sub.id,
|
||||||
|
amount: amountKurus,
|
||||||
|
currency: "TRY",
|
||||||
|
method: "stripe",
|
||||||
|
status: "completed",
|
||||||
|
stripeInvoiceId: invoice.id,
|
||||||
|
...(paymentIntentId ? { stripePaymentIntentId: paymentIntentId } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Renewal revenue is realized revenue: counted here as subscription_renewed,
|
||||||
|
// while first-charge revenue stays on subscription_activated — together they
|
||||||
|
// are the only $revenue sources (funnel steps intentionally carry none).
|
||||||
|
this.posthog.captureForUser(sub.userId, "subscription_renewed", {
|
||||||
|
$revenue: amountKurus / 100,
|
||||||
|
currency: "TRY",
|
||||||
|
amount_kurus: amountKurus,
|
||||||
|
mrr: sub.billingPeriod === "yearly" ? amountKurus / 12 / 100 : amountKurus / 100,
|
||||||
|
plan: plan?.name ?? null,
|
||||||
|
plan_id: sub.planId,
|
||||||
|
billing_period: sub.billingPeriod,
|
||||||
|
stripe_invoice_id: invoice.id,
|
||||||
|
recovered,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Receipt mail. Product rule: mail on success, mail on failure, never a
|
||||||
|
// pre-charge reminder.
|
||||||
|
try {
|
||||||
|
const [user] = await this.db
|
||||||
|
.select({ id: users.id, email: users.email, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, sub.userId))
|
||||||
|
.limit(1);
|
||||||
|
if (user) {
|
||||||
|
await this.novu.paymentSuccess(user, {
|
||||||
|
amountKurus,
|
||||||
|
plan: plan?.name ?? null,
|
||||||
|
nextBillingDate: newEndDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`renewal receipt mail failed (user=${sub.userId}): ${String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Renewal recorded: sub ${sub.id} paid ${amountKurus} kuruş through ` +
|
||||||
|
`${newEndDate.toISOString()} (invoice ${invoice.id})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* invoice.payment_failed — a renewal charge bounced. Mail the dunning notice
|
||||||
|
* with Stripe's next retry date. Access is NOT cut here: end_date still
|
||||||
|
* governs, Stripe keeps smart-retrying, and the nightly expiry cron closes
|
||||||
|
* access only when the paid-through date lapses.
|
||||||
|
*/
|
||||||
|
private async handleInvoiceFailed(invoice: StripeInvoice) {
|
||||||
|
const details = invoice.parent?.subscription_details ?? null;
|
||||||
|
if (!details) return;
|
||||||
|
// First-charge failures surface inline in the checkout flow; the
|
||||||
|
// session-expiry handler owns dunning for abandons.
|
||||||
|
if (invoice.billing_reason === "subscription_create") return;
|
||||||
|
|
||||||
|
const subRef = details.subscription;
|
||||||
|
const stripeSubId = typeof subRef === "string" ? subRef : (subRef?.id ?? null);
|
||||||
|
const metaSubId = details.metadata?.subscription_id ?? null;
|
||||||
|
|
||||||
|
const sub = await this.resolveOurSubscription(stripeSubId, metaSubId);
|
||||||
|
if (!sub) {
|
||||||
|
this.logger.warn(
|
||||||
|
`invoice.payment_failed ${invoice.id}: no matching subscription (${stripeSubId})`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryDate = invoice.next_payment_attempt
|
||||||
|
? new Date(invoice.next_payment_attempt * 1000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
this.posthog.captureForUser(sub.userId, "payment_failed", {
|
||||||
|
method: "stripe",
|
||||||
|
subscription_id: sub.id,
|
||||||
|
reason: "renewal_charge_failed",
|
||||||
|
amount: invoice.amount_due ?? null,
|
||||||
|
stripe_invoice_id: invoice.id,
|
||||||
|
next_retry_at: retryDate ? retryDate.toISOString() : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [user] = await this.db
|
||||||
|
.select({ id: users.id, email: users.email, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, sub.userId))
|
||||||
|
.limit(1);
|
||||||
|
if (user) {
|
||||||
|
await this.novu.paymentFailed(user, {
|
||||||
|
amountKurus: invoice.amount_due ?? undefined,
|
||||||
|
retryDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`renewal dunning mail failed (user=${sub.userId}): ${String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.warn(
|
||||||
|
`Renewal charge failed: sub ${sub.id} (invoice ${invoice.id}), ` +
|
||||||
|
`next retry ${retryDate ? retryDate.toISOString() : "none (final)"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* customer.subscription.deleted — Stripe will send no further invoices
|
||||||
|
* (cancel-at-period-end executed, or dunning gave up). Access keeps running
|
||||||
|
* to end_date; the nightly cron flips the row to expired after that.
|
||||||
|
*/
|
||||||
|
private async handleSubscriptionDeleted(subscription: StripeSubscriptionObj) {
|
||||||
|
const [sub] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(userSubscriptions)
|
||||||
|
.where(eq(userSubscriptions.stripeSubscriptionId, subscription.id))
|
||||||
|
.limit(1);
|
||||||
|
if (!sub) return;
|
||||||
|
|
||||||
|
await this.db
|
||||||
|
.update(userSubscriptions)
|
||||||
|
.set({ cancelledAt: sub.cancelledAt ?? new Date(), updatedAt: new Date() })
|
||||||
|
.where(eq(userSubscriptions.id, sub.id));
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Stripe subscription ${subscription.id} deleted — our sub ${sub.id} ` +
|
||||||
|
`(status=${sub.status}) will not renew; access runs out at its end_date`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle auto-renewal on the Stripe subscription backing a recurring plan.
|
||||||
|
* cancel() sets it (no further charges; access runs to end_date), resume()
|
||||||
|
* clears it. Callers skip legacy one-time subs (no Stripe subscription id).
|
||||||
|
*/
|
||||||
|
async setCancelAtPeriodEnd(stripeSubscriptionId: string, cancel: boolean): Promise<void> {
|
||||||
|
if (!this.stripe) {
|
||||||
|
throw new ServiceUnavailableException("Stripe ödeme şu an kullanılamıyor");
|
||||||
|
}
|
||||||
|
await this.stripe.subscriptions.update(stripeSubscriptionId, {
|
||||||
|
cancel_at_period_end: cancel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refund a completed Stripe payment. Called from the InternalAdmin module
|
* Refund a completed Stripe payment. Called from the InternalAdmin module
|
||||||
* via Süper Panel. `amount` is in the smallest currency unit (kuruş for TRY)
|
* via Süper Panel. `amount` is in the smallest currency unit (kuruş for TRY)
|
||||||
@@ -474,4 +907,155 @@ export class StripeService {
|
|||||||
currency: payment.currency,
|
currency: payment.currency,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Plans & discount codes
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Resolve the active plan for a plan key + the amount (kuruş) for the period. */
|
||||||
|
private async resolvePlanAmount(planKey: string, billingPeriod: "monthly" | "yearly") {
|
||||||
|
const brandCount = PLAN_KEY_TO_BRAND_COUNT[planKey];
|
||||||
|
if (brandCount === undefined) {
|
||||||
|
throw new BadRequestException(`Geçersiz plan anahtarı: ${planKey}`);
|
||||||
|
}
|
||||||
|
const [plan] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plans)
|
||||||
|
.where(and(eq(plans.brandCount, brandCount), eq(plans.isActive, true)))
|
||||||
|
.limit(1);
|
||||||
|
if (!plan) throw new NotFoundException("Plan bulunamadı");
|
||||||
|
const amount = billingPeriod === "yearly" ? plan.priceYearly : plan.priceMonthly;
|
||||||
|
return { plan, amount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Stripe Coupon (the discount definition + how long it rides a
|
||||||
|
* subscription) plus a customer-facing Promotion Code (the typed string with
|
||||||
|
* its redemption cap + expiry). Returns the ids for the mirror row.
|
||||||
|
*/
|
||||||
|
async createPromotion(input: {
|
||||||
|
code: string;
|
||||||
|
discountType: "percentage" | "fixed";
|
||||||
|
percentOff?: number;
|
||||||
|
amountOff?: number;
|
||||||
|
duration: "once" | "repeating" | "forever";
|
||||||
|
durationInMonths?: number;
|
||||||
|
maxRedemptions?: number;
|
||||||
|
expiresAt?: Date;
|
||||||
|
}): Promise<{ couponId: string; promotionCodeId: string; code: string }> {
|
||||||
|
if (!this.stripe) {
|
||||||
|
throw new ServiceUnavailableException("Stripe yapılandırılmamış");
|
||||||
|
}
|
||||||
|
|
||||||
|
const couponParams: CouponCreateParams = { duration: input.duration, name: input.code };
|
||||||
|
if (input.duration === "repeating" && input.durationInMonths) {
|
||||||
|
couponParams.duration_in_months = input.durationInMonths;
|
||||||
|
}
|
||||||
|
if (input.discountType === "percentage") {
|
||||||
|
if (input.percentOff !== undefined) couponParams.percent_off = input.percentOff;
|
||||||
|
} else {
|
||||||
|
if (input.amountOff !== undefined) couponParams.amount_off = input.amountOff;
|
||||||
|
couponParams.currency = "try";
|
||||||
|
}
|
||||||
|
const coupon = await this.stripe.coupons.create(couponParams);
|
||||||
|
|
||||||
|
// The 2026-04-22 API references the coupon through a `promotion` wrapper
|
||||||
|
// (type: "coupon") rather than a top-level `coupon` field.
|
||||||
|
const promo = await this.stripe.promotionCodes.create({
|
||||||
|
promotion: { type: "coupon", coupon: coupon.id },
|
||||||
|
code: input.code,
|
||||||
|
...(input.maxRedemptions !== undefined ? { max_redemptions: input.maxRedemptions } : {}),
|
||||||
|
...(input.expiresAt !== undefined
|
||||||
|
? { expires_at: Math.floor(input.expiresAt.getTime() / 1000) }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { couponId: coupon.id, promotionCodeId: promo.id, code: promo.code };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Archive a promotion code so it can no longer be redeemed at checkout. */
|
||||||
|
async deactivatePromotion(promotionCodeId: string): Promise<void> {
|
||||||
|
if (!this.stripe) {
|
||||||
|
throw new ServiceUnavailableException("Stripe yapılandırılmamış");
|
||||||
|
}
|
||||||
|
await this.stripe.promotionCodes.update(promotionCodeId, { active: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authoritative redemption count from Stripe (panel reconciliation). */
|
||||||
|
async getPromotionRedemptions(promotionCodeId: string): Promise<number | null> {
|
||||||
|
if (!this.stripe) return null;
|
||||||
|
try {
|
||||||
|
const promo = await this.stripe.promotionCodes.retrieve(promotionCodeId);
|
||||||
|
return promo.times_redeemed ?? 0;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`promotion retrieve failed (${promotionCodeId}): ${String(err)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a code for the checkout page and compute the discounted total for
|
||||||
|
* the chosen plan. Reads our mirror (so it works without a live Stripe key in
|
||||||
|
* dev); Stripe re-enforces the cap/expiry at redemption.
|
||||||
|
*/
|
||||||
|
async previewDiscount(
|
||||||
|
rawCode: string,
|
||||||
|
planKey: string,
|
||||||
|
billingPeriod: "monthly" | "yearly",
|
||||||
|
): Promise<
|
||||||
|
| { valid: false; reason: string; message: string }
|
||||||
|
| {
|
||||||
|
valid: true;
|
||||||
|
code: string;
|
||||||
|
discountType: "percentage" | "fixed";
|
||||||
|
percentOff: number | null;
|
||||||
|
amountOff: number | null;
|
||||||
|
duration: string;
|
||||||
|
durationInMonths: number | null;
|
||||||
|
originalAmount: number;
|
||||||
|
discountAmount: number;
|
||||||
|
finalAmount: number;
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
const code = (rawCode ?? "").trim().toUpperCase();
|
||||||
|
if (!code) return { valid: false, reason: "empty", message: "Kod boş olamaz" };
|
||||||
|
|
||||||
|
const [row] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(discountCodes)
|
||||||
|
.where(eq(discountCodes.code, code))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) return { valid: false, reason: "not_found", message: "Kod bulunamadı" };
|
||||||
|
if (!row.isActive) {
|
||||||
|
return { valid: false, reason: "inactive", message: "Kod artık geçerli değil" };
|
||||||
|
}
|
||||||
|
if (row.expiresAt && row.expiresAt.getTime() < Date.now()) {
|
||||||
|
return { valid: false, reason: "expired", message: "Kodun süresi dolmuş" };
|
||||||
|
}
|
||||||
|
if (row.maxRedemptions != null && row.timesRedeemed >= row.maxRedemptions) {
|
||||||
|
return { valid: false, reason: "exhausted", message: "Kod kullanım limitine ulaştı" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { amount } = await this.resolvePlanAmount(planKey, billingPeriod);
|
||||||
|
let discountAmount = 0;
|
||||||
|
if (row.discountType === "percentage" && row.percentOff) {
|
||||||
|
discountAmount = Math.round((amount * row.percentOff) / 100);
|
||||||
|
} else if (row.discountType === "fixed" && row.amountOff) {
|
||||||
|
discountAmount = Math.min(row.amountOff, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
code,
|
||||||
|
discountType: row.discountType as "percentage" | "fixed",
|
||||||
|
percentOff: row.percentOff,
|
||||||
|
amountOff: row.amountOff,
|
||||||
|
duration: row.duration,
|
||||||
|
durationInMonths: row.durationInMonths,
|
||||||
|
originalAmount: amount,
|
||||||
|
discountAmount,
|
||||||
|
finalAmount: Math.max(0, amount - discountAmount),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
apps/api/src/public-api/public-api-quota.service.spec.ts
Normal file
67
apps/api/src/public-api/public-api-quota.service.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { HttpException } from "@nestjs/common";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import type { RedisService } from "../redis/redis.service";
|
||||||
|
import { PublicApiQuotaService } from "./public-api-quota.service";
|
||||||
|
|
||||||
|
/** RedisService'in kota servisinin kullandığı alt kümesini in-memory taklit eder. */
|
||||||
|
class FakeRedis {
|
||||||
|
store = new Map<string, string>();
|
||||||
|
|
||||||
|
async get(key: string) {
|
||||||
|
return this.store.get(key) ?? null;
|
||||||
|
}
|
||||||
|
async setNx(key: string, value: string, _ttl: number) {
|
||||||
|
if (this.store.has(key)) return false;
|
||||||
|
this.store.set(key, value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
async incr(key: string) {
|
||||||
|
const next = (Number.parseInt(this.store.get(key) ?? "0", 10) || 0) + 1;
|
||||||
|
this.store.set(key, String(next));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
async expire(_key: string, _ttl: number) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PublicApiQuotaService", () => {
|
||||||
|
let redis: FakeRedis;
|
||||||
|
let service: PublicApiQuotaService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
redis = new FakeRedis();
|
||||||
|
service = new PublicApiQuotaService(redis as unknown as RedisService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveLimit: metadata > env > varsayılan önceliğini uygular", () => {
|
||||||
|
expect(service.resolveLimit({ dailyDecodeLimit: 25 })).toBe(25);
|
||||||
|
expect(service.resolveLimit({ dailyDecodeLimit: "abc" })).toBe(100);
|
||||||
|
expect(service.resolveLimit(null)).toBe(100);
|
||||||
|
process.env.PUBLIC_API_DAILY_DECODE_LIMIT = "40";
|
||||||
|
expect(service.resolveLimit(null)).toBe(40);
|
||||||
|
expect(service.resolveLimit({ dailyDecodeLimit: 25 })).toBe(25);
|
||||||
|
process.env.PUBLIC_API_DAILY_DECODE_LIMIT = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("başarılı decode sayar, aynı VIN aynı gün ikinci kez sayılmaz", async () => {
|
||||||
|
const first = await service.recordDecode("k1", "VF1RFB00X66666666", 10);
|
||||||
|
expect(first.used).toBe(1);
|
||||||
|
const repeat = await service.recordDecode("k1", "VF1RFB00X66666666", 10);
|
||||||
|
expect(repeat.used).toBe(1);
|
||||||
|
const other = await service.recordDecode("k1", "WVWZZZ1KZ6W000001", 10);
|
||||||
|
expect(other.used).toBe(2);
|
||||||
|
expect(other.remaining).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("anahtarlar arası sayaç izole", async () => {
|
||||||
|
await service.recordDecode("k1", "VF1RFB00X66666666", 10);
|
||||||
|
const stateK2 = await service.getState("k2", 10);
|
||||||
|
expect(stateK2.used).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kota dolunca assertNotExhausted 429 fırlatır", async () => {
|
||||||
|
await service.recordDecode("k1", "VF1RFB00X66666666", 1);
|
||||||
|
await expect(service.assertNotExhausted("k1", 1)).rejects.toThrow(HttpException);
|
||||||
|
// Dolmamışken sessizce geçer.
|
||||||
|
await expect(service.assertNotExhausted("k2", 1)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
85
apps/api/src/public-api/public-api-quota.service.ts
Normal file
85
apps/api/src/public-api/public-api-quota.service.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { HttpException, HttpStatus, Injectable } from "@nestjs/common";
|
||||||
|
import { RedisService } from "../redis/redis.service";
|
||||||
|
|
||||||
|
/** Günlük başarılı-decode kotasının anlık durumu (cevap header'larına yazılır). */
|
||||||
|
export interface DecodeQuotaState {
|
||||||
|
limit: number;
|
||||||
|
used: number;
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DAILY_DECODE_LIMIT = 100;
|
||||||
|
/** Sayaçlar gün dönümünden sonra da kısa süre okunabilsin diye 26 saat. */
|
||||||
|
const COUNTER_TTL_SECONDS = 26 * 60 * 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public API'nin faturalama birimi olan "başarılı decode" (identification)
|
||||||
|
* kotası. Kural benchmark'la aynı (Levam/parts-catalogs): sayılan şey başarılı
|
||||||
|
* araç tanımlaması; katalog gezinmesi ve OEM sorguları kotadan düşmez, aynı
|
||||||
|
* anahtarın aynı VIN'i aynı gün tekrar decode etmesi de düşmez (idempotent).
|
||||||
|
*
|
||||||
|
* Limit önceliği: anahtar metadata'sındaki `dailyDecodeLimit` →
|
||||||
|
* PUBLIC_API_DAILY_DECODE_LIMIT env → 100.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PublicApiQuotaService {
|
||||||
|
constructor(private readonly redis: RedisService) {}
|
||||||
|
|
||||||
|
resolveLimit(metadata: Record<string, unknown> | null | undefined): number {
|
||||||
|
const fromKey = metadata?.dailyDecodeLimit;
|
||||||
|
if (typeof fromKey === "number" && Number.isFinite(fromKey) && fromKey > 0) {
|
||||||
|
return Math.floor(fromKey);
|
||||||
|
}
|
||||||
|
const fromEnv = Number.parseInt(process.env.PUBLIC_API_DAILY_DECODE_LIMIT ?? "", 10);
|
||||||
|
if (Number.isFinite(fromEnv) && fromEnv > 0) return fromEnv;
|
||||||
|
return DEFAULT_DAILY_DECODE_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getState(keyId: string, limit: number): Promise<DecodeQuotaState> {
|
||||||
|
const used = Number.parseInt((await this.redis.get(this.counterKey(keyId))) ?? "0", 10) || 0;
|
||||||
|
return { limit, used, remaining: Math.max(0, limit - used) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode çağrısından ÖNCE: kota doluysa 429 fırlatır. */
|
||||||
|
async assertNotExhausted(keyId: string, limit: number): Promise<void> {
|
||||||
|
const state = await this.getState(keyId, limit);
|
||||||
|
if (state.used >= state.limit) {
|
||||||
|
throw new HttpException(
|
||||||
|
{
|
||||||
|
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
message: `Günlük decode kotanız doldu (${state.limit}). Kota her gün Türkiye saatiyle gece yarısı yenilenir.`,
|
||||||
|
error: "DAILY_DECODE_QUOTA_EXCEEDED",
|
||||||
|
},
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Başarılı decode SONRASI: bu anahtar bu VIN'i bugün ilk kez çözdüyse
|
||||||
|
* sayacı artırır. Güncel durumu döner.
|
||||||
|
*/
|
||||||
|
async recordDecode(keyId: string, vin: string, limit: number): Promise<DecodeQuotaState> {
|
||||||
|
const day = this.currentDay();
|
||||||
|
const isFirstToday = await this.redis.setNx(
|
||||||
|
`pubapi:vin:${keyId}:${day}:${vin}`,
|
||||||
|
"1",
|
||||||
|
COUNTER_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
if (isFirstToday) {
|
||||||
|
const counter = this.counterKey(keyId);
|
||||||
|
const used = await this.redis.incr(counter);
|
||||||
|
if (used === 1) await this.redis.expire(counter, COUNTER_TTL_SECONDS);
|
||||||
|
}
|
||||||
|
return this.getState(keyId, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private counterKey(keyId: string): string {
|
||||||
|
return `pubapi:quota:${keyId}:${this.currentDay()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kota günü Türkiye gününe göre döner (müşteriler TR). */
|
||||||
|
private currentDay(): string {
|
||||||
|
return new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Istanbul" });
|
||||||
|
}
|
||||||
|
}
|
||||||
465
apps/api/src/public-api/public-api.controller.ts
Normal file
465
apps/api/src/public-api/public-api.controller.ts
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import type { Response } from "express";
|
||||||
|
import { CatalogService } from "../catalog/catalog.service";
|
||||||
|
import { CategoriesService } from "../categories/categories.service";
|
||||||
|
import { Public } from "../common/decorators/public.decorator";
|
||||||
|
import { ApiKeyGuard, type VerifiedApiKey } from "../common/guards/api-key.guard";
|
||||||
|
import { VinValidationPipe } from "../common/pipes/vin-validation.pipe";
|
||||||
|
import { PSourceDbService } from "../integrations/p/p-source-db.service";
|
||||||
|
import { VehiclesService } from "../vehicles/vehicles.service";
|
||||||
|
import { type DecodeQuotaState, PublicApiQuotaService } from "./public-api-quota.service";
|
||||||
|
import { sanitizeCanonicalTree, sanitizeRawTree } from "./public-api.sanitize";
|
||||||
|
|
||||||
|
interface ApiKeyRequest {
|
||||||
|
user: { id: string };
|
||||||
|
apiKey: VerifiedApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public API v1 — API anahtarıyla (Authorization: Bearer sase_...) erişilen,
|
||||||
|
* müşteri entegrasyonlarına açık yüzey. Dashboard'ın cookie'li API'sinden
|
||||||
|
* bilinçli olarak ayrı ve dar tutulur; cevap kontratları dashboard'takiyle
|
||||||
|
* aynıdır (decode slim kontratı: sadece id/brandName/model/year + opak aday
|
||||||
|
* anahtarları — kaynak/rawData asla dönmez).
|
||||||
|
*
|
||||||
|
* Kota: başarılı decode (identification) günlük kotadan düşer; aday listesi
|
||||||
|
* dönmesi, hata ve OEM sorguları düşmez. Durum her decode cevabında
|
||||||
|
* X-Decode-Quota-* header'larında raporlanır.
|
||||||
|
*/
|
||||||
|
@Controller("v1")
|
||||||
|
@Public()
|
||||||
|
@UseGuards(ApiKeyGuard)
|
||||||
|
@ApiBearerAuth("apiKey")
|
||||||
|
@ApiResponse({ status: 401, description: "API anahtarı eksik, geçersiz veya devre dışı." })
|
||||||
|
export class PublicApiController {
|
||||||
|
constructor(
|
||||||
|
private readonly vehicles: VehiclesService,
|
||||||
|
private readonly categories: CategoriesService,
|
||||||
|
private readonly catalog: CatalogService,
|
||||||
|
private readonly p: PSourceDbService,
|
||||||
|
private readonly quota: PublicApiQuotaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VIN decode. Tek eşleşmede `{ id, brandName, model, year }` döner; birden
|
||||||
|
* çok aday varsa `{ candidates: [...], vin }` döner — istemci seçilen adayın
|
||||||
|
* opak `candidate` anahtarıyla aynı isteği tekrarlar (yalnız bu ikinci,
|
||||||
|
* başarılı çağrı kotadan düşer).
|
||||||
|
*/
|
||||||
|
@Post("vin/decode")
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiTags("VIN")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Şase (VIN) çözümle",
|
||||||
|
description:
|
||||||
|
"17 haneli şase numarasını araca çözer. Tek eşleşmede araç bilgisi döner; " +
|
||||||
|
"birden çok aday varsa `candidates` listesi döner ve seçilen adayın opak " +
|
||||||
|
"`candidate` anahtarıyla istek tekrarlanır. Yalnız başarılı tanımlama kotadan düşer.",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["vin"],
|
||||||
|
properties: {
|
||||||
|
vin: {
|
||||||
|
type: "string",
|
||||||
|
example: "WBA8E1105J5K12345",
|
||||||
|
description: "17 haneli şase numarası (I, O, Q içeremez)",
|
||||||
|
},
|
||||||
|
candidate: {
|
||||||
|
type: "string",
|
||||||
|
description: "Çok-adaylı cevaptan seçilen adayın opak anahtarı",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiOkResponse({
|
||||||
|
description:
|
||||||
|
"Araç tanımlandı (veya aday listesi döndü). Kota durumu X-Decode-Quota-* header'larında.",
|
||||||
|
schema: {
|
||||||
|
oneOf: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
success: { type: "boolean", example: true },
|
||||||
|
data: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", format: "uuid" },
|
||||||
|
brandName: { type: "string", example: "BMW" },
|
||||||
|
model: { type: "string", example: "120d F21 LCI" },
|
||||||
|
year: { type: "number", example: 2018 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
description: "Çok-adaylı cevap",
|
||||||
|
properties: {
|
||||||
|
success: { type: "boolean", example: true },
|
||||||
|
data: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
vin: { type: "string" },
|
||||||
|
candidates: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
key: { type: "string", description: "Opak aday anahtarı" },
|
||||||
|
label: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: "Geçersiz şase numarası." })
|
||||||
|
@ApiResponse({ status: 429, description: "Günlük decode kotası doldu veya istek limiti aşıldı." })
|
||||||
|
async decode(
|
||||||
|
@Req() req: ApiKeyRequest,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
@Body("vin", VinValidationPipe) vin: string,
|
||||||
|
@Body("candidate") candidate?: string,
|
||||||
|
) {
|
||||||
|
const limit = this.quota.resolveLimit(req.apiKey.metadata);
|
||||||
|
await this.quota.assertNotExhausted(req.apiKey.id, limit);
|
||||||
|
|
||||||
|
const result = await this.vehicles.decodeVin(vin, req.user.id, undefined, undefined, candidate);
|
||||||
|
|
||||||
|
// Aday listesi henüz bir "başarılı tanımlama" değil — kotadan düşmez.
|
||||||
|
const isIdentification = !("candidates" in (result as Record<string, unknown>));
|
||||||
|
const state = isIdentification
|
||||||
|
? await this.quota.recordDecode(req.apiKey.id, vin, limit)
|
||||||
|
: await this.quota.getState(req.apiKey.id, limit);
|
||||||
|
this.setQuotaHeaders(res, state);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OEM çapraz-referans: uyumlu ürünler, muadiller ve çapraz OE kodları.
|
||||||
|
* Eşleşme yoksa da 200 + `matched: false` döner. Kotadan düşmez.
|
||||||
|
*/
|
||||||
|
@Get("oem/:code")
|
||||||
|
@ApiTags("OEM")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "OEM çapraz-referans",
|
||||||
|
description:
|
||||||
|
"OEM koduna uyumlu ürünleri, muadilleri ve çapraz OE kodlarını döner. " +
|
||||||
|
"Eşleşme yoksa da 200 + `matched: false` döner. Kotadan düşmez.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "code", example: "1J0973702", description: "OEM parça kodu" })
|
||||||
|
@ApiOkResponse({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
success: { type: "boolean", example: true },
|
||||||
|
data: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string" },
|
||||||
|
matched: { type: "boolean" },
|
||||||
|
articles: { type: "array", items: { type: "object" } },
|
||||||
|
aftermarketParts: { type: "array", items: { type: "object" } },
|
||||||
|
oeCrossReferences: { type: "array", items: { type: "object" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async oem(@Param("code") code: string) {
|
||||||
|
return this.p.lookupByOem(code ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kanonik taksonomi: araçtan bağımsız, kaynak-bağımsız SABİT kategori yapısı
|
||||||
|
* (20 üst kova + kürate alt-kategoriler). B2C entegrasyonların kendi
|
||||||
|
* navigasyon/filtre yapısını kurduğu stabil kontrat — araç ağaçlarındaki
|
||||||
|
* canonical.slug değerleri bu yapıya çözümlenir. Kotadan düşmez.
|
||||||
|
*/
|
||||||
|
@Get("taxonomy")
|
||||||
|
@ApiTags("Katalog")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Kanonik taksonomi (sabit yapı)",
|
||||||
|
description:
|
||||||
|
"Araçtan bağımsız birleşik kategori yapısını döner: 20 üst kova ve her " +
|
||||||
|
"birinin kürate alt-kategorileri ({ id, slug, name, subcategories[] }). " +
|
||||||
|
"Araç kategori ağacındaki canonical.slug değerleri bu yapıya çözümlenir; " +
|
||||||
|
"kendi navigasyonunuzu/filtrelerinizi bu stabil kontrat üzerine kurun. " +
|
||||||
|
"`mappedCategories` her düğüme halihazırda eşlenmiş kaynak-kategori " +
|
||||||
|
"sayısıdır (doluluk sinyali). Kotadan düşmez.",
|
||||||
|
})
|
||||||
|
async taxonomy() {
|
||||||
|
return this.categories.getCanonicalTaxonomy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode edilmiş aracın kategori ağacı. **Varsayılan = birleşik (kanonik)
|
||||||
|
* görünüm:** kaynak (parts-catalogs/emex/pl24) fark etmeksizin tek, normalize
|
||||||
|
* 20-kova taksonomisi + alt-kategoriler. `?view=raw` ile aracın ham kaynak
|
||||||
|
* ağacı alınır (kaynağa özgü, düzensiz — plaka-kodu yaprakları olan bazı yeni
|
||||||
|
* PSA/Renault kataloglarında birleşik görünüm zayıfsa kaçış kapısı). Gezinme
|
||||||
|
* kotadan düşmez. `source` gibi iç alanlar hiçbir görünümde dönmez.
|
||||||
|
*/
|
||||||
|
@Get("vehicles/:vehicleId/categories")
|
||||||
|
@ApiTags("Katalog")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Kategori ağacı (varsayılan: birleşik/kanonik)",
|
||||||
|
description:
|
||||||
|
"Varsayılan olarak kaynak-bağımsız **birleşik** taksonomiyi döner: 20 üst " +
|
||||||
|
"kova ({ canonical:{slug,name}, partCount, categories[], subgroups[] }); " +
|
||||||
|
"her yaprak `categories[].id` ile kategori-detay ucuna drill edilir. " +
|
||||||
|
"`?view=raw` aracın ham kaynak ağacını döner ({ id, name, partCount, " +
|
||||||
|
"children[] }). Ağaç soğuksa ilk çağrıda tohumlanır, arka planda derinleşir.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "vehicleId", format: "uuid", description: "Decode cevabındaki araç id'si" })
|
||||||
|
@ApiQuery({
|
||||||
|
name: "view",
|
||||||
|
required: false,
|
||||||
|
enum: ["canonical", "raw"],
|
||||||
|
description: "Varsayılan `canonical` (birleşik). `raw` = ham kaynak ağacı.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 404, description: "Araç bulunamadı." })
|
||||||
|
async categoryTree(@Param("vehicleId") vehicleId: string, @Query("view") view?: string) {
|
||||||
|
// Decode edilmiş araçlar paylaşımlı — anahtar sahibi her kullanıcı okuyabilir
|
||||||
|
// (dashboard'daki kuralla aynı); 404 için varlık kontrolü.
|
||||||
|
await this.vehicles.getById(vehicleId);
|
||||||
|
if (view === "raw") {
|
||||||
|
return sanitizeRawTree(await this.categories.getCategoryTree(vehicleId));
|
||||||
|
}
|
||||||
|
// getCanonicalTree içeride getCategoryTree'yi çağırır (ham ağacı tohumlar),
|
||||||
|
// sonra 20 kanonik kova altında gruplar (materyalize + anlık lexicon fallback).
|
||||||
|
return sanitizeCanonicalTree(await this.categories.getCanonicalTree(vehicleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kategori detayı + parça listesi (patlak şema görselleri dahil). Alt grubu
|
||||||
|
* olan kategorilerde `children`, yaprakta `parts` dolu döner.
|
||||||
|
*/
|
||||||
|
@Get("vehicles/:vehicleId/categories/:categoryId")
|
||||||
|
@ApiTags("Katalog")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Kategori detayı + parçalar",
|
||||||
|
description:
|
||||||
|
"Kategori ağacındaki bir düğümün detayını döner: alt gruplar, parça listesi " +
|
||||||
|
"(OEM kodları, pozisyon numaraları) ve şema görselleri. Kotadan düşmez.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "vehicleId", format: "uuid" })
|
||||||
|
@ApiParam({ name: "categoryId", format: "uuid", description: "Ağaçtan gelen kategori id'si" })
|
||||||
|
@ApiResponse({ status: 404, description: "Araç veya kategori bulunamadı." })
|
||||||
|
async categoryParts(
|
||||||
|
@Param("vehicleId") vehicleId: string,
|
||||||
|
@Param("categoryId") categoryId: string,
|
||||||
|
) {
|
||||||
|
await this.vehicles.getById(vehicleId);
|
||||||
|
const detail = await this.categories.getCategoryWithParts(categoryId);
|
||||||
|
// Parça kayıtları iç alanlar taşır (source=upstream sağlayıcı adı, presel,
|
||||||
|
// ham fiyat vb.) — decode kontratıyla aynı disiplin: public API'den yalnız
|
||||||
|
// beyaz-listedeki alanlar çıkar. Yeni eklenen iç alanlar otomatik sızamaz.
|
||||||
|
return {
|
||||||
|
...detail,
|
||||||
|
parts: (detail.parts ?? []).map((p: Record<string, unknown>) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
oemCode: p.oemCode,
|
||||||
|
position: p.position ?? p.hotspotIndex ?? null,
|
||||||
|
quantity: p.quantity,
|
||||||
|
remark: p.remark,
|
||||||
|
unavailable: p.unavailable,
|
||||||
|
categoryId: p.categoryId,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Araç kataloğunda serbest metin arama (kategori + parça adı/OEM kodu). */
|
||||||
|
@Get("vehicles/:vehicleId/search")
|
||||||
|
@ApiTags("Katalog")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Katalogda ara",
|
||||||
|
description: "Aracın tüm kataloğunda kategori ve parça (ad/OEM kodu) araması. Kotadan düşmez.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "vehicleId", format: "uuid" })
|
||||||
|
@ApiQuery({ name: "q", example: "fren balatası", description: "Arama metni" })
|
||||||
|
@ApiResponse({ status: 404, description: "Araç bulunamadı." })
|
||||||
|
async searchCatalog(@Param("vehicleId") vehicleId: string, @Query("q") q: string) {
|
||||||
|
await this.vehicles.getById(vehicleId);
|
||||||
|
return this.categories.searchCatalog(vehicleId, q ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Katalog gezinme (VIN'siz araç ağacı). VIN decode ile AYNI kategori/parça
|
||||||
|
// akışına ulaşır; fark: araç, marka→model→(kasa/motor/şanzıman) seçimiyle
|
||||||
|
// belirlenir. Marka erişimi API-anahtarı sahibinin (user.id) aboneliğine
|
||||||
|
// bağlıdır. Gezinme kotadan düşmez (yalnız başarılı VIN identification düşer).
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/** Katalog markaları (anahtar sahibinin erişebildiği). */
|
||||||
|
@Get("catalog/brands")
|
||||||
|
@ApiTags("Katalog Gezinme")
|
||||||
|
@ApiOperation({ summary: "Markalar", description: "Araç ağacı wizard'ı için marka listesi." })
|
||||||
|
async catalogBrands(@Req() req: ApiKeyRequest) {
|
||||||
|
return this.catalog.getBrands(req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bir markanın modelleri (araçları). */
|
||||||
|
@Get("catalog/brands/:brandName/models")
|
||||||
|
@ApiTags("Katalog Gezinme")
|
||||||
|
@ApiOperation({ summary: "Modeller", description: "Seçilen markanın model/araç listesi." })
|
||||||
|
@ApiParam({ name: "brandName", example: "Fiat" })
|
||||||
|
@ApiQuery({ name: "service", required: false, description: "Katalog servis adı (opsiyonel filtre)" })
|
||||||
|
async catalogModels(
|
||||||
|
@Req() req: ApiKeyRequest,
|
||||||
|
@Param("brandName") brandName: string,
|
||||||
|
@Query("service") service?: string,
|
||||||
|
) {
|
||||||
|
return this.catalog.getModels(brandName, req.user.id, service);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Araç detayı (mimari + seçim adımları için meta). */
|
||||||
|
@Get("catalog/vehicles/:id")
|
||||||
|
@ApiTags("Katalog Gezinme")
|
||||||
|
@ApiOperation({ summary: "Araç detayı" })
|
||||||
|
@ApiParam({ name: "id", format: "uuid" })
|
||||||
|
async catalogVehicle(@Req() req: ApiKeyRequest, @Param("id") id: string) {
|
||||||
|
return this.catalog.getVehicle(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ford model konfigürasyonu (Ford mimarisi). */
|
||||||
|
@Get("catalog/vehicles/:id/ford-config")
|
||||||
|
@ApiTags("Katalog Gezinme")
|
||||||
|
async catalogFordConfig(@Req() req: ApiKeyRequest, @Param("id") id: string) {
|
||||||
|
return this.catalog.getFordModelConfig(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PSA kasa seçenekleri. */
|
||||||
|
@Get("catalog/vehicles/:id/psa-bodies")
|
||||||
|
@ApiTags("Katalog Gezinme")
|
||||||
|
async catalogPsaBodies(@Req() req: ApiKeyRequest, @Param("id") id: string) {
|
||||||
|
return this.catalog.getPsaBodies(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PSA motor seçenekleri (kasa seçimine bağlı). */
|
||||||
|
@Get("catalog/vehicles/:id/psa-engines")
|
||||||
|
@ApiTags("Katalog Gezinme")
|
||||||
|
@ApiQuery({ name: "body", description: "Seçilen kasa" })
|
||||||
|
async catalogPsaEngines(
|
||||||
|
@Req() req: ApiKeyRequest,
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Query("body") body: string,
|
||||||
|
) {
|
||||||
|
return this.catalog.getPsaEngines(id, body, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PSA şanzıman seçenekleri (kasa + motor seçimine bağlı). */
|
||||||
|
@Get("catalog/vehicles/:id/psa-gearboxes")
|
||||||
|
@ApiTags("Katalog Gezinme")
|
||||||
|
@ApiQuery({ name: "body" })
|
||||||
|
@ApiQuery({ name: "engine" })
|
||||||
|
async catalogPsaGearboxes(
|
||||||
|
@Req() req: ApiKeyRequest,
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Query("body") body: string,
|
||||||
|
@Query("engine") engine: string,
|
||||||
|
) {
|
||||||
|
return this.catalog.getPsaGearboxes(id, body, engine, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seçilen aracın kategori ağacı (birleşik/kanonik). Public v1 kontratıyla
|
||||||
|
* aynı şekil ({ canonical, partCount, categories[], subgroups[] }); iç
|
||||||
|
* alanlar (source vb.) sanitize edilir. Kasa/motor/şanzıman gerektiren
|
||||||
|
* mimarilerde query ile verilir.
|
||||||
|
*/
|
||||||
|
@Get("catalog/vehicles/:id/categories")
|
||||||
|
@ApiTags("Katalog Gezinme")
|
||||||
|
@ApiOperation({ summary: "Kategori ağacı (araç seçiminden)" })
|
||||||
|
@ApiQuery({ name: "body", required: false })
|
||||||
|
@ApiQuery({ name: "engine", required: false })
|
||||||
|
@ApiQuery({ name: "gearbox", required: false })
|
||||||
|
@ApiQuery({ name: "mgp", required: false, description: "mainGroupsPath" })
|
||||||
|
async catalogCategoryTree(
|
||||||
|
@Req() req: ApiKeyRequest,
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Query("body") body?: string,
|
||||||
|
@Query("engine") engine?: string,
|
||||||
|
@Query("gearbox") gearbox?: string,
|
||||||
|
@Query("mgp") mgp?: string,
|
||||||
|
) {
|
||||||
|
return sanitizeCanonicalTree(
|
||||||
|
(await this.catalog.getCanonicalTree(id, req.user.id, body, engine, gearbox, mgp)) as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seçilen aracın kategori detayı + parçaları (OEM kodlarıyla). */
|
||||||
|
@Get("catalog/vehicles/:id/categories/:categoryId")
|
||||||
|
@ApiTags("Katalog Gezinme")
|
||||||
|
@ApiQuery({ name: "body", required: false })
|
||||||
|
@ApiQuery({ name: "engine", required: false })
|
||||||
|
@ApiQuery({ name: "gearbox", required: false })
|
||||||
|
async catalogCategoryParts(
|
||||||
|
@Req() req: ApiKeyRequest,
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Param("categoryId") categoryId: string,
|
||||||
|
@Query("body") body?: string,
|
||||||
|
@Query("engine") engine?: string,
|
||||||
|
@Query("gearbox") gearbox?: string,
|
||||||
|
) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const detail: any = await this.catalog.getCategoryWithParts(
|
||||||
|
id,
|
||||||
|
categoryId,
|
||||||
|
req.user.id,
|
||||||
|
body,
|
||||||
|
engine,
|
||||||
|
gearbox,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...detail,
|
||||||
|
parts: (detail.parts ?? []).map((p: Record<string, unknown>) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
oemCode: p.oemCode,
|
||||||
|
position: p.position ?? p.hotspotIndex ?? null,
|
||||||
|
quantity: p.quantity,
|
||||||
|
remark: p.remark,
|
||||||
|
unavailable: p.unavailable,
|
||||||
|
categoryId: p.categoryId,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private setQuotaHeaders(res: Response, state: DecodeQuotaState): void {
|
||||||
|
res.setHeader("X-Decode-Quota-Limit", String(state.limit));
|
||||||
|
res.setHeader("X-Decode-Quota-Used", String(state.used));
|
||||||
|
res.setHeader("X-Decode-Quota-Remaining", String(state.remaining));
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/api/src/public-api/public-api.module.ts
Normal file
20
apps/api/src/public-api/public-api.module.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { CatalogModule } from "../catalog/catalog.module";
|
||||||
|
import { CategoriesModule } from "../categories/categories.module";
|
||||||
|
import { ApiKeyGuard } from "../common/guards/api-key.guard";
|
||||||
|
import { PModule } from "../integrations/p/p.module";
|
||||||
|
import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||||
|
import { PublicApiQuotaService } from "./public-api-quota.service";
|
||||||
|
import { PublicApiController } from "./public-api.controller";
|
||||||
|
import { WidgetController } from "./widget.controller";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Müşteri entegrasyonlarına açık Public API (/api/v1). Kimlik better-auth
|
||||||
|
* apiKey plugin'i + ApiKeyGuard; iç servisler aynen yeniden kullanılır.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [VehiclesModule, CategoriesModule, CatalogModule, PModule],
|
||||||
|
controllers: [PublicApiController, WidgetController],
|
||||||
|
providers: [ApiKeyGuard, PublicApiQuotaService],
|
||||||
|
})
|
||||||
|
export class PublicApiModule {}
|
||||||
90
apps/api/src/public-api/public-api.sanitize.spec.ts
Normal file
90
apps/api/src/public-api/public-api.sanitize.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { sanitizeCanonicalTree, sanitizeRawTree } from "./public-api.sanitize";
|
||||||
|
|
||||||
|
describe("sanitizeCanonicalTree", () => {
|
||||||
|
it("keeps canonical headers + id/name/partCount, drops source from leaves & subgroups", () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
canonical: { id: "b1", slug: "motor", name: "Motor" },
|
||||||
|
partCount: 417,
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
id: "c1",
|
||||||
|
name: "Krank Mili",
|
||||||
|
source: "parts-catalogs",
|
||||||
|
partCount: 199,
|
||||||
|
canonicalId: "b1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subgroups: [
|
||||||
|
{
|
||||||
|
canonical: { id: "s1", slug: "motor-conta", name: "Conta" },
|
||||||
|
partCount: 12,
|
||||||
|
categories: [
|
||||||
|
{ id: "c2", name: "Silindir Kapağı Contası", source: "emex", partCount: 12 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const out = sanitizeCanonicalTree(input) as any[];
|
||||||
|
const flat = JSON.stringify(out);
|
||||||
|
expect(flat).not.toContain("source");
|
||||||
|
expect(flat).not.toContain("parts-catalogs");
|
||||||
|
expect(flat).not.toContain("canonicalId");
|
||||||
|
expect(out[0].canonical).toEqual({ id: "b1", slug: "motor", name: "Motor" });
|
||||||
|
expect(out[0].categories[0]).toEqual({ id: "c1", name: "Krank Mili", partCount: 199 });
|
||||||
|
expect(out[0].subgroups[0].categories[0]).toEqual({
|
||||||
|
id: "c2",
|
||||||
|
name: "Silindir Kapağı Contası",
|
||||||
|
partCount: 12,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles missing subgroups / empty categories / null partCount", () => {
|
||||||
|
const out = sanitizeCanonicalTree([
|
||||||
|
{ canonical: { id: "b1", slug: "fren", name: "Fren" }, categories: [] },
|
||||||
|
]) as any[];
|
||||||
|
expect(out[0]).toEqual({
|
||||||
|
canonical: { id: "b1", slug: "fren", name: "Fren" },
|
||||||
|
partCount: 0,
|
||||||
|
categories: [],
|
||||||
|
});
|
||||||
|
expect("subgroups" in out[0]).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeRawTree", () => {
|
||||||
|
it("whitelists id/name/partCount/children recursively, drops source & linkWid", () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
id: "n1",
|
||||||
|
name: "Motor",
|
||||||
|
source: "pl24",
|
||||||
|
linkWid: "WID-123",
|
||||||
|
canonicalCategoryId: "b1",
|
||||||
|
partCount: 50,
|
||||||
|
children: [
|
||||||
|
{ id: "n2", name: "Silindir", source: "pl24", linkWid: "WID-456", partCount: 20 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const out = sanitizeRawTree(input as never) as any[];
|
||||||
|
const flat = JSON.stringify(out);
|
||||||
|
expect(flat).not.toContain("source");
|
||||||
|
expect(flat).not.toContain("linkWid");
|
||||||
|
expect(flat).not.toContain("WID-");
|
||||||
|
expect(flat).not.toContain("canonicalCategoryId");
|
||||||
|
expect(out[0]).toEqual({
|
||||||
|
id: "n1",
|
||||||
|
name: "Motor",
|
||||||
|
partCount: 50,
|
||||||
|
children: [{ id: "n2", name: "Silindir", partCount: 20 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaf node without children omits the children key", () => {
|
||||||
|
const out = sanitizeRawTree([{ id: "n1", name: "X", partCount: 3 }] as never) as any[];
|
||||||
|
expect("children" in out[0]).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
71
apps/api/src/public-api/public-api.sanitize.ts
Normal file
71
apps/api/src/public-api/public-api.sanitize.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Public API kategori-ağacı temizleyicileri.
|
||||||
|
*
|
||||||
|
* Decode kontratıyla aynı disiplin: public yüzeyden yalnız beyaz-listedeki
|
||||||
|
* alanlar çıkar. Özellikle `source` (upstream sağlayıcı adı: parts-catalogs /
|
||||||
|
* emex / pl24) ve `linkWid` (upstream widget id'si) gibi iç alanlar ASLA
|
||||||
|
* sızmaz — hem kanonik hem ham görünümde.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Kanonik görünümdeki bir yaprak kategori (drill için `id` korunur). */
|
||||||
|
interface PublicLeaf {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
partCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CanonicalNode {
|
||||||
|
canonical?: { id: string | null; slug: string; name: string };
|
||||||
|
partCount?: number;
|
||||||
|
categories?: Array<{ id: string; name: string; partCount?: number }>;
|
||||||
|
subgroups?: CanonicalNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaf = (c: { id: string; name: string; partCount?: number }): PublicLeaf => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
partCount: c.partCount ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kanonik kova ağacını temizler: kova/alt-grup başlıkları (kanonik taksonomi —
|
||||||
|
* bizim, sızıntı değil) + yaprak kategorilerden yalnız id/name/partCount.
|
||||||
|
* `source` her yaprakta düşürülür.
|
||||||
|
*/
|
||||||
|
export function sanitizeCanonicalTree(groups: CanonicalNode[]): unknown[] {
|
||||||
|
return (groups ?? []).map((g) => ({
|
||||||
|
canonical: g.canonical,
|
||||||
|
partCount: g.partCount ?? 0,
|
||||||
|
categories: (g.categories ?? []).map(leaf),
|
||||||
|
...(g.subgroups
|
||||||
|
? {
|
||||||
|
subgroups: g.subgroups.map((s) => ({
|
||||||
|
canonical: s.canonical,
|
||||||
|
partCount: s.partCount ?? 0,
|
||||||
|
categories: (s.categories ?? []).map(leaf),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
partCount?: number;
|
||||||
|
children?: RawNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ham kaynak ağacını (view=raw) temizler: her düğümden yalnız
|
||||||
|
* id/name/partCount + (varsa) children. `source`/`linkWid`/`canonicalCategoryId`
|
||||||
|
* gibi tüm iç kolonlar düşer.
|
||||||
|
*/
|
||||||
|
export function sanitizeRawTree(nodes: RawNode[]): unknown[] {
|
||||||
|
return (nodes ?? []).map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
name: n.name,
|
||||||
|
partCount: n.partCount ?? 0,
|
||||||
|
...(Array.isArray(n.children) ? { children: sanitizeRawTree(n.children) } : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
382
apps/api/src/public-api/widget.assets.ts
Normal file
382
apps/api/src/public-api/widget.assets.ts
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
/**
|
||||||
|
* Widget statik varlıkları — build/asset-copy derdi olmasın diye TS template
|
||||||
|
* string olarak gömülü (dist yalnız .js taşır). İki parça:
|
||||||
|
*
|
||||||
|
* - LOADER_JS: müşterinin sitesine yapıştırdığı tek <script>. Iframe'i kurar,
|
||||||
|
* postMessage köprüsünü işletir (yükseklik + partClick/decoded olayları) ve
|
||||||
|
* parça tıklamasını müşterinin verdiği {code}/{brand} şablonuna yönlendirir.
|
||||||
|
* - WIDGET_HTML: iframe içindeki bağımsız katalog uygulaması (vanilla JS).
|
||||||
|
* Public API'yi (/api/v1) publishable anahtarla, same-origin çağırır.
|
||||||
|
* Cookie kullanmaz; kimlik yalnız anahtardır.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const LOADER_JS = `(function () {
|
||||||
|
var s = document.currentScript;
|
||||||
|
if (!s) return;
|
||||||
|
var key = s.getAttribute("data-key") || "";
|
||||||
|
var base = new URL(s.src).origin;
|
||||||
|
var cfg = {
|
||||||
|
vin: s.getAttribute("data-vin") || "",
|
||||||
|
hideVinForm: s.getAttribute("data-hide-vin-form") === "true",
|
||||||
|
partClickUrl: s.getAttribute("data-part-click-url") || "",
|
||||||
|
theme: s.getAttribute("data-theme") || "auto"
|
||||||
|
};
|
||||||
|
var targetSel = s.getAttribute("data-target");
|
||||||
|
var mount = targetSel ? document.querySelector(targetSel) : null;
|
||||||
|
|
||||||
|
var iframe = document.createElement("iframe");
|
||||||
|
iframe.src =
|
||||||
|
base +
|
||||||
|
"/widget?key=" +
|
||||||
|
encodeURIComponent(key) +
|
||||||
|
"&cfg=" +
|
||||||
|
encodeURIComponent(btoa(unescape(encodeURIComponent(JSON.stringify(cfg)))));
|
||||||
|
iframe.style.cssText = "width:100%;border:0;display:block;min-height:420px";
|
||||||
|
iframe.setAttribute("title", "sase.tr par\\u00e7a katalo\\u011fu");
|
||||||
|
iframe.setAttribute("loading", "lazy");
|
||||||
|
|
||||||
|
if (mount) mount.appendChild(iframe);
|
||||||
|
else s.parentNode.insertBefore(iframe, s.nextSibling);
|
||||||
|
|
||||||
|
window.addEventListener("message", function (e) {
|
||||||
|
if (e.origin !== base || !e.data || e.source !== iframe.contentWindow) return;
|
||||||
|
var d = e.data;
|
||||||
|
var api = window.saseWidget || {};
|
||||||
|
if (d.type === "sase:height" && typeof d.value === "number") {
|
||||||
|
iframe.style.height = Math.max(320, d.value) + "px";
|
||||||
|
} else if (d.type === "sase:decoded") {
|
||||||
|
if (typeof api.onDecode === "function") api.onDecode(d.vehicle);
|
||||||
|
} else if (d.type === "sase:partClick" && d.part) {
|
||||||
|
if (typeof api.onPartClick === "function" && api.onPartClick(d.part) === false) return;
|
||||||
|
if (cfg.partClickUrl) {
|
||||||
|
var u = cfg.partClickUrl
|
||||||
|
.replace("{code}", encodeURIComponent(d.part.code || ""))
|
||||||
|
.replace("{brand}", encodeURIComponent(d.part.brand || ""));
|
||||||
|
window.open(u, "_blank", "noopener");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const WIDGET_HTML = `<!doctype html>
|
||||||
|
<html lang="tr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<title>sase.tr parça kataloğu</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg:#ffffff; --fg:#111827; --muted:#6b7280; --line:#e5e7eb;
|
||||||
|
--card:#f9fafb; --accent:#2563eb; --accent-fg:#ffffff; --err:#dc2626;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg:#0b1220; --fg:#e5e7eb; --muted:#94a3b8; --line:#1f2a44;
|
||||||
|
--card:#111a2e; --accent:#3b82f6; --accent-fg:#ffffff; --err:#f87171;
|
||||||
|
}
|
||||||
|
* { box-sizing:border-box; margin:0 }
|
||||||
|
body { background:var(--bg); color:var(--fg);
|
||||||
|
font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; padding:12px }
|
||||||
|
.row { display:flex; gap:8px; flex-wrap:wrap; align-items:center }
|
||||||
|
input[type=text] { flex:1; min-width:220px; padding:10px 12px; border:1px solid var(--line);
|
||||||
|
border-radius:8px; background:var(--bg); color:var(--fg); font-size:15px;
|
||||||
|
letter-spacing:.06em; text-transform:uppercase }
|
||||||
|
button { padding:10px 16px; border:0; border-radius:8px; background:var(--accent);
|
||||||
|
color:var(--accent-fg); font-weight:600; cursor:pointer; font-size:14px }
|
||||||
|
button:disabled { opacity:.5; cursor:default }
|
||||||
|
button.ghost { background:transparent; color:var(--accent); padding:6px 8px }
|
||||||
|
.muted { color:var(--muted); font-size:12px }
|
||||||
|
.err { color:var(--err); margin-top:8px; font-size:13px }
|
||||||
|
.vehicle { margin:12px 0 4px; padding:10px 12px; background:var(--card);
|
||||||
|
border:1px solid var(--line); border-radius:10px; display:flex; gap:10px;
|
||||||
|
align-items:baseline; flex-wrap:wrap }
|
||||||
|
.vehicle b { font-size:15px }
|
||||||
|
.crumbs { margin:10px 0 6px; font-size:12px; color:var(--muted); display:flex;
|
||||||
|
flex-wrap:wrap; gap:4px; align-items:center }
|
||||||
|
.crumbs a { color:var(--accent); cursor:pointer; text-decoration:none }
|
||||||
|
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr));
|
||||||
|
gap:8px; margin-top:8px }
|
||||||
|
.cat { padding:10px 12px; background:var(--card); border:1px solid var(--line);
|
||||||
|
border-radius:10px; cursor:pointer; transition:border-color .12s }
|
||||||
|
.cat:hover { border-color:var(--accent) }
|
||||||
|
.cat .n { font-weight:600; font-size:13px }
|
||||||
|
table { width:100%; border-collapse:collapse; margin-top:8px; font-size:13px }
|
||||||
|
th { text-align:left; color:var(--muted); font-weight:600; font-size:12px;
|
||||||
|
padding:6px 8px; border-bottom:1px solid var(--line) }
|
||||||
|
td { padding:8px; border-bottom:1px solid var(--line); vertical-align:top }
|
||||||
|
tr.part { cursor:pointer } tr.part:hover td { background:var(--card) }
|
||||||
|
.oem { font-family:ui-monospace,Menlo,Consolas,monospace; font-weight:600;
|
||||||
|
color:var(--accent); white-space:nowrap }
|
||||||
|
.schema { max-width:100%; border:1px solid var(--line); border-radius:10px; margin-top:10px }
|
||||||
|
.cands { margin-top:10px; display:flex; flex-direction:column; gap:6px }
|
||||||
|
.cand { text-align:left; background:var(--card); color:var(--fg); border:1px solid var(--line) }
|
||||||
|
.cand:hover { border-color:var(--accent) }
|
||||||
|
.spin { display:inline-block; width:14px; height:14px; border:2px solid var(--line);
|
||||||
|
border-top-color:var(--accent); border-radius:50%; animation:r 0.7s linear infinite;
|
||||||
|
vertical-align:-2px; margin-right:6px }
|
||||||
|
@keyframes r { to { transform:rotate(360deg) } }
|
||||||
|
.foot { margin-top:14px; text-align:right }
|
||||||
|
.foot a { color:var(--muted); font-size:11px; text-decoration:none }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<div class="foot"><a href="https://sase.tr" target="_blank" rel="noopener">sase.tr altyapısıyla</a></div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
var qs = new URLSearchParams(location.search);
|
||||||
|
var KEY = qs.get("key") || "";
|
||||||
|
var CFG = {};
|
||||||
|
try { CFG = JSON.parse(decodeURIComponent(escape(atob(qs.get("cfg") || "")))); } catch (e) {}
|
||||||
|
var THEME = CFG.theme || "auto";
|
||||||
|
if (THEME === "auto") {
|
||||||
|
THEME = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
document.documentElement.dataset.theme = THEME;
|
||||||
|
|
||||||
|
var app = document.getElementById("app");
|
||||||
|
var state = { vehicle: null, buckets: [], path: [], view: null, quotaMsg: "" };
|
||||||
|
|
||||||
|
function post(type, extra) {
|
||||||
|
var m = { type: type };
|
||||||
|
for (var k in extra) m[k] = extra[k];
|
||||||
|
parent.postMessage(m, "*");
|
||||||
|
}
|
||||||
|
new ResizeObserver(function () {
|
||||||
|
post("sase:height", { value: document.documentElement.scrollHeight });
|
||||||
|
}).observe(document.body);
|
||||||
|
|
||||||
|
function api(path, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
opts.headers = Object.assign({ Authorization: "Bearer " + KEY }, opts.headers || {});
|
||||||
|
return fetch(path, opts).then(function (r) {
|
||||||
|
return r.json().then(function (j) {
|
||||||
|
if (!r.ok) {
|
||||||
|
var msg = (j && j.error && j.error.message) || (j && j.message) || ("Hata (" + r.status + ")");
|
||||||
|
var e = new Error(msg); e.status = r.status; throw e;
|
||||||
|
}
|
||||||
|
return j.data !== undefined ? j.data : j;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(tag, attrs, children) {
|
||||||
|
var n = document.createElement(tag);
|
||||||
|
for (var k in attrs || {}) {
|
||||||
|
if (k === "onclick") n.addEventListener("click", attrs[k]);
|
||||||
|
else if (k === "text") n.textContent = attrs[k];
|
||||||
|
else if (k === "html") n.innerHTML = attrs[k];
|
||||||
|
else n.setAttribute(k, attrs[k]);
|
||||||
|
}
|
||||||
|
(children || []).forEach(function (c) { c && n.appendChild(c); });
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── VIN formu ───────────────────────────────────────
|
||||||
|
var vinInput, vinBtn, errBox, resultBox;
|
||||||
|
function renderShell() {
|
||||||
|
app.textContent = "";
|
||||||
|
if (!CFG.hideVinForm) {
|
||||||
|
vinInput = el("input", { type: "text", placeholder: "Müşteri aracının şase (VIN) numarası", maxlength: "17", value: CFG.vin || "" });
|
||||||
|
// decode'u doğrudan handler yapma: click event'i "candidate" parametresi
|
||||||
|
// sanılır ve sunucu aday-anahtarı çözmeye çalışır.
|
||||||
|
vinBtn = el("button", { text: "Sorgula", onclick: function () { decode(); } });
|
||||||
|
vinInput.addEventListener("keydown", function (e) { if (e.key === "Enter") decode(); });
|
||||||
|
app.appendChild(el("div", { class: "row" }, [vinInput, vinBtn]));
|
||||||
|
app.appendChild(el("div", { class: "muted", text: "17 haneli şase numarasıyla orijinal parça kataloğuna ulaşın." }));
|
||||||
|
}
|
||||||
|
errBox = el("div", { class: "err" });
|
||||||
|
resultBox = el("div", {});
|
||||||
|
app.appendChild(errBox);
|
||||||
|
app.appendChild(resultBox);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(b, label) {
|
||||||
|
if (vinBtn) { vinBtn.disabled = b; vinBtn.innerHTML = b ? '<span class="spin"></span>' + (label || "Sorgulanıyor…") : "Sorgula"; }
|
||||||
|
}
|
||||||
|
function fail(e) {
|
||||||
|
errBox.textContent = e && e.message ? e.message : "Beklenmeyen bir hata oluştu.";
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode(candidate) {
|
||||||
|
var vin = (CFG.hideVinForm ? CFG.vin : vinInput.value).toUpperCase().trim();
|
||||||
|
if (vin.length !== 17) { errBox.textContent = "Şase numarası 17 karakter olmalı."; return; }
|
||||||
|
errBox.textContent = ""; resultBox.textContent = ""; setBusy(true);
|
||||||
|
var body = { vin: vin };
|
||||||
|
if (typeof candidate === "string" && candidate) body.candidate = candidate;
|
||||||
|
api("/api/v1/vin/decode", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}).then(function (d) {
|
||||||
|
setBusy(false);
|
||||||
|
if (d.candidates) return renderCandidates(vin, d.candidates);
|
||||||
|
state.vehicle = d;
|
||||||
|
post("sase:decoded", { vehicle: d });
|
||||||
|
loadTree();
|
||||||
|
}).catch(fail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCandidates(vin, cands) {
|
||||||
|
resultBox.textContent = "";
|
||||||
|
resultBox.appendChild(el("div", { class: "muted", text: "Birden fazla eşleşme bulundu, aracı seçin:" }));
|
||||||
|
var box = el("div", { class: "cands" });
|
||||||
|
cands.forEach(function (c) {
|
||||||
|
box.appendChild(el("button", { class: "cand", text: c.label || c.title || c.key, onclick: function () { decode(c.key); } }));
|
||||||
|
});
|
||||||
|
resultBox.appendChild(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Katalog (birleşik/kanonik görünüm) ──────────────
|
||||||
|
// /categories varsayılan olarak kaynak-bağımsız kanonik kova ağacını döner:
|
||||||
|
// [ { canonical:{slug,name}, partCount, categories:[{id,name,partCount}],
|
||||||
|
// subgroups:[{ canonical:{slug,name}, partCount, categories:[…] }] } ].
|
||||||
|
// Gezinme: kova → alt-grup → yaprak kategori → (drill) parça listesi.
|
||||||
|
function loadTree() {
|
||||||
|
renderVehicleHeader("Katalog yükleniyor…");
|
||||||
|
api("/api/v1/vehicles/" + state.vehicle.id + "/categories").then(function (buckets) {
|
||||||
|
state.buckets = Array.isArray(buckets) ? buckets : [];
|
||||||
|
state.path = [];
|
||||||
|
renderBuckets();
|
||||||
|
}).catch(fail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVehicleHeader(note) {
|
||||||
|
resultBox.textContent = "";
|
||||||
|
var v = state.vehicle;
|
||||||
|
resultBox.appendChild(el("div", { class: "vehicle" }, [
|
||||||
|
el("b", { text: v.brandName + " " + (v.model || "") }),
|
||||||
|
v.year ? el("span", { class: "muted", text: String(v.year) }) : null,
|
||||||
|
note ? el("span", { class: "muted", html: '<span class="spin"></span>' + note }) : null
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breadcrumb: her seviye {name, render} frame'i taşır; frame'e tıklamak o
|
||||||
|
// seviyeyi (ve altındakileri kırparak) yeniden çizer.
|
||||||
|
function crumbs() {
|
||||||
|
var c = el("div", { class: "crumbs" });
|
||||||
|
c.appendChild(el("a", { text: "Katalog", onclick: function () { state.path = []; renderBuckets(); } }));
|
||||||
|
state.path.forEach(function (p, i) {
|
||||||
|
c.appendChild(el("span", { text: "›" }));
|
||||||
|
c.appendChild(el("a", { text: p.name, onclick: function () {
|
||||||
|
state.path = state.path.slice(0, i + 1);
|
||||||
|
state.path[i].render();
|
||||||
|
} }));
|
||||||
|
});
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kart-grid element'i üretir (başlık/crumb yok). Her öğe {name, partCount, open()}.
|
||||||
|
function gridEl(items) {
|
||||||
|
var g = el("div", { class: "grid" });
|
||||||
|
items.forEach(function (it) {
|
||||||
|
var sub = el("div", { class: "muted", text: it.partCount ? it.partCount + " parça" : "" });
|
||||||
|
g.appendChild(el("div", { class: "cat", onclick: it.open },
|
||||||
|
[el("div", { class: "n", text: it.name }), sub]));
|
||||||
|
});
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bir seviyeyi çiz: başlık + crumb + grid (boşsa mesaj).
|
||||||
|
function grid(items, emptyMsg) {
|
||||||
|
renderVehicleHeader();
|
||||||
|
resultBox.appendChild(crumbs());
|
||||||
|
if (!items.length) {
|
||||||
|
resultBox.appendChild(el("div", { class: "muted", text: emptyMsg ||
|
||||||
|
"Bu araç için katalog hazırlanıyor — birkaç dakika sonra tekrar deneyin." }));
|
||||||
|
} else resultBox.appendChild(gridEl(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bir seviyeye in: frame'i path'e ekle + render et.
|
||||||
|
function drill(name, render) {
|
||||||
|
state.path.push({ name: name, render: render });
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBuckets() {
|
||||||
|
var items = state.buckets
|
||||||
|
.filter(function (b) { return (b.partCount || 0) > 0; })
|
||||||
|
.map(function (b) {
|
||||||
|
return { name: b.canonical.name, partCount: b.partCount, open: function () {
|
||||||
|
drill(b.canonical.name, function () { openBucket(b); });
|
||||||
|
} };
|
||||||
|
});
|
||||||
|
grid(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBucket(b) {
|
||||||
|
var items = [];
|
||||||
|
(b.subgroups || []).forEach(function (sg) {
|
||||||
|
if (!((sg.partCount || 0) > 0 || (sg.categories || []).length)) return;
|
||||||
|
items.push({ name: sg.canonical.name, partCount: sg.partCount, open: function () {
|
||||||
|
drill(sg.canonical.name, function () { openSubgroup(sg); });
|
||||||
|
} });
|
||||||
|
});
|
||||||
|
(b.categories || []).forEach(function (c) { items.push(leafItem(c)); });
|
||||||
|
grid(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSubgroup(sg) {
|
||||||
|
grid((sg.categories || []).map(leafItem));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yaprak kategori = ham katalog düğümü; tıklama /categories/:id ile parça +
|
||||||
|
// şemayı getirir (daha derin ham alt-düğüm varsa onları da gezdirir).
|
||||||
|
function leafItem(c) {
|
||||||
|
return { name: c.name, partCount: c.partCount, open: function () {
|
||||||
|
drill(c.name, function () { openCategory(c); });
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCategory(cat) {
|
||||||
|
renderVehicleHeader("Yükleniyor…");
|
||||||
|
api("/api/v1/vehicles/" + state.vehicle.id + "/categories/" + cat.id).then(function (d) {
|
||||||
|
renderVehicleHeader();
|
||||||
|
resultBox.appendChild(crumbs());
|
||||||
|
var kids = (d.children || []).map(leafItem);
|
||||||
|
if (kids.length) resultBox.appendChild(gridEl(kids));
|
||||||
|
if (d.schemaPics && d.schemaPics.length) {
|
||||||
|
// schemaPics öğeleri {id,url,label,...} nesneleri — src'ye .url gider.
|
||||||
|
var pic = d.schemaPics[0];
|
||||||
|
var img = el("img", { class: "schema", src: pic.url || "", alt: pic.label || d.name || "şema" });
|
||||||
|
img.addEventListener("error", function () { img.remove(); });
|
||||||
|
resultBox.appendChild(img);
|
||||||
|
}
|
||||||
|
if (d.parts && d.parts.length) renderParts(d.parts);
|
||||||
|
if (!kids.length && (!d.parts || !d.parts.length)) {
|
||||||
|
resultBox.appendChild(el("div", { class: "muted", text: "Bu bölüm için parça listesi hazırlanıyor — az sonra tekrar deneyin." }));
|
||||||
|
}
|
||||||
|
}).catch(fail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderParts(parts) {
|
||||||
|
var t = el("table", {}, [
|
||||||
|
el("thead", {}, [el("tr", {}, [
|
||||||
|
el("th", { text: "No" }), el("th", { text: "Parça" }), el("th", { text: "OEM Kodu" })
|
||||||
|
])]),
|
||||||
|
]);
|
||||||
|
var tb = el("tbody", {});
|
||||||
|
parts.forEach(function (p) {
|
||||||
|
var code = p.oemCode || p.number || "";
|
||||||
|
tb.appendChild(el("tr", { class: "part", onclick: function () {
|
||||||
|
post("sase:partClick", { part: { code: code, brand: state.vehicle.brandName, name: p.name || "" } });
|
||||||
|
} }, [
|
||||||
|
el("td", { class: "muted", text: p.position != null ? String(p.position) : "" }),
|
||||||
|
el("td", { text: p.name || "" }),
|
||||||
|
el("td", {}, [el("span", { class: "oem", text: code })])
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
t.appendChild(tb);
|
||||||
|
resultBox.appendChild(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderShell();
|
||||||
|
if (CFG.vin && CFG.vin.length === 17) decode();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
119
apps/api/src/public-api/widget.controller.ts
Normal file
119
apps/api/src/public-api/widget.controller.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { Controller, Get, Query, Res } from "@nestjs/common";
|
||||||
|
import { ApiExcludeController } from "@nestjs/swagger";
|
||||||
|
import type { Response } from "express";
|
||||||
|
import { getApiKeyApi } from "../auth/auth";
|
||||||
|
import { Public } from "../common/decorators/public.decorator";
|
||||||
|
import { LOADER_JS, WIDGET_HTML } from "./widget.assets";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gömülebilir katalog widget'ı.
|
||||||
|
*
|
||||||
|
* - GET /widget/loader.js — müşterinin sitesine yapıştırdığı script (cache'li).
|
||||||
|
* - GET /widget?key=sase_pk_... — iframe shell'i. Domain kilidi burada:
|
||||||
|
* anahtar doğrulanır, publishable olmalı ve cevaba anahtarın domain
|
||||||
|
* listesinden üretilen `frame-ancestors` CSP'si yazılır — izinsiz sitede
|
||||||
|
* iframe'i TARAYICI reddeder (Referer sahteciliğinden etkilenmez).
|
||||||
|
*
|
||||||
|
* Publishable (sase_pk_) anahtar sırdır ama "yayınlanabilir" sırdır: müşteri
|
||||||
|
* sayfasının kaynağında görünür. Bu yüzden decode kotası + dakikalık istek
|
||||||
|
* limiti anahtarın gerçek freni, frame-ancestors ise marka/yerleşim kilididir.
|
||||||
|
* Secret (sase_live_) anahtarlar widget'ta ÇALIŞMAZ — publishable şartı var.
|
||||||
|
*/
|
||||||
|
@Controller("widget")
|
||||||
|
@Public()
|
||||||
|
@ApiExcludeController()
|
||||||
|
export class WidgetController {
|
||||||
|
// @Res ile ham gönderim ŞART: string dönseydi global {success,data}
|
||||||
|
// interceptor'ı JSON'a sarar, tarayıcı da JS-MIME'lı JSON gövdeyi ORB ile
|
||||||
|
// bloklardı (canlıda yaşandı).
|
||||||
|
@Get("loader.js")
|
||||||
|
loader(@Res() res: Response): void {
|
||||||
|
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||||
|
// Helmet varsayılanı same-origin — müşteri sitesindeki <script src=...>
|
||||||
|
// cross-origin yüklemesi bununla açılır; yoksa tarayıcı script'i bloklar.
|
||||||
|
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
|
||||||
|
res.send(LOADER_JS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async shell(@Query("key") key: string, @Res() res: Response): Promise<void> {
|
||||||
|
const deny = (msg: string) => {
|
||||||
|
res.status(403).type("html").send(this.errorPage(msg));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!key || !key.startsWith("sase_pk_")) {
|
||||||
|
return deny(
|
||||||
|
"Geçersiz widget anahtarı. Widget için sase_pk_ ile başlayan yayınlanabilir anahtar gerekir.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let verified: Awaited<ReturnType<ReturnType<typeof getApiKeyApi>["verifyApiKey"]>>;
|
||||||
|
try {
|
||||||
|
verified = await getApiKeyApi().verifyApiKey({ body: { key } });
|
||||||
|
} catch {
|
||||||
|
return deny("Widget anahtarı doğrulanamadı.");
|
||||||
|
}
|
||||||
|
if (!verified.valid || !verified.key) {
|
||||||
|
return deny("Widget anahtarı geçersiz, süresi dolmuş veya devre dışı.");
|
||||||
|
}
|
||||||
|
const metadata = verified.key.metadata ?? {};
|
||||||
|
if (metadata.publishable !== true) {
|
||||||
|
return deny(
|
||||||
|
"Bu anahtar widget'ta kullanılamaz. Yayınlanabilir (publishable) anahtar isteyin.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameAncestors = this.buildFrameAncestors(metadata.domains);
|
||||||
|
|
||||||
|
// Bu sayfanın kendi CSP'si global helmet politikasını EZER: inline
|
||||||
|
// script/style'a izin verir (tek dosyalık shell), frame-ancestors'ı
|
||||||
|
// anahtarın domain listesine kilitler. X-Frame-Options kaldırılır —
|
||||||
|
// SAMEORIGIN kalsaydı hiçbir müşteri sitesi iframe'i render edemezdi.
|
||||||
|
res.removeHeader("X-Frame-Options");
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
[
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'unsafe-inline'",
|
||||||
|
"style-src 'unsafe-inline'",
|
||||||
|
"img-src 'self' data: https://storage.sase.tr",
|
||||||
|
"connect-src 'self'",
|
||||||
|
`frame-ancestors ${frameAncestors}`,
|
||||||
|
].join("; "),
|
||||||
|
);
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
res.type("html").send(WIDGET_HTML);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anahtar metadata'sındaki domains listesini frame-ancestors kaynaklarına
|
||||||
|
* çevirir. Şemalı girdiler (https://..., http://localhost:*) aynen geçer;
|
||||||
|
* çıplak host'lar https + www varyantıyla açılır. Liste boşsa yalnız 'self'
|
||||||
|
* kalır — iframe hiçbir 3. parti sitede render olmaz (doğrudan açılış,
|
||||||
|
* frame-ancestors kapsamı dışında olduğu için test amaçlı çalışmaya devam
|
||||||
|
* eder).
|
||||||
|
*/
|
||||||
|
private buildFrameAncestors(domains: unknown): string {
|
||||||
|
const sources = ["'self'"];
|
||||||
|
if (Array.isArray(domains)) {
|
||||||
|
for (const raw of domains) {
|
||||||
|
if (typeof raw !== "string") continue;
|
||||||
|
// CSP kaynak listesine header enjeksiyonunu/ayraç kaçağını engelle.
|
||||||
|
const d = raw.trim().replace(/[;\s,"']/g, "");
|
||||||
|
if (!d) continue;
|
||||||
|
if (d.includes("://")) {
|
||||||
|
sources.push(d);
|
||||||
|
} else {
|
||||||
|
sources.push(`https://${d}`);
|
||||||
|
if (!d.startsWith("www.") && !d.includes("*")) sources.push(`https://www.${d}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sources.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private errorPage(msg: string): string {
|
||||||
|
return `<!doctype html><html lang="tr"><meta charset="utf-8"><body style="font:14px system-ui;color:#374151;padding:24px;text-align:center">${msg.replace(/[<>&]/g, "")}</body></html>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module, forwardRef } from "@nestjs/common";
|
||||||
import { BrandsModule } from "../brands/brands.module";
|
import { BrandsModule } from "../brands/brands.module";
|
||||||
|
import { StripeModule } from "../payments/stripe/stripe.module";
|
||||||
import { PlansModule } from "../plans/plans.module";
|
import { PlansModule } from "../plans/plans.module";
|
||||||
import { SubscriptionsController } from "./subscriptions.controller";
|
import { SubscriptionsController } from "./subscriptions.controller";
|
||||||
import { SubscriptionsService } from "./subscriptions.service";
|
import { SubscriptionsService } from "./subscriptions.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [BrandsModule, PlansModule],
|
// forwardRef: StripeModule imports us back (webhook activation needs
|
||||||
|
// SubscriptionsService; cancel/resume here need StripeService).
|
||||||
|
imports: [BrandsModule, PlansModule, forwardRef(() => StripeModule)],
|
||||||
controllers: [SubscriptionsController],
|
controllers: [SubscriptionsController],
|
||||||
providers: [SubscriptionsService],
|
providers: [SubscriptionsService],
|
||||||
exports: [SubscriptionsService],
|
exports: [SubscriptionsService],
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user